Add inconsiderateLanguage lint rule
MODIFY
packages/@romejs-runtime/rome/test.ts
packages/@romejs-runtime/rome/test.ts
@@ -36,8 +36,8 @@ export interface TestHelper {
notThrowsAsync(nonThrower: AsyncFunc, message?: string): Promise<void>;
regex(contents: string, regex: RegExp, message?: string): void;
notRegex(contents: string, regex: RegExp, message?: string): void;
- snapshot(expected: unknown, message?: string): void;
- snapshotNamed(name: string, expected: unknown, message?: string): void;
+ snapshot(expected: unknown, message?: string): string;
+ snapshotNamed(name: string, expected: unknown, message?: string): string;
getSnapshot(snapshotName: string): unknown;
}

MODIFY
packages/@romejs/core/common/fileHandlers.ts
packages/@romejs/core/common/fileHandlers.ts
@@ -238,7 +238,7 @@ const jsonHandler: ExtensionHandler = {
// should be required everywhere.
// TypeScript is unfortunately included here as it produces an error if you use an
// import source with ".ts"
-export const IMPLICIT_JS_EXTENSIONS = ['js', 'json', 'ts', 'tsx'];
+export const IMPLICIT_JS_EXTENSIONS = ['js', 'ts', 'tsx', 'json'];
// Extensions that have a `lint` handler
export const LINTABLE_EXTENSIONS: Array<string> = [];

MODIFY
packages/@romejs/core/test-worker/TestAPI.ts
packages/@romejs/core/test-worker/TestAPI.ts
@@ -428,13 +428,13 @@ export default class TestAPI implements TestHelper {
throw new Error('unimplemented');
}
- snapshot(expected: unknown, message?: string): void {
+ snapshot(expected: unknown, message?: string): string {
const id = this.snapshotCounter++;
- this._snapshotNamed(String(id), expected, message, 2);
+ return this._snapshotNamed(String(id), expected, message, 2);
}
- snapshotNamed(name: string, expected: unknown, message?: string): void {
- this._snapshotNamed(name, expected, message, 1);
+ snapshotNamed(name: string, expected: unknown, message?: string): string {
+ return this._snapshotNamed(name, expected, message, 1);
}
getSnapshot(snapshotName: string): unknown {
@@ -446,7 +446,7 @@ export default class TestAPI implements TestHelper {
expected: unknown,
message?: string,
framesToPop?: number,
- ): void {
+ ): string {
let language: undefined | string;
let formatted = '';
@@ -467,7 +467,7 @@ export default class TestAPI implements TestHelper {
value: formatted,
language,
});
- return;
+ return name;
}
// Compare the snapshots
@@ -504,5 +504,7 @@ export default class TestAPI implements TestHelper {
this.fail(message, advice, framesToPop);
}
+
+ return name;
}
}

MODIFY
packages/@romejs/diagnostics/categories.ts
packages/@romejs/diagnostics/categories.ts
@@ -27,6 +27,7 @@ export type DiagnosticCategory =
| 'lint/duplicateImportSource'
| 'lint/emptyBlocks'
| 'lint/getterReturn'
+ | 'lint/inconsiderateLanguage'
| 'lint/noArguments'
| 'lint/noAsyncPromiseExecutor'
| 'lint/noCompareNegZero'

MODIFY
packages/@romejs/diagnostics/descriptions.ts
packages/@romejs/diagnostics/descriptions.ts
@@ -264,6 +264,23 @@ export const descriptions = createMessages(
fixable: true,
},
+ INCONSIDERATE_LANGUAGE: (
+ description: string,
+ word: string,
+ suggestion: string,
+ ) => ({
+ fixable: true,
+ category: 'lint/inconsiderateLanguage',
+ message: description,
+ advice: [
+ {
+ type: 'log',
+ category: 'info',
+ message: markup`Instead of <emphasis>${word}</emphasis> use <emphasis>${suggestion}</emphasis>`,
+ },
+ ],
+ }),
+
DOUBLE_EQUALS: {
category: 'lint/doubleEquals',
message: 'Use === instead of ==',

MODIFY
packages/@romejs/js-compiler/api/lint.test.ts
packages/@romejs/js-compiler/api/lint.test.ts
@@ -67,15 +67,17 @@ export async function testLint(t: TestHelper, input: string, {
code: input,
});
- const res = await lint({
- options: {},
- format,
- ast: parseJS({
+ const ast = parseJS({
input,
sourceType,
path: createUnknownFilePath('unknown'),
syntax,
- }),
+ });
+
+ const res = await lint({
+ options: {},
+ format,
+ ast,
sourceText: input,
project: {
folder: undefined,
@@ -95,17 +97,13 @@ export async function testLint(t: TestHelper, input: string, {
};
});
- if (format) {
- t.snapshot({
- ...res,
- diagnostics,
- });
- } else {
- // Nicer snapshot when we don't care about formatting
- t.snapshot(printDiagnosticsToString({
+ const snapshotName = t.snapshot(printDiagnosticsToString({
diagnostics,
suppressions: res.suppressions,
}));
+
+ if (format) {
+ t.snapshotNamed(`${snapshotName}: formatted`, res.src);
}
t.clearAdvice();

ADD
packages/@romejs/js-compiler/transforms/lint/inconsiderateLanguage.json
+[
+ {
+ "description": "Replace racially-charged language with more accurate and inclusive words",
+ "word": "whitelist",
+ "suggestion": "allowlist"
+ },
+ {
+ "description": "Replace racially-charged language with more accurate and inclusive words",
+ "word": "blacklist",
+ "suggestion": "denylist"
+ }
+]

ADD
packages/@romejs/js-compiler/transforms/lint/inconsiderateLanguage.test.ts
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import {test} from 'rome';
+import {testLintMultiple} from '../../api/lint.test';
+
+test('inconsiderate language', async (t) => {
+ await testLintMultiple(t, [
+ '// the blacklist',
+ '/* the\nblacklist */',
+ 'blacklist;',
+ 'BLACKLIST;',
+ 'someBlacklist;',
+ 'SOME_BLACKLIST;',
+ ], {category: 'lint/inconsiderateLanguage', format: true});
+});

ADD
packages/@romejs/js-compiler/transforms/lint/inconsiderateLanguage.ts
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import {Path} from '@romejs/js-compiler';
+import {AnyNode} from '@romejs/js-ast';
+import {SourceLocation, PositionTracker} from '@romejs/parser-core';
+import {coerce0} from '@romejs/ob1';
+import {isIdentifierish} from '@romejs/js-ast-utils';
+import {descriptions} from '@romejs/diagnostics';
+import terms from './inconsiderateLanguage.json';
+import {preserveCasing} from '@romejs/string-utils';
+
+type CheckResult = {
+ loc: SourceLocation;
+ word: string;
+ description: string;
+ suggestion: string;
+ startIndex: number;
+ endIndex: number;
+};
+
+function check(loc: SourceLocation, input: string): {
+ fixed: string;
+ results: Array<CheckResult>;
+} {
+ let fixed = input;
+ const lower = input.toLowerCase();
+
+ // Quick check to see if the string contains any of these terms
+ let check = false;
+ for (const {word} of terms) {
+ if (lower.includes(word)) {
+ check = true;
+ break;
+ }
+ }
+ if (!check) {
+ return {
+ fixed,
+ results: [],
+ };
+ }
+
+ const tracker = new PositionTracker(lower, loc.start);
+ const results: Array<CheckResult> = [];
+
+ // This is a bit more complicated since we try to do the minimal amount of work
+ for (let i = 0; i < lower.length; i++) {
+ const char = lower[i];
+
+ for (const {word, description, suggestion} of terms) {
+ if (char === word[0] && lower.startsWith(word, i)) {
+ const wordWithSourceCasing = input.slice(i, i + word.length);
+
+ results.push({
+ // We want to preserve the original casing
+ word: wordWithSourceCasing,
+ description,
+ suggestion: preserveCasing(wordWithSourceCasing, suggestion),
+ startIndex: i,
+ endIndex: i + word.length,
+
+ // Calculate the actual location of this
+ loc: {
+ ...loc,
+ start: tracker.getPositionFromIndex(coerce0(i)),
+ end: tracker.getPositionFromIndex(coerce0(i + word.length)),
+ },
+ });
+
+ i += word.length;
+ break;
+ }
+ }
+ }
+
+ // Walk backwards through the results, autofixing with the suggestions
+ // Walking backwards means we don't need to maintain offsets
+ for (let i = results.length - 1; i >= 0; i--) {
+ const result = results[i];
+ fixed = fixed.slice(0, result.startIndex) + result.suggestion + fixed.slice(
+ result.endIndex,
+ );
+ }
+
+ return {
+ results,
+ fixed,
+ };
+}
+
+export default {
+ name: 'inconsiderateLanguage',
+ enter(path: Path): AnyNode {
+ const {node, context} = path;
+
+ const {loc} = node;
+ if (loc !== undefined) {
+ // Infer a string to check
+ let value: undefined | string;
+ if (node.type === 'CommentBlock' || node.type === 'CommentLine') {
+ value = node.value;
+ }
+ if (isIdentifierish(node)) {
+ value = node.name;
+ }
+
+ if (value !== undefined) {
+ // Produce diagnostics
+ const {results, fixed} = check(loc, value);
+ let suppressed = false;
+ for (const {loc, word, description, suggestion} of results) {
+ ({suppressed} = context.addLocDiagnostic(
+ loc,
+ descriptions.LINT.INCONSIDERATE_LANGUAGE(
+ description,
+ word,
+ suggestion,
+ ),
+ ));
+
+ if (suppressed) {
+ break;
+ }
+ }
+
+ // Autofix if not suppressed
+ if (results.length > 0 && !suppressed) {
+ if (node.type === 'CommentBlock' || node.type === 'CommentLine') {
+ return {
+ ...node,
+ value: fixed,
+ };
+ }
+
+ if (isIdentifierish(node)) {
+ return {
+ ...node,
+ name: fixed,
+ };
+ }
+ }
+ }
+ }
+
+ return node;
+ },
+};

MODIFY
packages/@romejs/js-compiler/transforms/lint/index.ts
packages/@romejs/js-compiler/transforms/lint/index.ts
@@ -12,6 +12,7 @@ import doubleEquals from './doubleEquals';
import duplicateImportSource from './duplicateImportSource';
import emptyBlocks from './emptyBlocks';
import getterReturn from './getterReturn';
+import inconsiderateLanguage from './inconsiderateLanguage';
import noArguments from './noArguments';
import noAsyncPromiseExecutor from './noAsyncPromiseExecutor';
import noCompareNegZero from './noCompareNegZero';
@@ -53,6 +54,7 @@ export const lintTransforms = [
doubleEquals,
emptyBlocks,
getterReturn,
+ inconsiderateLanguage,
noArguments,
noAsyncPromiseExecutor,
noCompareNegZero,

MODIFY
packages/@romejs/js-parser/parser/statement.ts
packages/@romejs/js-parser/parser/statement.ts
@@ -110,12 +110,9 @@ export function parseTopLevel(parser: JSParser): Program {
const interpreter = parsePossibleInterpreterDirective(parser);
const {body, directives} = parseBlockBody(parser, true, true, openContext);
- const loc = parser.finishLoc(start);
-
- return {
+ return parser.finishNode(start, {
type: 'Program',
corrupt: parser.state.corrupt,
- loc,
body,
directives,
mtime: parser.mtime,
@@ -126,7 +123,7 @@ export function parseTopLevel(parser: JSParser): Program {
interpreter,
syntax: Array.from(parser.syntax),
hasHoistedVars: parser.state.hasHoistedVars,
- };
+ });
}
export function parsePossibleInterpreterDirective(

MODIFY
packages/@romejs/parser-core/index.ts
packages/@romejs/parser-core/index.ts
@@ -148,39 +148,29 @@ export class ParserCore<Tokens extends TokensShape, State> {
this.currColumn = offsetPosition === undefined
? number0
: offsetPosition.column;
- this.offsetIndex = offsetPosition === undefined
- ? number0
- : offsetPosition.index;
- this.startLine = this.currLine;
- this.startColumn = this.currColumn;
this.nextTokenIndex = number0;
this.currentToken = SOF_TOKEN;
this.prevToken = SOF_TOKEN;
this.state = initialState;
this.ignoreWhitespaceTokens = false;
- this.latestPosition = {
- index: number0, // TODO this.offsetIndex
- line: this.currLine,
- column: this.currColumn,
- };
- this.cachedPositions = new Map();
+ this.indexTracker = new PositionTracker(
+ this.input,
+ offsetPosition,
+ this.getPosition.bind(this),
+ );
}
+ indexTracker: PositionTracker;
offsetPosition: undefined | Position;
- startLine: Number1;
- startColumn: Number0;
- offsetIndex: Number0;
diagnosticCategory: DiagnosticCategory;
tokenizing: boolean;
nextTokenIndex: Number0;
state: State;
prevToken: TokenValues<Tokens>;
currentToken: TokenValues<Tokens>;
- latestPosition: Position;
eofToken: EOFToken;
ignoreWhitespaceTokens: boolean;
- cachedPositions: Map<Number0, Position>;
path: undefined | UnknownFilePath;
filename: undefined | string;
@@ -337,17 +327,17 @@ export class ParserCore<Tokens extends TokensShape, State> {
getPosition(): Position {
const index = this.currentToken.start;
- const cached = this.cachedPositions.get(index);
+ const cached = this.indexTracker.cachedPositions.get(index);
if (cached !== undefined) {
return cached;
}
const pos: Position = {
- index: this.addOffset(index),
+ index: this.indexTracker.addOffset(index),
line: this.currLine,
column: this.currColumn,
};
- this.cachedPositions.set(index, pos);
+ this.indexTracker.cachedPositions.set(index, pos);
return pos;
}
@@ -393,64 +383,8 @@ export class ParserCore<Tokens extends TokensShape, State> {
return nextToken;
}
- addOffset(index: Number0): Number0 {
- return add(index, this.offsetIndex);
- }
-
- removeOffset(index: Number0): Number0 {
- return sub(index, this.offsetIndex);
- }
-
getPositionFromIndex(index: Number0): Position {
- const cached = this.cachedPositions.get(index);
- if (cached !== undefined) {
- return cached;
- }
-
- let line: Number1 = number1;
- let column: Number0 = number0;
- let indexSearchOffset: number = 0;
-
- const indexWithOffset = this.addOffset(index);
-
- // Reuse existing line information if possible
- const {latestPosition} = this;
- const currPosition = this.getPosition();
- if (currPosition.index > latestPosition.index && currPosition.index <
- indexWithOffset) {
- line = currPosition.line;
- column = currPosition.column;
- indexSearchOffset = get0(this.removeOffset(currPosition.index));
- } else if (latestPosition.index < indexWithOffset) {
- line = latestPosition.line;
- column = latestPosition.column;
- indexSearchOffset = get0(this.removeOffset(latestPosition.index));
- }
-
- // Read the rest of the input until we hit the index
- for (let i = indexSearchOffset; i < get0(index); i++) {
- const char = this.input[i];
-
- if (char === '\n') {
- line = inc(line);
- column = number0;
- } else {
- column = inc(column);
- }
- }
-
- const pos: Position = {
- index: indexWithOffset,
- line,
- column,
- };
-
- if (latestPosition === undefined || pos.index > latestPosition.index) {
- this.latestPosition = pos;
- }
-
- this.cachedPositions.set(index, pos);
- return pos;
+ return this.indexTracker.getPositionFromIndex(index);
}
createDiagnostic(opts: ParserUnexpectedOptions = {}): Diagnostic {
@@ -709,6 +643,90 @@ export class ParserWithRequiredPath<Tokens extends TokensShape, State> extends P
filename: string;
}
+type GetPosition = () => Position;
+
+export class PositionTracker {
+ constructor(input: string, offsetPosition: Position = {
+ line: number1,
+ column: number0,
+ index: number0,
+ }, getPosition?: GetPosition) {
+ this.getPosition = getPosition;
+ this.input = input;
+ this.offsetPosition = offsetPosition;
+ this.latestPosition = offsetPosition;
+ this.cachedPositions = new Map();
+ }
+
+ input: string;
+ latestPosition: Position;
+ offsetPosition: Position;
+ cachedPositions: Map<Number0, Position>;
+ getPosition: undefined | GetPosition;
+
+ addOffset(index: Number0): Number0 {
+ return add(index, this.offsetPosition.index);
+ }
+
+ removeOffset(index: Number0): Number0 {
+ return sub(index, this.offsetPosition.index);
+ }
+
+ getPositionFromIndex(index: Number0): Position {
+ const cached = this.cachedPositions.get(index);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ let line: Number1 = number1;
+ let column: Number0 = number0;
+ let indexSearchWithoutOffset: number = 0;
+
+ const indexWithOffset = this.addOffset(index);
+
+ // Reuse existing line information if possible
+ const {latestPosition} = this;
+ const currPosition = this.getPosition === undefined
+ ? undefined
+ : this.getPosition();
+ if (currPosition !== undefined && currPosition.index > latestPosition.index &&
+ currPosition.index < indexWithOffset) {
+ line = currPosition.line;
+ column = currPosition.column;
+ indexSearchWithoutOffset = get0(this.removeOffset(currPosition.index));
+ } else if (latestPosition.index < indexWithOffset) {
+ line = latestPosition.line;
+ column = latestPosition.column;
+ indexSearchWithoutOffset = get0(this.removeOffset(latestPosition.index));
+ }
+
+ // Read the rest of the input until we hit the index
+ for (let i = indexSearchWithoutOffset; i < get0(index); i++) {
+ const char = this.input[i];
+
+ if (char === '\n') {
+ line = inc(line);
+ column = number0;
+ } else {
+ column = inc(column);
+ }
+ }
+
+ const pos: Position = {
+ index: indexWithOffset,
+ line,
+ column,
+ };
+
+ if (latestPosition === undefined || pos.index > latestPosition.index) {
+ this.latestPosition = pos;
+ }
+
+ this.cachedPositions.set(index, pos);
+ return pos;
+ }
+}
+
//# Helpers methods for basic token parsing
export function isDigit(char: undefined | string): boolean {
return char !== undefined && /[0-9]/.test(char);

MODIFY
packages/@romejs/string-utils/index.ts
packages/@romejs/string-utils/index.ts
@@ -28,3 +28,5 @@ export * from './toKebabCase';
export * from './toCamelCase';
export * from './removeCarriageReturn';
+
+export * from './preserveCasing';

ADD
packages/@romejs/string-utils/preserveCasing.ts
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+export function preserveCasing(a: string, b: string) {
+ if (a === a.toUpperCase()) {
+ // If a is upper case then make b uppercase
+ return b.toUpperCase();
+ } else if (a[0] === a[0].toUpperCase()) {
+ // First letter is capitalized
+ return b[0].toUpperCase() + b.slice(1);
+ } else {
+ return b;
+ }
+}

Add inconsiderateLanguage lint rule