'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
// `` -> `` 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 === ''
|| filenameWithExtension === ''
) {
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,
},
};