eslint-plugin-unicorn
Version:
Various awesome ESLint rules
617 lines (509 loc) • 14.8 kB
JavaScript
'use strict';
const path = require('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 renameVariable = require('./utils/rename-variable.js');
const isStaticRequire = require('./utils/is-static-require.js');
const {defaultReplacements, defaultAllowList} = require('./shared/abbreviations.js');
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 = 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);
return {
total,
samples: samples.map(words => words.join(''))
};
};
const anotherNameMessage = 'A more descriptive name will do too.';
const formatMessage = (discouragedName, replacements, nameTypeText) => {
const message = [];
const {total, samples = []} = replacements;
if (total === 1) {
message.push(`The ${nameTypeText} \`${discouragedName}\` should be named \`${samples[0]}\`.`);
} else {
let replacementsText = samples
.map(replacement => `\`${replacement}\``)
.join(', ');
const omittedReplacementsCount = total - samples.length;
if (omittedReplacementsCount > 0) {
replacementsText += `, ... (${omittedReplacementsCount > 99 ? '99+' : omittedReplacementsCount} more omitted)`;
}
message.push(
`Please rename the ${nameTypeText} \`${discouragedName}\`.`,
`Suggested names are: ${replacementsText}.`
);
}
message.push(anotherNameMessage);
return message.join(' ');
};
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 => {
return !getVariableIdentifiers(variable).some(identifier => isExportedIdentifier(identifier));
};
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.key === identifier &&
!identifier.parent.computed
) {
return true;
}
if (
(identifier.parent.type === 'ClassProperty' || 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('/'))
);
};
const create = context => {
const {ecmaVersion} = context.parserOptions;
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 (whereever 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, ecmaVersion, isSafeName)
);
const problem = {
node: definition.name,
message: formatMessage(definition.name.name, variableReplacements, 'variable')
};
if (variableReplacements.total === 1 && shouldFix(variable)) {
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 checkChildScopes = scope => {
for (const childScope of scope.childScopes) {
checkScope(childScope);
}
};
const checkScope = scope => {
checkVariables(scope);
return checkChildScopes(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 = {
node,
message: formatMessage(node.name, identifierReplacements, 'property')
};
context.report(problem);
},
Program(node) {
if (!options.checkFilenames) {
return;
}
if (
filenameWithExtension === '<input>' ||
filenameWithExtension === '<text>'
) {
return;
}
const extension = path.extname(filenameWithExtension);
const filename = path.basename(filenameWithExtension, extension);
const filenameReplacements = getNameReplacements(filename, options);
if (filenameReplacements.total === 0) {
return;
}
filenameReplacements.samples = filenameReplacements.samples.map(replacement => `${replacement}${extension}`);
context.report({
node,
message: formatMessage(filenameWithExtension, filenameReplacements, 'filename')
});
},
'Program:exit'() {
if (!options.checkVariables) {
return;
}
checkScope(context.getScope());
}
};
};
const schema = [
{
type: 'object',
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: '#/items/0/definitions/abbreviations'
},
extendDefaultAllowList: {
type: 'boolean'
},
allowList: {
$ref: '#/items/0/definitions/booleanObject'
},
ignore: {
type: 'array',
uniqueItems: true
}
},
additionalProperties: false,
definitions: {
abbreviations: {
type: 'object',
additionalProperties: {
$ref: '#/items/0/definitions/replacements'
}
},
replacements: {
anyOf: [
{
enum: [
false
]
},
{
$ref: '#/items/0/definitions/booleanObject'
}
]
},
booleanObject: {
type: 'object',
additionalProperties: {
type: 'boolean'
}
}
}
}
];
module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prevent abbreviations.'
},
fixable: 'code',
schema
}
};