diff --git a/packages/eslint-plugin/docs/rules/naming-convention.md b/packages/eslint-plugin/docs/rules/naming-convention.md index 3c0f8325631..089bf6d3f81 100644 --- a/packages/eslint-plugin/docs/rules/naming-convention.md +++ b/packages/eslint-plugin/docs/rules/naming-convention.md @@ -163,6 +163,10 @@ If these are provided, the identifier must start with one of the provided values - For example, if you provide `{ modifiers: ['private', 'static', 'readonly'] }`, then it will only match something that is `private static readonly`, and something that is just `private` will not match. - The following `modifiers` are allowed: - `const` - matches a variable declared as being `const` (`const x = 1`). + - `destructured` - matches a variable declared via an object destructuring pattern (`const {x, z = 2}`). + - Note that this does not match renamed destructured properties (`const {x: y, a: b = 2}`). + - `global` - matches a variable/function declared in the top-level scope. + - `exported` - matches anything that is exported from the module. - `public` - matches any member that is either explicitly declared as `public`, or has no visibility modifier (i.e. implicitly public). - `readonly`, `static`, `abstract`, `protected`, `private` - matches any member explicitly declared with the given modifier. - `types` allows you to specify which types to match. This option supports simple, primitive types only (`boolean`, `string`, `number`, `array`, `function`). @@ -200,10 +204,10 @@ There are two types of selectors, individual selectors, and grouped selectors. Individual Selectors match specific, well-defined sets. There is no overlap between each of the individual selectors. - `variable` - matches any `var` / `let` / `const` variable name. - - Allowed `modifiers`: `const`. + - Allowed `modifiers`: `const`, `destructured`, `global`, `exported`. - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. - `function` - matches any named function declaration or named function expression. - - Allowed `modifiers`: none. + - Allowed `modifiers`: `global`, `exported`. - Allowed `types`: none. - `parameter` - matches any function parameter. Does not match parameter properties. - Allowed `modifiers`: none. @@ -236,16 +240,16 @@ Individual Selectors match specific, well-defined sets. There is no overlap betw - Allowed `modifiers`: none. - Allowed `types`: none. - `class` - matches any class declaration. - - Allowed `modifiers`: `abstract`. + - Allowed `modifiers`: `abstract`, `exported`. - Allowed `types`: none. - `interface` - matches any interface declaration. - - Allowed `modifiers`: none. + - Allowed `modifiers`: `exported`. - Allowed `types`: none. - `typeAlias` - matches any type alias declaration. - - Allowed `modifiers`: none. + - Allowed `modifiers`: `exported`. - Allowed `types`: none. - `enum` - matches any enum declaration. - - Allowed `modifiers`: none. + - Allowed `modifiers`: `exported`. - Allowed `types`: none. - `typeParameter` - matches any generic type parameter declaration. - Allowed `modifiers`: none. @@ -447,6 +451,25 @@ You can use the `filter` option to ignore names that require quoting: } ``` +### Ignore destructured names + +Sometimes you might want to allow destructured properties to retain their original name, even if it breaks your naming convention. + +You can use the `destructured` modifier to match these names, and explicitly set `format: null` to apply no formatting: + +```jsonc +{ + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": "variable", + "modifiers": ["destructured"], + "format": null + } + ] +} +``` + ### Enforce the codebase follows ESLint's `camelcase` conventions ```json diff --git a/packages/eslint-plugin/src/rules/naming-convention.ts b/packages/eslint-plugin/src/rules/naming-convention.ts index 9cc12967c9d..597a7492428 100644 --- a/packages/eslint-plugin/src/rules/naming-convention.ts +++ b/packages/eslint-plugin/src/rules/naming-convention.ts @@ -4,6 +4,7 @@ import { TSESLint, TSESTree, } from '@typescript-eslint/experimental-utils'; +import { PatternVisitor } from '@typescript-eslint/scope-manager'; import * as ts from 'typescript'; import * as util from '../util'; @@ -95,13 +96,23 @@ type MetaSelectorsString = keyof typeof MetaSelectors; type IndividualAndMetaSelectorsString = SelectorsString | MetaSelectorsString; enum Modifiers { + // const variable const = 1 << 0, + // readonly members readonly = 1 << 1, + // static members static = 1 << 2, + // member accessibility public = 1 << 3, protected = 1 << 4, private = 1 << 5, abstract = 1 << 6, + // destructured variable + destructured = 1 << 7, + // variables declared in the top-level scope + global = 1 << 8, + // things that are exported + exported = 1 << 9, } type ModifiersString = keyof typeof Modifiers; @@ -324,8 +335,13 @@ const SCHEMA: JSONSchema.JSONSchema4 = { ...selectorSchema('default', false, util.getEnumNames(Modifiers)), ...selectorSchema('variableLike', false), - ...selectorSchema('variable', true, ['const']), - ...selectorSchema('function', false), + ...selectorSchema('variable', true, [ + 'const', + 'destructured', + 'global', + 'exported', + ]), + ...selectorSchema('function', false, ['global', 'exported']), ...selectorSchema('parameter', true), ...selectorSchema('memberLike', false, [ @@ -412,11 +428,11 @@ const SCHEMA: JSONSchema.JSONSchema4 = { ]), ...selectorSchema('enumMember', false), - ...selectorSchema('typeLike', false, ['abstract']), - ...selectorSchema('class', false, ['abstract']), - ...selectorSchema('interface', false), - ...selectorSchema('typeAlias', false), - ...selectorSchema('enum', false), + ...selectorSchema('typeLike', false, ['abstract', 'exported']), + ...selectorSchema('class', false, ['abstract', 'exported']), + ...selectorSchema('interface', false, ['exported']), + ...selectorSchema('typeAlias', false, ['exported']), + ...selectorSchema('enum', false, ['exported']), ...selectorSchema('typeParameter', false), ], }, @@ -550,22 +566,40 @@ export default util.createRule({ if (!validator) { return; } + const identifiers = getIdentifiersFromPattern(node.id); - const identifiers: TSESTree.Identifier[] = []; - getIdentifiersFromPattern(node.id, identifiers); - - const modifiers = new Set(); + const baseModifiers = new Set(); const parent = node.parent; - if ( - parent && - parent.type === AST_NODE_TYPES.VariableDeclaration && - parent.kind === 'const' - ) { - modifiers.add(Modifiers.const); + if (parent?.type === AST_NODE_TYPES.VariableDeclaration) { + if (parent.kind === 'const') { + baseModifiers.add(Modifiers.const); + } + if (isGlobal(context.getScope())) { + baseModifiers.add(Modifiers.global); + } } - identifiers.forEach(i => { - validator(i, modifiers); + identifiers.forEach(id => { + const modifiers = new Set(baseModifiers); + if ( + // `const { x }` + // does not match `const { x: y }` + (id.parent?.type === AST_NODE_TYPES.Property && + id.parent.shorthand) || + // `const { x = 2 }` + // does not match const `{ x: y = 2 }` + (id.parent?.type === AST_NODE_TYPES.AssignmentPattern && + id.parent.parent?.type === AST_NODE_TYPES.Property && + id.parent.parent.shorthand) + ) { + modifiers.add(Modifiers.destructured); + } + + if (isExported(parent, id.name, context.getScope())) { + modifiers.add(Modifiers.exported); + } + + validator(id, modifiers); }); }, @@ -584,7 +618,17 @@ export default util.createRule({ return; } - validator(node.id); + const modifiers = new Set(); + // functions create their own nested scope + const scope = context.getScope().upper; + if (isGlobal(scope)) { + modifiers.add(Modifiers.global); + } + if (isExported(node, node.id.name, scope)) { + modifiers.add(Modifiers.exported); + } + + validator(node.id, modifiers); }, // #endregion function @@ -608,8 +652,7 @@ export default util.createRule({ return; } - const identifiers: TSESTree.Identifier[] = []; - getIdentifiersFromPattern(param, identifiers); + const identifiers = getIdentifiersFromPattern(param); identifiers.forEach(i => { validator(i); @@ -629,8 +672,7 @@ export default util.createRule({ const modifiers = getMemberModifiers(node); - const identifiers: TSESTree.Identifier[] = []; - getIdentifiersFromPattern(node.parameter, identifiers); + const identifiers = getIdentifiersFromPattern(node.parameter); identifiers.forEach(i => { validator(i, modifiers); @@ -765,6 +807,11 @@ export default util.createRule({ modifiers.add(Modifiers.abstract); } + // classes create their own nested scope + if (isExported(node, id.name, context.getScope().upper)) { + modifiers.add(Modifiers.exported); + } + validator(id, modifiers); }, @@ -778,7 +825,12 @@ export default util.createRule({ return; } - validator(node.id); + const modifiers = new Set(); + if (isExported(node, node.id.name, context.getScope())) { + modifiers.add(Modifiers.exported); + } + + validator(node.id, modifiers); }, // #endregion interface @@ -791,7 +843,12 @@ export default util.createRule({ return; } - validator(node.id); + const modifiers = new Set(); + if (isExported(node, node.id.name, context.getScope())) { + modifiers.add(Modifiers.exported); + } + + validator(node.id, modifiers); }, // #endregion typeAlias @@ -804,7 +861,13 @@ export default util.createRule({ return; } - validator(node.id); + const modifiers = new Set(); + // enums create their own nested scope + if (isExported(node, node.id.name, context.getScope().upper)) { + modifiers.add(Modifiers.exported); + } + + validator(node.id, modifiers); }, // #endregion enum @@ -829,55 +892,54 @@ export default util.createRule({ function getIdentifiersFromPattern( pattern: TSESTree.DestructuringPattern, - identifiers: TSESTree.Identifier[], -): void { - switch (pattern.type) { - case AST_NODE_TYPES.Identifier: - identifiers.push(pattern); - break; - - case AST_NODE_TYPES.ArrayPattern: - pattern.elements.forEach(element => { - if (element !== null) { - getIdentifiersFromPattern(element, identifiers); - } - }); - break; - - case AST_NODE_TYPES.ObjectPattern: - pattern.properties.forEach(property => { - if (property.type === AST_NODE_TYPES.RestElement) { - getIdentifiersFromPattern(property, identifiers); - } else { - // this is a bit weird, but it's because ESTree doesn't have a new node type - // for object destructuring properties - it just reuses Property... - // https://github.com/estree/estree/blob/9ae284b71130d53226e7153b42f01bf819e6e657/es2015.md#L206-L211 - // However, the parser guarantees this is safe (and there is error handling) - getIdentifiersFromPattern( - property.value as TSESTree.DestructuringPattern, - identifiers, - ); - } - }); - break; +): TSESTree.Identifier[] { + const identifiers: TSESTree.Identifier[] = []; + const visitor = new PatternVisitor({}, pattern, id => identifiers.push(id)); + visitor.visit(pattern); + return identifiers; +} - case AST_NODE_TYPES.RestElement: - getIdentifiersFromPattern(pattern.argument, identifiers); - break; +function isExported( + node: TSESTree.Node | undefined, + name: string, + scope: TSESLint.Scope.Scope | null, +): boolean { + if ( + node?.parent?.type === AST_NODE_TYPES.ExportDefaultDeclaration || + node?.parent?.type === AST_NODE_TYPES.ExportNamedDeclaration + ) { + return true; + } - case AST_NODE_TYPES.AssignmentPattern: - getIdentifiersFromPattern(pattern.left, identifiers); - break; + if (scope == null) { + return false; + } - case AST_NODE_TYPES.MemberExpression: - // ignore member expressions, as the everything must already be defined - break; + const variable = scope.set.get(name); + if (variable) { + for (const ref of variable.references) { + const refParent = ref.identifier.parent; + if ( + refParent?.type === AST_NODE_TYPES.ExportDefaultDeclaration || + refParent?.type === AST_NODE_TYPES.ExportSpecifier + ) { + return true; + } + } + } + + return false; +} - default: - // https://github.com/typescript-eslint/typescript-eslint/issues/1282 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - throw new Error(`Unexpected pattern type ${pattern!.type}`); +function isGlobal(scope: TSESLint.Scope.Scope | null): boolean { + if (scope == null) { + return false; } + + return ( + scope.type === TSESLint.Scope.ScopeType.global || + scope.type === TSESLint.Scope.ScopeType.module + ); } type ValidatorFunction = ( diff --git a/packages/eslint-plugin/tests/rules/naming-convention.test.ts b/packages/eslint-plugin/tests/rules/naming-convention.test.ts index adf93fa1490..aed9bf9c47d 100644 --- a/packages/eslint-plugin/tests/rules/naming-convention.test.ts +++ b/packages/eslint-plugin/tests/rules/naming-convention.test.ts @@ -912,6 +912,177 @@ ruleTester.run('naming-convention', rule, { { selector: 'variable', format: ['camelCase'] }, ], }, + { + code: ` + const camelCaseVar = 1; + enum camelCaseEnum {} + class camelCaseClass {} + function camelCaseFunction() {} + interface camelCaseInterface {} + type camelCaseType = {}; + export const PascalCaseVar = 1; + export enum PascalCaseEnum {} + export class PascalCaseClass {} + export function PascalCaseFunction() {} + export interface PascalCaseInterface {} + export type PascalCaseType = {}; + `, + options: [ + { selector: 'default', format: ['camelCase'] }, + { + selector: 'variable', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'function', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'class', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'interface', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'typeAlias', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'enum', + format: ['PascalCase'], + modifiers: ['exported'], + }, + ], + }, + { + code: ` + const camelCaseVar = 1; + enum camelCaseEnum {} + class camelCaseClass {} + function camelCaseFunction() {} + interface camelCaseInterface {} + type camelCaseType = {}; + const PascalCaseVar = 1; + enum PascalCaseEnum {} + class PascalCaseClass {} + function PascalCaseFunction() {} + interface PascalCaseInterface {} + type PascalCaseType = {}; + export { + PascalCaseVar, + PascalCaseEnum, + PascalCaseClass, + PascalCaseFunction, + PascalCaseInterface, + PascalCaseType, + }; + `, + options: [ + { selector: 'default', format: ['camelCase'] }, + { + selector: 'variable', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'function', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'class', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'interface', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'typeAlias', + format: ['PascalCase'], + modifiers: ['exported'], + }, + { + selector: 'enum', + format: ['PascalCase'], + modifiers: ['exported'], + }, + ], + }, + { + code: ` + { + const camelCaseVar = 1; + function camelCaseFunction() {} + declare function camelCaseDeclaredFunction() { + }; + } + const PascalCaseVar = 1; + function PascalCaseFunction() {} + declare function PascalCaseDeclaredFunction() { + }; + `, + options: [ + { selector: 'default', format: ['camelCase'] }, + { + selector: 'variable', + format: ['PascalCase'], + modifiers: ['global'], + }, + { + selector: 'function', + format: ['PascalCase'], + modifiers: ['global'], + }, + ], + }, + { + code: ` + const { some_name1 } = {}; + const { ignore: IgnoredDueToModifiers1 } = {}; + const { some_name2 = 2 } = {}; + const IgnoredDueToModifiers2 = 1; + `, + options: [ + { + selector: 'default', + format: ['PascalCase'], + }, + { + selector: 'variable', + format: ['snake_case'], + modifiers: ['destructured'], + }, + ], + }, + { + code: ` + const { some_name1 } = {}; + const { ignore: IgnoredDueToModifiers1 } = {}; + const { some_name2 = 2 } = {}; + const IgnoredDueToModifiers2 = 1; + `, + options: [ + { + selector: 'default', + format: ['PascalCase'], + }, + { + selector: 'variable', + format: null, + modifiers: ['destructured'], + }, + ], + }, { code: ` class Ignored { @@ -1414,6 +1585,147 @@ ruleTester.run('naming-convention', rule, { }, ], }, + { + code: ` + export const PascalCaseVar = 1; + export enum PascalCaseEnum {} + export class PascalCaseClass {} + export function PascalCaseFunction() {} + export interface PascalCaseInterface {} + export type PascalCaseType = {}; + `, + options: [ + { + selector: 'default', + format: ['snake_case'], + }, + { + selector: 'variable', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'function', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'class', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'interface', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'typeAlias', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'enum', + format: ['camelCase'], + modifiers: ['exported'], + }, + ], + errors: Array(6).fill({ messageId: 'doesNotMatchFormat' }), + }, + { + code: ` + const PascalCaseVar = 1; + enum PascalCaseEnum {} + class PascalCaseClass {} + function PascalCaseFunction() {} + interface PascalCaseInterface {} + type PascalCaseType = {}; + export { + PascalCaseVar, + PascalCaseEnum, + PascalCaseClass, + PascalCaseFunction, + PascalCaseInterface, + PascalCaseType, + }; + `, + options: [ + { selector: 'default', format: ['snake_case'] }, + { + selector: 'variable', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'function', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'class', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'interface', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'typeAlias', + format: ['camelCase'], + modifiers: ['exported'], + }, + { + selector: 'enum', + format: ['camelCase'], + modifiers: ['exported'], + }, + ], + errors: Array(6).fill({ messageId: 'doesNotMatchFormat' }), + }, + { + code: ` + const PascalCaseVar = 1; + function PascalCaseFunction() {} + declare function PascalCaseDeclaredFunction() { + }; + `, + options: [ + { selector: 'default', format: ['snake_case'] }, + { + selector: 'variable', + format: ['camelCase'], + modifiers: ['global'], + }, + { + selector: 'function', + format: ['camelCase'], + modifiers: ['global'], + }, + ], + errors: Array(3).fill({ messageId: 'doesNotMatchFormat' }), + }, + { + code: ` + const { some_name1 } = {}; + const { ignore: IgnoredDueToModifiers1 } = {}; + const { some_name2 = 2 } = {}; + const IgnoredDueToModifiers2 = 1; + `, + options: [ + { + selector: 'default', + format: ['PascalCase'], + }, + { + selector: 'variable', + format: ['UPPER_CASE'], + modifiers: ['destructured'], + }, + ], + errors: Array(2).fill({ messageId: 'doesNotMatchFormat' }), + }, { code: ` class Ignored {