647 lines
15 KiB
JavaScript
647 lines
15 KiB
JavaScript
'use strict';
|
|
const path = require('node:path');
|
|
const {defaultsDeep, upperFirst, lowerFirst} = require('lodash');
|
|
|
|
const avoidCapture = require('./utils/avoid-capture.js');
|
|
const cartesianProductSamples = require('./utils/cartesian-product-samples.js');
|
|
const isShorthandPropertyValue = require('./utils/is-shorthand-property-value.js');
|
|
const isShorthandImportLocal = require('./utils/is-shorthand-import-local.js');
|
|
const getVariableIdentifiers = require('./utils/get-variable-identifiers.js');
|
|
const {defaultReplacements, defaultAllowList, defaultIgnore} = require('./shared/abbreviations.js');
|
|
const {renameVariable} = require('./fix/index.js');
|
|
const getScopes = require('./utils/get-scopes.js');
|
|
const {isStaticRequire} = require('./ast/index.js');
|
|
|
|
const MESSAGE_ID_REPLACE = 'replace';
|
|
const MESSAGE_ID_SUGGESTION = 'suggestion';
|
|
const anotherNameMessage = 'A more descriptive name will do too.';
|
|
const messages = {
|
|
[MESSAGE_ID_REPLACE]: `The {{nameTypeText}} \`{{discouragedName}}\` should be named \`{{replacement}}\`. ${anotherNameMessage}`,
|
|
[MESSAGE_ID_SUGGESTION]: `Please rename the {{nameTypeText}} \`{{discouragedName}}\`. Suggested names are: {{replacementsText}}. ${anotherNameMessage}`,
|
|
};
|
|
|
|
const isUpperCase = string => string === string.toUpperCase();
|
|
const isUpperFirst = string => isUpperCase(string[0]);
|
|
|
|
const prepareOptions = ({
|
|
checkProperties = false,
|
|
checkVariables = true,
|
|
|
|
checkDefaultAndNamespaceImports = 'internal',
|
|
checkShorthandImports = 'internal',
|
|
checkShorthandProperties = false,
|
|
|
|
checkFilenames = true,
|
|
|
|
extendDefaultReplacements = true,
|
|
replacements = {},
|
|
|
|
extendDefaultAllowList = true,
|
|
allowList = {},
|
|
|
|
ignore = [],
|
|
} = {}) => {
|
|
const mergedReplacements = extendDefaultReplacements
|
|
? defaultsDeep({}, replacements, defaultReplacements)
|
|
: replacements;
|
|
|
|
const mergedAllowList = extendDefaultAllowList
|
|
? defaultsDeep({}, allowList, defaultAllowList)
|
|
: allowList;
|
|
|
|
ignore = [...defaultIgnore, ...ignore];
|
|
|
|
ignore = ignore.map(
|
|
pattern => pattern instanceof RegExp ? pattern : new RegExp(pattern, 'u'),
|
|
);
|
|
|
|
return {
|
|
checkProperties,
|
|
checkVariables,
|
|
|
|
checkDefaultAndNamespaceImports,
|
|
checkShorthandImports,
|
|
checkShorthandProperties,
|
|
|
|
checkFilenames,
|
|
|
|
replacements: new Map(
|
|
Object.entries(mergedReplacements).map(
|
|
([discouragedName, replacements]) =>
|
|
[discouragedName, new Map(Object.entries(replacements))],
|
|
),
|
|
),
|
|
allowList: new Map(Object.entries(mergedAllowList)),
|
|
|
|
ignore,
|
|
};
|
|
};
|
|
|
|
const getWordReplacements = (word, {replacements, allowList}) => {
|
|
// Skip constants and allowList
|
|
if (isUpperCase(word) || allowList.get(word)) {
|
|
return [];
|
|
}
|
|
|
|
const replacement = replacements.get(lowerFirst(word))
|
|
|| replacements.get(word)
|
|
|| replacements.get(upperFirst(word));
|
|
|
|
let wordReplacement = [];
|
|
if (replacement) {
|
|
const transform = isUpperFirst(word) ? upperFirst : lowerFirst;
|
|
wordReplacement = [...replacement.keys()]
|
|
.filter(name => replacement.get(name))
|
|
.map(name => transform(name));
|
|
}
|
|
|
|
return wordReplacement.length > 0 ? wordReplacement.sort() : [];
|
|
};
|
|
|
|
const getNameReplacements = (name, options, limit = 3) => {
|
|
const {allowList, ignore} = options;
|
|
|
|
// Skip constants and allowList
|
|
if (isUpperCase(name) || allowList.get(name) || ignore.some(regexp => regexp.test(name))) {
|
|
return {total: 0};
|
|
}
|
|
|
|
// Find exact replacements
|
|
const exactReplacements = getWordReplacements(name, options);
|
|
|
|
if (exactReplacements.length > 0) {
|
|
return {
|
|
total: exactReplacements.length,
|
|
samples: exactReplacements.slice(0, limit),
|
|
};
|
|
}
|
|
|
|
// Split words
|
|
const words = name.split(/(?=[^a-z])|(?<=[^A-Za-z])/).filter(Boolean);
|
|
|
|
let hasReplacements = false;
|
|
const combinations = words.map(word => {
|
|
const wordReplacements = getWordReplacements(word, options);
|
|
|
|
if (wordReplacements.length > 0) {
|
|
hasReplacements = true;
|
|
return wordReplacements;
|
|
}
|
|
|
|
return [word];
|
|
});
|
|
|
|
// No replacements for any word
|
|
if (!hasReplacements) {
|
|
return {total: 0};
|
|
}
|
|
|
|
const {
|
|
total,
|
|
samples,
|
|
} = cartesianProductSamples(combinations, limit);
|
|
|
|
// `retVal` -> `['returnValue', 'Value']` -> `['returnValue']`
|
|
for (const parts of samples) {
|
|
for (let index = parts.length - 1; index > 0; index--) {
|
|
const word = parts[index];
|
|
if (/^[A-Za-z]+$/.test(word) && parts[index - 1].endsWith(parts[index])) {
|
|
parts.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
total,
|
|
samples: samples.map(words => words.join('')),
|
|
};
|
|
};
|
|
|
|
const getMessage = (discouragedName, replacements, nameTypeText) => {
|
|
const {total, samples = []} = replacements;
|
|
|
|
if (total === 1) {
|
|
return {
|
|
messageId: MESSAGE_ID_REPLACE,
|
|
data: {
|
|
nameTypeText,
|
|
discouragedName,
|
|
replacement: samples[0],
|
|
},
|
|
};
|
|
}
|
|
|
|
let replacementsText = samples
|
|
.map(replacement => `\`${replacement}\``)
|
|
.join(', ');
|
|
|
|
const omittedReplacementsCount = total - samples.length;
|
|
if (omittedReplacementsCount > 0) {
|
|
replacementsText += `, ... (${omittedReplacementsCount > 99 ? '99+' : omittedReplacementsCount} more omitted)`;
|
|
}
|
|
|
|
return {
|
|
messageId: MESSAGE_ID_SUGGESTION,
|
|
data: {
|
|
nameTypeText,
|
|
discouragedName,
|
|
replacementsText,
|
|
},
|
|
};
|
|
};
|
|
|
|
const isExportedIdentifier = identifier => {
|
|
if (
|
|
identifier.parent.type === 'VariableDeclarator'
|
|
&& identifier.parent.id === identifier
|
|
) {
|
|
return (
|
|
identifier.parent.parent.type === 'VariableDeclaration'
|
|
&& identifier.parent.parent.parent.type === 'ExportNamedDeclaration'
|
|
);
|
|
}
|
|
|
|
if (
|
|
identifier.parent.type === 'FunctionDeclaration'
|
|
&& identifier.parent.id === identifier
|
|
) {
|
|
return identifier.parent.parent.type === 'ExportNamedDeclaration';
|
|
}
|
|
|
|
if (
|
|
identifier.parent.type === 'ClassDeclaration'
|
|
&& identifier.parent.id === identifier
|
|
) {
|
|
return identifier.parent.parent.type === 'ExportNamedDeclaration';
|
|
}
|
|
|
|
if (
|
|
identifier.parent.type === 'TSTypeAliasDeclaration'
|
|
&& identifier.parent.id === identifier
|
|
) {
|
|
return identifier.parent.parent.type === 'ExportNamedDeclaration';
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const shouldFix = variable => getVariableIdentifiers(variable)
|
|
.every(identifier =>
|
|
!isExportedIdentifier(identifier)
|
|
// In typescript parser, only `JSXOpeningElement` is added to variable
|
|
// `<foo></foo>` -> `<bar></foo>` will cause parse error
|
|
&& identifier.type !== 'JSXIdentifier',
|
|
);
|
|
|
|
const isDefaultOrNamespaceImportName = identifier => {
|
|
if (
|
|
identifier.parent.type === 'ImportDefaultSpecifier'
|
|
&& identifier.parent.local === identifier
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
identifier.parent.type === 'ImportNamespaceSpecifier'
|
|
&& identifier.parent.local === identifier
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
identifier.parent.type === 'ImportSpecifier'
|
|
&& identifier.parent.local === identifier
|
|
&& identifier.parent.imported.type === 'Identifier'
|
|
&& identifier.parent.imported.name === 'default'
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
identifier.parent.type === 'VariableDeclarator'
|
|
&& identifier.parent.id === identifier
|
|
&& isStaticRequire(identifier.parent.init)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const isClassVariable = variable => {
|
|
if (variable.defs.length !== 1) {
|
|
return false;
|
|
}
|
|
|
|
const [definition] = variable.defs;
|
|
|
|
return definition.type === 'ClassName';
|
|
};
|
|
|
|
const shouldReportIdentifierAsProperty = identifier => {
|
|
if (
|
|
identifier.parent.type === 'MemberExpression'
|
|
&& identifier.parent.property === identifier
|
|
&& !identifier.parent.computed
|
|
&& identifier.parent.parent.type === 'AssignmentExpression'
|
|
&& identifier.parent.parent.left === identifier.parent
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
identifier.parent.type === 'Property'
|
|
&& identifier.parent.key === identifier
|
|
&& !identifier.parent.computed
|
|
&& !identifier.parent.shorthand // Shorthand properties are reported and fixed as variables
|
|
&& identifier.parent.parent.type === 'ObjectExpression'
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
identifier.parent.type === 'ExportSpecifier'
|
|
&& identifier.parent.exported === identifier
|
|
&& identifier.parent.local !== identifier // Same as shorthand properties above
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
(
|
|
identifier.parent.type === 'MethodDefinition'
|
|
|| identifier.parent.type === 'PropertyDefinition'
|
|
)
|
|
&& identifier.parent.key === identifier
|
|
&& !identifier.parent.computed
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const isInternalImport = node => {
|
|
let source = '';
|
|
|
|
if (node.type === 'Variable') {
|
|
source = node.node.init.arguments[0].value;
|
|
} else if (node.type === 'ImportBinding') {
|
|
source = node.parent.source.value;
|
|
}
|
|
|
|
return (
|
|
!source.includes('node_modules')
|
|
&& (source.startsWith('.') || source.startsWith('/'))
|
|
);
|
|
};
|
|
|
|
/** @param {import('eslint').Rule.RuleContext} context */
|
|
const create = context => {
|
|
const options = prepareOptions(context.options[0]);
|
|
const filenameWithExtension = context.getPhysicalFilename();
|
|
|
|
// A `class` declaration produces two variables in two scopes:
|
|
// the inner class scope, and the outer one (wherever the class is declared).
|
|
// This map holds the outer ones to be later processed when the inner one is encountered.
|
|
// For why this is not a eslint issue see https://github.com/eslint/eslint-scope/issues/48#issuecomment-464358754
|
|
const identifierToOuterClassVariable = new WeakMap();
|
|
|
|
const checkPossiblyWeirdClassVariable = variable => {
|
|
if (isClassVariable(variable)) {
|
|
if (variable.scope.type === 'class') { // The inner class variable
|
|
const [definition] = variable.defs;
|
|
const outerClassVariable = identifierToOuterClassVariable.get(definition.name);
|
|
|
|
if (!outerClassVariable) {
|
|
return checkVariable(variable);
|
|
}
|
|
|
|
// Create a normal-looking variable (like a `var` or a `function`)
|
|
// For which a single `variable` holds all references, unlike with a `class`
|
|
const combinedReferencesVariable = {
|
|
name: variable.name,
|
|
scope: variable.scope,
|
|
defs: variable.defs,
|
|
identifiers: variable.identifiers,
|
|
references: [...variable.references, ...outerClassVariable.references],
|
|
};
|
|
|
|
// Call the common checker with the newly forged normalized class variable
|
|
return checkVariable(combinedReferencesVariable);
|
|
}
|
|
|
|
// The outer class variable, we save it for later, when it's inner counterpart is encountered
|
|
const [definition] = variable.defs;
|
|
identifierToOuterClassVariable.set(definition.name, variable);
|
|
|
|
return;
|
|
}
|
|
|
|
return checkVariable(variable);
|
|
};
|
|
|
|
// Holds a map from a `Scope` to a `Set` of new variable names generated by our fixer.
|
|
// Used to avoid generating duplicate names, see for instance `let errCb, errorCb` test.
|
|
const scopeToNamesGeneratedByFixer = new WeakMap();
|
|
const isSafeName = (name, scopes) => scopes.every(scope => {
|
|
const generatedNames = scopeToNamesGeneratedByFixer.get(scope);
|
|
return !generatedNames || !generatedNames.has(name);
|
|
});
|
|
|
|
const checkVariable = variable => {
|
|
if (variable.defs.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const [definition] = variable.defs;
|
|
|
|
if (isDefaultOrNamespaceImportName(definition.name)) {
|
|
if (!options.checkDefaultAndNamespaceImports) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
options.checkDefaultAndNamespaceImports === 'internal'
|
|
&& !isInternalImport(definition)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isShorthandImportLocal(definition.name)) {
|
|
if (!options.checkShorthandImports) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
options.checkShorthandImports === 'internal'
|
|
&& !isInternalImport(definition)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (
|
|
!options.checkShorthandProperties
|
|
&& isShorthandPropertyValue(definition.name)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const variableReplacements = getNameReplacements(variable.name, options);
|
|
|
|
if (variableReplacements.total === 0) {
|
|
return;
|
|
}
|
|
|
|
const scopes = [
|
|
...variable.references.map(reference => reference.from),
|
|
variable.scope,
|
|
];
|
|
variableReplacements.samples = variableReplacements.samples.map(
|
|
name => avoidCapture(name, scopes, isSafeName),
|
|
);
|
|
|
|
const problem = {
|
|
...getMessage(definition.name.name, variableReplacements, 'variable'),
|
|
node: definition.name,
|
|
};
|
|
|
|
if (
|
|
variableReplacements.total === 1
|
|
&& shouldFix(variable)
|
|
&& variableReplacements.samples[0]
|
|
&& !variable.references.some(reference => reference.vueUsedInTemplate)
|
|
) {
|
|
const [replacement] = variableReplacements.samples;
|
|
|
|
for (const scope of scopes) {
|
|
if (!scopeToNamesGeneratedByFixer.has(scope)) {
|
|
scopeToNamesGeneratedByFixer.set(scope, new Set());
|
|
}
|
|
|
|
const generatedNames = scopeToNamesGeneratedByFixer.get(scope);
|
|
generatedNames.add(replacement);
|
|
}
|
|
|
|
problem.fix = fixer => renameVariable(variable, replacement, fixer);
|
|
}
|
|
|
|
context.report(problem);
|
|
};
|
|
|
|
const checkVariables = scope => {
|
|
for (const variable of scope.variables) {
|
|
checkPossiblyWeirdClassVariable(variable);
|
|
}
|
|
};
|
|
|
|
const checkScope = scope => {
|
|
const scopes = getScopes(scope);
|
|
for (const scope of scopes) {
|
|
checkVariables(scope);
|
|
}
|
|
};
|
|
|
|
return {
|
|
Identifier(node) {
|
|
if (!options.checkProperties) {
|
|
return;
|
|
}
|
|
|
|
if (node.name === '__proto__') {
|
|
return;
|
|
}
|
|
|
|
const identifierReplacements = getNameReplacements(node.name, options);
|
|
|
|
if (identifierReplacements.total === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!shouldReportIdentifierAsProperty(node)) {
|
|
return;
|
|
}
|
|
|
|
const problem = {
|
|
...getMessage(node.name, identifierReplacements, 'property'),
|
|
node,
|
|
};
|
|
|
|
context.report(problem);
|
|
},
|
|
|
|
Program(node) {
|
|
if (!options.checkFilenames) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
filenameWithExtension === '<input>'
|
|
|| filenameWithExtension === '<text>'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const filename = path.basename(filenameWithExtension);
|
|
const extension = path.extname(filename);
|
|
const filenameReplacements = getNameReplacements(path.basename(filename, extension), options);
|
|
|
|
if (filenameReplacements.total === 0) {
|
|
return;
|
|
}
|
|
|
|
filenameReplacements.samples = filenameReplacements.samples.map(replacement => `${replacement}${extension}`);
|
|
|
|
context.report({
|
|
...getMessage(filename, filenameReplacements, 'filename'),
|
|
node,
|
|
});
|
|
},
|
|
|
|
'Program:exit'() {
|
|
if (!options.checkVariables) {
|
|
return;
|
|
}
|
|
|
|
checkScope(context.getScope());
|
|
},
|
|
};
|
|
};
|
|
|
|
const schema = {
|
|
type: 'array',
|
|
additionalItems: false,
|
|
items: [
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
checkProperties: {
|
|
type: 'boolean',
|
|
},
|
|
checkVariables: {
|
|
type: 'boolean',
|
|
},
|
|
checkDefaultAndNamespaceImports: {
|
|
type: [
|
|
'boolean',
|
|
'string',
|
|
],
|
|
pattern: 'internal',
|
|
},
|
|
checkShorthandImports: {
|
|
type: [
|
|
'boolean',
|
|
'string',
|
|
],
|
|
pattern: 'internal',
|
|
},
|
|
checkShorthandProperties: {
|
|
type: 'boolean',
|
|
},
|
|
checkFilenames: {
|
|
type: 'boolean',
|
|
},
|
|
extendDefaultReplacements: {
|
|
type: 'boolean',
|
|
},
|
|
replacements: {
|
|
$ref: '#/definitions/abbreviations',
|
|
},
|
|
extendDefaultAllowList: {
|
|
type: 'boolean',
|
|
},
|
|
allowList: {
|
|
$ref: '#/definitions/booleanObject',
|
|
},
|
|
ignore: {
|
|
type: 'array',
|
|
uniqueItems: true,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
definitions: {
|
|
abbreviations: {
|
|
type: 'object',
|
|
additionalProperties: {
|
|
$ref: '#/definitions/replacements',
|
|
},
|
|
},
|
|
replacements: {
|
|
anyOf: [
|
|
{
|
|
enum: [
|
|
false,
|
|
],
|
|
},
|
|
{
|
|
$ref: '#/definitions/booleanObject',
|
|
},
|
|
],
|
|
},
|
|
booleanObject: {
|
|
type: 'object',
|
|
additionalProperties: {
|
|
type: 'boolean',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
module.exports = {
|
|
create,
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description: 'Prevent abbreviations.',
|
|
},
|
|
fixable: 'code',
|
|
schema,
|
|
messages,
|
|
},
|
|
};
|