// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview This custom ESLint plugin includes several custom rules.
* Please see variable `rules` at the end of file for a list of rules, and the
* jsdoc comment on the functions that implements each rule (the values of the
* `rules` object) for explanation of each rule.
*/
// Note: https://astexplorer.net/ is useful when developing custom rules.
// Select "JavaScript", "@typescript-eslint/parser", "Transform: ESLint v8"
// on the top options.
/* global require */
// This file is using CommonJS modules instead of ES module since the .eslintrc
// currently don't support ES module.
// TODO(pihsun): Convert this file to ES module when we have newer version of
// ESLint that supports it.
// The @typescript-eslint/utils import needs to be full path since there
// doesn't seem to be a way to tell ESLint to find the import at that path. And
// it needs to be in one single string so TypeScript would recognize the type
// import and infer types correctly.
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/naming-convention */
const {
TSESTree,
ESLintUtils,
TSESLint,
} = require( // eslint-disable-next-line max-len
'../../../../../../third_party/node/node_modules/@typescript-eslint/utils/dist/index.js');
/* eslint-enable @typescript-eslint/naming-convention */
/* eslint-enable @typescript-eslint/no-var-requires */
// This file is written in JavaScript with types in jsdoc, since ESLint can't
// use .ts file directly, and we don't want a separate compile pass before lint
// can be used.
// ESLint jsdoc doesn't recognize "asserts condition".
/* eslint-disable jsdoc/valid-types */
/**
* @param {boolean} condition The condition that should be true.
* @param {string=} optMessage Message when it's not true.
* @return {asserts condition} Asserts `condition` is true.
*/
/* eslint-enable jsdoc/valid-types */
function assert(condition, optMessage) {
if (!condition) {
let message = 'Assertion failed';
if (optMessage !== undefined) {
message = message + ': ' + optMessage;
}
throw new Error(message);
}
}
/**
* Checks if the inline comment before arguments in function call ends with
* '='.
*
* See go/tsjs-style#comments-when-calling-a-function.
*
* Note that in the following example, '|' should be replaced by '/', but
* JavaScript doesn't support nested comment.
* Example:
* foo(/* bar *| 1) should be written as foo(/* bar= *| 1) instead.
*/
const parameterCommentFormatRule = ESLintUtils.RuleCreator.withoutDocs({
create: (context) => {
const sourceCode = context.getSourceCode();
return {
/* eslint-disable-next-line @typescript-eslint/naming-convention */
CallExpression(node) {
for (const arg of node.arguments) {
const comments = sourceCode.getCommentsBefore(arg);
for (const comment of comments) {
const {type, value} = comment;
if (type !== 'Block') {
continue;
}
if (!value.match(/ \w+= /) &&
!value.match(/^\s*eslint-disable-next-line/)) {
context.report({
node: comment,
messageId: 'inlineCommentError',
});
}
}
}
},
};
},
meta: {
messages: {
inlineCommentError: 'Inline block comment for parameters' +
' should be in the form of /* var= */',
},
type: 'suggestion',
schema: [],
},
defaultOptions: [],
});
/**
* Checks whether generic parameters can be moved onto new expression.
*
* Example:
* `let s: Set<number> = new Set();` can be simplified to
* `let s = new Set<number>()`.
*/
const genericParameterOnDeclarationType = ESLintUtils.RuleCreator.withoutDocs({
create: (context) => {
/**
* @param {TSESTree.TSTypeAnnotation|undefined} typeAnnotation Type
* annotation.
* @param {TSESTree.Expression|null} value The value expression.
*/
function checkTypeParameterSameAsNewExpression(typeAnnotation, value) {
if (value === null || value.type !== 'NewExpression' ||
value.callee.type !== 'Identifier') {
return;
}
const newTypeName = value.callee.name;
if (typeAnnotation === undefined ||
typeAnnotation.type !== 'TSTypeAnnotation' ||
typeAnnotation.typeAnnotation.type !== 'TSTypeReference' ||
typeAnnotation.typeAnnotation.typeName.type !== 'Identifier') {
return;
}
const typeAnnotationTypeName =
typeAnnotation.typeAnnotation.typeName.name;
if (newTypeName === typeAnnotationTypeName) {
if (typeAnnotation.typeAnnotation.typeArguments !== undefined) {
context.report({
node: typeAnnotation.typeAnnotation.typeArguments,
messageId: 'genericTypeParametersToNew',
});
} else {
context.report({
node: typeAnnotation.typeAnnotation,
messageId: 'redundantType',
});
}
}
}
return {
/* eslint-disable-next-line @typescript-eslint/naming-convention */
PropertyDefinition({value, typeAnnotation}) {
checkTypeParameterSameAsNewExpression(typeAnnotation, value);
},
/* eslint-disable-next-line @typescript-eslint/naming-convention */
VariableDeclarator({id: {typeAnnotation}, init}) {
checkTypeParameterSameAsNewExpression(typeAnnotation, init);
},
};
},
meta: {
messages: {
genericTypeParametersToNew:
'Generic type parameters can be moved to the new expression.',
redundantType: 'Redundant type annotation.',
},
type: 'suggestion',
schema: [],
},
defaultOptions: [],
});
/* eslint-disable cra/todo-format */
const BAD_TODO_FORMAT_REGEX = new RegExp(
'TODO' +
('(' +
'\\(' + // Old format TODO: Starts with 'TODO('
'(?![a-z]+\\))' + // And isn't followed by '<ldap>)'
'|' +
': ' + // New format TODO: Starts with 'TODO:'
'(?!(b\\/\\d+) - )' + // And isn't followed by 'b/<num> - '
')'),
'gd',
);
/* eslint-enable cra/todo-format */
/**
* @param {TSESTree.Position} loc The source location of the string.
* @param {string} str The string.
* @param {number} idx The offset in the string.
* @return {TSESTree.Position} The source location of the character at
* that offset of the string.
*/
function getLocationAtOffset(loc, str, idx) {
const {line, column} = loc;
const prefix = str.substring(0, idx);
const newLines = Array.from(prefix.matchAll(/\n/dg));
if (newLines.length === 0) {
return {
line,
column: column + idx,
};
}
const lastNewLine = newLines[newLines.length - 1];
assert(lastNewLine.indices !== undefined);
// [0] for the whole match, and [1] for the match range ending.
const resultColumn = idx - lastNewLine.indices[0][1];
const resultLine = line + newLines.length;
return {line: resultLine, column: resultColumn};
}
/**
* @template {string} T
* @template {unknown[]} O
* @param {Readonly<TSESLint.RuleContext<T, O>>} context The ESLint
* context.
* @param {TSESTree.SourceLocation} loc The source location of the string.
* @param {string} str The string to be matched against.
* @param {RegExp} regex The regex to be matched.
* @param {T} messageId The error message.
*/
function reportRegexMatches(context, loc, str, regex, messageId) {
for (const match of str.matchAll(regex)) {
assert(match.indices !== undefined, `Regex should have 'd' flag on`);
const [startIdx, endIdx] = match.indices[0];
const regexLoc = {
start: getLocationAtOffset(loc.start, str, startIdx),
end: getLocationAtOffset(loc.start, str, endIdx),
};
context.report({
messageId,
loc: regexLoc,
});
}
}
/**
* Checks the TODO in comments match style in go/todo-style.
*
* Specifically, we currently allow the following:
* - Old style with ldap `TODO(ldap): xxx`.
* - New style with bug link `TODO: b/123 - xxx`.
*
* Non-trivial TODOs should be tracked in a bug and use the new style TODO.
*/
const todoFormatRule = ESLintUtils.RuleCreator.withoutDocs({
create: (context) => {
const sourceCode = context.getSourceCode();
return {
/* eslint-disable-next-line @typescript-eslint/naming-convention */
Program() {
const comments = sourceCode.getAllComments();
for (const comment of comments) {
const commentValue = comment.value;
reportRegexMatches(
context,
comment.loc,
commentValue,
BAD_TODO_FORMAT_REGEX,
'invalidTodo',
);
}
},
// Also check TODO strings inside css`` and html`` tag.
/* eslint-disable-next-line @typescript-eslint/naming-convention */
TaggedTemplateExpression({tag, quasi}) {
if (tag.type !== 'Identifier') {
return;
}
if (tag.name !== 'css' && tag.name !== 'html') {
return;
}
for (const el of quasi.quasis) {
reportRegexMatches(
context,
el.loc,
el.value.raw,
BAD_TODO_FORMAT_REGEX,
'invalidTodo',
);
}
},
};
},
meta: {
messages: {
invalidTodo:
'Use either `TODO(ldap)` or `TODO: b/123 -`, see go/todo-style',
},
type: 'suggestion',
schema: [],
},
defaultOptions: [],
});
// TODO(pihsun): Add a rule for checking string enum order that can be applied
// to only specific enums.
// TODO(pihsun): Add a rule for checking object literal {foo: foo} -> {foo}.
const rules = {
/* eslint-disable @typescript-eslint/naming-convention */
'parameter-comment-format': parameterCommentFormatRule,
'generic-parameter-on-declaration-type': genericParameterOnDeclarationType,
'todo-format': todoFormatRule,
/* eslint-enable @typescript-eslint/naming-convention */
};
/* global module */
module.exports = {rules};