eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
472 lines (414 loc) • 12.1 kB
JavaScript
import {
getAvailableVariableName,
isShorthandPropertyValue,
lowerFirst,
upperFirst,
isIdentifierName,
} from './utils/index.js';
import {
createFixerNameTracker,
getAllVariables,
isTSParameterPropertyName,
shouldCheckDefaultOrNamespaceImportName,
shouldCheckShorthandImportName,
shouldRenameVariable,
shouldReportIdentifierAsProperty,
} from './shared/identifier-checks.js';
import {renameVariable} from './fix/index.js';
import onCssIdentifier from './shared/css-identifiers.js';
import onHtmlIdentifier from './shared/html-identifiers.js';
import onDataKey from './shared/data-keys.js';
const MESSAGE_ID_ERROR = 'consistent-compound-words/error';
const MESSAGE_ID_RENAME = 'consistent-compound-words/rename';
const messages = {
[MESSAGE_ID_ERROR]: 'Prefer `{{replacement}}` over `{{name}}`.',
[MESSAGE_ID_RENAME]: 'Rename to `{{replacement}}`.',
};
const defaultReplacements = {
// Ambiguous/common API spellings like `fileName`, `setUp`, `lookUp`, `newLine`, `onLine`, `offLine`, `styleSheet`, and `superClass` are intentionally excluded.
backGround: 'background',
callBack: 'callback',
checkBox: 'checkbox',
clipBoard: 'clipboard',
codeBase: 'codebase',
dataBase: 'database',
downLoad: 'download',
feedBack: 'feedback',
foreGround: 'foreground',
frameWork: 'framework',
headLine: 'headline',
keyBoard: 'keyboard',
keyFrame: 'keyframe',
lifeCycle: 'lifecycle',
metaData: 'metadata',
midPoint: 'midpoint',
nameSpace: 'namespace',
overRide: 'override',
passWord: 'password',
payLoad: 'payload',
placeHolder: 'placeholder',
preView: 'preview',
screenShot: 'screenshot',
sideBar: 'sidebar',
subClass: 'subclass',
subDirectory: 'subdirectory',
subDomain: 'subdomain',
subMenu: 'submenu',
subProcess: 'subprocess',
subString: 'substring',
subTree: 'subtree',
subType: 'subtype',
subTitle: 'subtitle',
timeOut: 'timeout',
timeStamp: 'timestamp',
toolBar: 'toolbar',
toolKit: 'toolkit',
toolTip: 'tooltip',
touchScreen: 'touchscreen',
unSubscribe: 'unsubscribe',
underScore: 'underscore',
upLoad: 'upload',
userName: 'username',
viewPort: 'viewport',
webCam: 'webcam',
webHook: 'webhook',
webSite: 'website',
weekEnd: 'weekend',
whiteSpace: 'whitespace',
wildCard: 'wildcard',
workFlow: 'workflow',
workSpace: 'workspace',
};
const isUpperCase = string => string === string.toUpperCase();
const isUpperFirst = string => isUpperCase(string[0]);
const regExpSyntaxCharacters = new Set(String.raw`\^$.*+?()[]{}|`);
const escapeRegExp = string => {
let result = '';
for (const character of string) {
result += regExpSyntaxCharacters.has(character) ? `\\${character}` : character;
}
return result;
};
// Combine every discouraged name into a single regular expression so each identifier is scanned in one pass.
// Without this, the rule would compile and run one regular expression per replacement for every identifier in the file.
const boundary = String.raw`(?=$|[\d_$]|\p{Uppercase_Letter})`;
const buildReplacementRegExp = replacements => {
// The lowercase-first form only matches at the start of the name; the uppercase-first form matches a compound segment anywhere.
const lowerFirstForms = [];
const upperFirstForms = [];
for (const discouragedName of replacements.keys()) {
lowerFirstForms.push(escapeRegExp(discouragedName));
upperFirstForms.push(escapeRegExp(upperFirst(discouragedName)));
}
return new RegExp(`(?:^(?:${lowerFirstForms.join('|')})|(?:${upperFirstForms.join('|')}))${boundary}`, 'gv');
};
const prepareOptions = ({
checkProperties = false,
checkVariables = true,
checkDefaultAndNamespaceImports = 'internal',
checkShorthandImports = 'internal',
checkShorthandProperties = false,
extendDefaultReplacements = true,
replacements = {},
allowList = {},
} = {}) => {
const mergedReplacements = extendDefaultReplacements
? {...defaultReplacements, ...replacements}
: replacements;
const replacementsMap = new Map(Object.entries(mergedReplacements).filter(([, replacement]) => replacement !== false));
return {
checkProperties,
checkVariables,
checkDefaultAndNamespaceImports,
checkShorthandImports,
checkShorthandProperties,
replacements: replacementsMap,
replacementRegExp: replacementsMap.size > 0 ? buildReplacementRegExp(replacementsMap) : undefined,
allowList: new Set(Object.keys(allowList)),
};
};
const defaultPreparedOptions = prepareOptions();
const getReplacementForPart = (part, replacements) => {
const replacement = replacements.get(part) ?? replacements.get(lowerFirst(part));
// A discouraged name whose first letter does not round-trip through case conversion, for example `ıtem`, can match a part it is not the key for.
if (!replacement) {
return part;
}
return isUpperFirst(part) ? upperFirst(replacement) : lowerFirst(replacement);
};
const getNameReplacement = (name, {replacementRegExp, replacements, allowList}) => {
if (!replacementRegExp || isUpperCase(name) || allowList.has(name)) {
return;
}
const replacement = name.replaceAll(replacementRegExp, part => getReplacementForPart(part, replacements));
if (replacement === name) {
return;
}
return replacement;
};
const getNonJavaScriptNameReplacer = options => {
const segmentOptions = {...options, allowList: new Set()};
const separatedReplacements = new Map();
for (const separator of ['-', '_']) {
for (const [discouraged, preferred] of options.replacements) {
const separated = discouraged.replaceAll(/([a-z])([A-Z])/g, (_, first, second) => first + separator + second.toLowerCase());
separatedReplacements.set(separated, preferred);
}
}
const pattern = new RegExp(`(?<=^|[-_])(?:${separatedReplacements.keys().map(name => escapeRegExp(name)).toArray().join('|')})(?=$|[-_])`, 'g');
return name => {
if (options.allowList.has(name) || separatedReplacements.size === 0) {
return;
}
const replacement = name.replaceAll(pattern, part => separatedReplacements.get(part))
.split(/([-_])/).map(part => getNameReplacement(part, segmentOptions) ?? part).join('');
return replacement === name ? undefined : replacement;
};
};
const createProblem = (node, replacement) => ({
node,
messageId: MESSAGE_ID_ERROR,
data: {
name: node.name,
replacement,
},
});
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
const options = Object.keys(context.options[0]).length === 0
? defaultPreparedOptions
: prepareOptions(context.options[0]);
const {ast, parserServices} = context.sourceCode;
const isHtml = ast.body?.[0]?.type === 'Document';
const isData = ast.type === 'Document' || parserServices?.isYAML || parserServices?.isTOML;
const replaceExternalName = isHtml || isData || ast.type === 'StyleSheet' ? getNonJavaScriptNameReplacer(options) : undefined;
const getExternalNameProblem = ({node, name, location}) => {
const replacement = replaceExternalName(name);
if (replacement) {
return {
node, loc: location, messageId: MESSAGE_ID_ERROR, data: {name, replacement},
};
}
};
if (ast.type === 'StyleSheet' || isHtml) {
if (options.checkVariables) {
const onIdentifier = isHtml ? onHtmlIdentifier : onCssIdentifier;
onIdentifier(context, getExternalNameProblem);
}
return;
}
if (isData) {
if (options.checkProperties) {
onDataKey(context, getExternalNameProblem);
}
return;
}
const {isSafeName, addName} = createFixerNameTracker();
const reportVariable = variable => {
if (variable.defs.length === 0) {
return;
}
// The combined-regex gate is cheap and rejects most names, so run it before the
// import/shorthand checks.
const replacement = getNameReplacement(variable.name, options);
if (!replacement) {
return;
}
const [definition] = variable.defs;
if (!shouldCheckDefaultOrNamespaceImportName(definition, options)) {
return;
}
if (!shouldCheckShorthandImportName(definition, options, context)) {
return;
}
if (
!options.checkShorthandProperties
&& isShorthandPropertyValue(definition.name)
) {
return;
}
const scopes = [
...variable.references.map(reference => reference.from),
variable.scope,
];
const safeReplacement = isIdentifierName(replacement)
? getAvailableVariableName(replacement, scopes, isSafeName)
: undefined;
const problem = createProblem(definition.name, safeReplacement ?? replacement);
if (
safeReplacement
&& shouldRenameVariable(variable)
&& variable.references.every(reference => !reference.vueUsedInTemplate)
) {
addName(safeReplacement, scopes);
problem.suggest = [
{
messageId: MESSAGE_ID_RENAME,
data: {replacement: safeReplacement},
fix: fixer => renameVariable(variable, safeReplacement, context, fixer),
},
];
}
context.report(problem);
};
const reportProperty = node => {
if (!options.checkProperties) {
return;
}
if (node.name === '__proto__') {
return;
}
const replacement = getNameReplacement(node.name, options);
if (!replacement) {
return;
}
if (node.parent.type === 'ExportSpecifier') {
return;
}
// When variables are checked, a TypeScript parameter property is reported as a variable instead
const isProperty = (!options.checkVariables && isTSParameterPropertyName(node))
|| shouldReportIdentifierAsProperty(node);
if (!isProperty) {
return;
}
const problem = createProblem(node, replacement);
context.report(problem);
};
context.on('Identifier', reportProperty);
context.on('PrivateIdentifier', reportProperty);
// eslint-disable-next-line no-warning-comments
// TODO: Consider expanding JavaScript property checks beyond identifiers.
context.on('Program:exit', program => {
if (!options.checkVariables) {
return;
}
for (const variable of getAllVariables(context.sourceCode.getScope(program))) {
reportVariable(variable);
}
});
};
const schema = {
type: 'array',
additionalItems: false,
items: [
{
type: 'object',
additionalProperties: false,
properties: {
checkProperties: {
type: 'boolean',
description: 'Whether to check property names.',
},
checkVariables: {
type: 'boolean',
description: 'Whether to check variable names.',
},
checkDefaultAndNamespaceImports: {
anyOf: [
{
type: 'boolean',
},
{
enum: [
'internal',
],
},
],
description: 'Whether to check default and namespace import names.',
},
checkShorthandImports: {
anyOf: [
{
type: 'boolean',
},
{
enum: [
'internal',
],
},
],
description: 'Whether to check shorthand import names.',
},
checkShorthandProperties: {
type: 'boolean',
description: 'Whether to check shorthand property names.',
},
extendDefaultReplacements: {
type: 'boolean',
description: 'Whether to extend the default replacements.',
},
replacements: {
$ref: '#/definitions/replacements',
description: 'Custom compound word replacements.',
},
allowList: {
$ref: '#/definitions/trueObject',
description: 'Custom allow list of names.',
},
},
},
],
definitions: {
replacements: {
type: 'object',
propertyNames: {
minLength: 1,
},
additionalProperties: {
anyOf: [
{
enum: [
false,
],
},
{
type: 'string',
minLength: 1,
},
],
},
},
trueObject: {
type: 'object',
propertyNames: {
minLength: 1,
},
additionalProperties: {
enum: [
true,
],
},
},
},
};
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce consistent spelling of compound words in identifiers.',
recommended: 'unopinionated',
},
hasSuggestions: true,
schema,
defaultOptions: [{}],
messages,
languages: [
'js/js',
'json/json',
'json/jsonc',
'json/json5',
'css/css',
'html/html',
'soml/soml',
'yml/yaml',
'toml/toml',
],
},
};
export default config;