123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646 |
- '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,
- },
- };
|