diff --git a/packages/eslint-plugin/docs/rules/naming-convention.md b/packages/eslint-plugin/docs/rules/naming-convention.md index 0d180bbf7ae..6a116ebece6 100644 --- a/packages/eslint-plugin/docs/rules/naming-convention.md +++ b/packages/eslint-plugin/docs/rules/naming-convention.md @@ -183,6 +183,7 @@ If these are provided, the identifier must start with one of the provided values - `global` - matches a variable/function declared in the top-level scope. - `exported` - matches anything that is exported from the module. - `unused` - matches anything that is not used. + - `requiresQuotes` - matches any name that requires quotes as it is not a valid identifier (i.e. has a space, a dash, etc in it). - `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`). @@ -229,31 +230,31 @@ Individual Selectors match specific, well-defined sets. There is no overlap betw - Allowed `modifiers`: `unused`. - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. - `classProperty` - matches any class property. Does not match properties that have direct function expression or arrow function expression values. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. - `objectLiteralProperty` - matches any object literal property. Does not match properties that have direct function expression or arrow function expression values. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. - `typeProperty` - matches any object type property. Does not match properties that have direct function expression or arrow function expression values. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. - `parameterProperty` - matches any parameter property. - Allowed `modifiers`: `private`, `protected`, `public`, `readonly`. - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. - `classMethod` - matches any class method. Also matches properties that have direct function expression or arrow function expression values. Does not match accessors. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: none. - `objectLiteralMethod` - matches any object literal method. Also matches properties that have direct function expression or arrow function expression values. Does not match accessors. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: none. - `typeMethod` - matches any object type method. Also matches properties that have direct function expression or arrow function expression values. Does not match accessors. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: none. - `accessor` - matches any accessor. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. - `enumMember` - matches any enum member. - - Allowed `modifiers`: none. + - Allowed `modifiers`: `requiresQuotes`. - Allowed `types`: none. - `class` - matches any class declaration. - Allowed `modifiers`: `abstract`, `exported`, `unused`. @@ -276,22 +277,22 @@ Individual Selectors match specific, well-defined sets. There is no overlap betw Group Selectors are provided for convenience, and essentially bundle up sets of individual selectors. - `default` - matches everything. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: all modifiers. - Allowed `types`: none. - `variableLike` - matches the same as `variable`, `function` and `parameter`. - Allowed `modifiers`: `unused`. - Allowed `types`: none. - `memberLike` - matches the same as `property`, `parameterProperty`, `method`, `accessor`, `enumMember`. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: none. - `typeLike` - matches the same as `class`, `interface`, `typeAlias`, `enum`, `typeParameter`. - Allowed `modifiers`: `abstract`, `unused`. - Allowed `types`: none. - `property` - matches the same as `classProperty`, `objectLiteralProperty`, `typeProperty`. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. - `method` - matches the same as `classMethod`, `objectLiteralMethod`, `typeMethod`. - - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: none. ## Examples @@ -424,12 +425,36 @@ This allows you to lint multiple type with same pattern. } ``` -### Ignore properties that require quotes +### Ignore properties that **_require_** quotes Sometimes you have to use a quoted name that breaks the convention (for example, HTTP headers). -If this is a common thing in your codebase, then you can use the `filter` option in one of two ways: +If this is a common thing in your codebase, then you have a few options. -You can use the `filter` option to ignore specific names only: +If you simply want to allow all property names that require quotes, you can use the `requiresQuotes` modifier to match any property name that _requires_ quoting, and use `format: null` to ignore the name. + +```jsonc +{ + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": [ + "classProperty", + "objectLiteralProperty", + "typeProperty", + "classMethod", + "objectLiteralMethod", + "typeMethod", + "accessor", + "enumMember" + ], + "format": null, + "modifiers": ["requiresQuotes"] + } + ] +} +``` + +If you have a small and known list of exceptions, you can use the `filter` option to ignore these specific names only: ```jsonc { @@ -448,7 +473,7 @@ You can use the `filter` option to ignore specific names only: } ``` -You can use the `filter` option to ignore names that require quoting: +You can use the `filter` option to ignore names with specific characters: ```jsonc { @@ -467,6 +492,10 @@ You can use the `filter` option to ignore names that require quoting: } ``` +Note that there is no way to ignore any name that is quoted - only names that are required to be quoted. +This is intentional - adding quotes around a name is not an escape hatch for proper naming. +If you want an escape hatch for a specific name - you should can use an [`eslint-disable` comment](https://eslint.org/docs/user-guide/configuring#disabling-rules-with-inline-comments). + ### Ignore destructured names Sometimes you might want to allow destructured properties to retain their original name, even if it breaks your naming convention. diff --git a/packages/eslint-plugin/src/rules/naming-convention.ts b/packages/eslint-plugin/src/rules/naming-convention.ts index fe025389af9..c0211bb64ef 100644 --- a/packages/eslint-plugin/src/rules/naming-convention.ts +++ b/packages/eslint-plugin/src/rules/naming-convention.ts @@ -120,6 +120,8 @@ enum Modifiers { exported = 1 << 9, // things that are unused unused = 1 << 10, + // properties that require quoting + requiresQuotes = 1 << 11, } type ModifiersString = keyof typeof Modifiers; @@ -359,6 +361,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'static', 'readonly', 'abstract', + 'requiresQuotes', ]), ...selectorSchema('classProperty', true, [ 'private', @@ -367,6 +370,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'static', 'readonly', 'abstract', + 'requiresQuotes', ]), ...selectorSchema('objectLiteralProperty', true, [ 'private', @@ -375,6 +379,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'static', 'readonly', 'abstract', + 'requiresQuotes', ]), ...selectorSchema('typeProperty', true, [ 'private', @@ -383,6 +388,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'static', 'readonly', 'abstract', + 'requiresQuotes', ]), ...selectorSchema('parameterProperty', true, [ 'private', @@ -397,6 +403,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'static', 'readonly', 'abstract', + 'requiresQuotes', ]), ...selectorSchema('classMethod', false, [ @@ -405,6 +412,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'public', 'static', 'abstract', + 'requiresQuotes', ]), ...selectorSchema('objectLiteralMethod', false, [ 'private', @@ -412,6 +420,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'public', 'static', 'abstract', + 'requiresQuotes', ]), ...selectorSchema('typeMethod', false, [ 'private', @@ -419,6 +428,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'public', 'static', 'abstract', + 'requiresQuotes', ]), ...selectorSchema('method', false, [ 'private', @@ -426,6 +436,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'public', 'static', 'abstract', + 'requiresQuotes', ]), ...selectorSchema('accessor', true, [ 'private', @@ -433,8 +444,9 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'public', 'static', 'abstract', + 'requiresQuotes', ]), - ...selectorSchema('enumMember', false), + ...selectorSchema('enumMember', false, ['requiresQuotes']), ...selectorSchema('typeLike', false, ['abstract', 'exported', 'unused']), ...selectorSchema('class', false, ['abstract', 'exported', 'unused']), @@ -516,6 +528,9 @@ export default util.createRule({ const validators = parseOptions(context); + const compilerOptions = util + .getParserServices(context, true) + .program.getCompilerOptions(); function handleMember( validator: ValidatorFunction | null, node: @@ -533,6 +548,10 @@ export default util.createRule({ } const key = node.key; + if (requiresQuoting(key, compilerOptions.target)) { + modifiers.add(Modifiers.requiresQuotes); + } + validator(key, modifiers); } @@ -829,7 +848,13 @@ export default util.createRule({ } const id = node.id; - validator(id); + const modifiers = new Set(); + + if (requiresQuoting(id, compilerOptions.target)) { + modifiers.add(Modifiers.requiresQuotes); + } + + validator(id, modifiers); }, // #endregion enumMember @@ -1020,8 +1045,17 @@ function isGlobal(scope: TSESLint.Scope.Scope | null): boolean { ); } -type ValidatorFunction = ( +function requiresQuoting( node: TSESTree.Identifier | TSESTree.Literal, + target: ts.ScriptTarget | undefined, +): boolean { + const name = + node.type === AST_NODE_TYPES.Identifier ? node.name : `${node.value}`; + return util.requiresQuoting(name, target); +} + +type ValidatorFunction = ( + node: TSESTree.Identifier | TSESTree.StringLiteral | TSESTree.NumberLiteral, modifiers?: Set, ) => void; type ParsedOptions = Record; diff --git a/packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts b/packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts index d8a7750efba..8881473da05 100644 --- a/packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts +++ b/packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts @@ -6,6 +6,7 @@ import { getParserServices, isClosingBraceToken, isOpeningBraceToken, + requiresQuoting, } from '../util'; import { isTypeFlagSet, unionTypeParts } from 'tsutils'; @@ -34,24 +35,6 @@ export default createRule({ const checker = service.program.getTypeChecker(); const compilerOptions = service.program.getCompilerOptions(); - function requiresQuoting(name: string): boolean { - if (name.length === 0) { - return true; - } - - if (!ts.isIdentifierStart(name.charCodeAt(0), compilerOptions.target)) { - return true; - } - - for (let i = 1; i < name.length; i += 1) { - if (!ts.isIdentifierPart(name.charCodeAt(i), compilerOptions.target)) { - return true; - } - } - - return false; - } - function getNodeType(node: TSESTree.Node): ts.Type { const tsNode = service.esTreeNodeToTSNodeMap.get(node); return getConstrainedTypeAtLocation(checker, tsNode); @@ -93,7 +76,7 @@ export default createRule({ if ( symbolName && (missingBranchName || missingBranchName === '') && - requiresQuoting(missingBranchName.toString()) + requiresQuoting(missingBranchName.toString(), compilerOptions.target) ) { caseTest = `${symbolName}['${missingBranchName}']`; } diff --git a/packages/eslint-plugin/src/util/index.ts b/packages/eslint-plugin/src/util/index.ts index af0a64eddbf..86e0ec233fd 100644 --- a/packages/eslint-plugin/src/util/index.ts +++ b/packages/eslint-plugin/src/util/index.ts @@ -8,6 +8,7 @@ export * from './misc'; export * from './nullThrows'; export * from './objectIterators'; export * from './propertyTypes'; +export * from './requiresQuoting'; export * from './types'; // this is done for convenience - saves migrating all of the old rules diff --git a/packages/eslint-plugin/src/util/requiresQuoting.ts b/packages/eslint-plugin/src/util/requiresQuoting.ts new file mode 100644 index 00000000000..27c9a2ff77c --- /dev/null +++ b/packages/eslint-plugin/src/util/requiresQuoting.ts @@ -0,0 +1,24 @@ +import * as ts from 'typescript'; + +function requiresQuoting( + name: string, + target: ts.ScriptTarget = ts.ScriptTarget.ESNext, +): boolean { + if (name.length === 0) { + return true; + } + + if (!ts.isIdentifierStart(name.charCodeAt(0), target)) { + return true; + } + + for (let i = 1; i < name.length; i += 1) { + if (!ts.isIdentifierPart(name.charCodeAt(i), target)) { + return true; + } + } + + return false; +} + +export { requiresQuoting }; diff --git a/packages/eslint-plugin/tests/rules/naming-convention.test.ts b/packages/eslint-plugin/tests/rules/naming-convention.test.ts index 87123331153..b53d7b9304c 100644 --- a/packages/eslint-plugin/tests/rules/naming-convention.test.ts +++ b/packages/eslint-plugin/tests/rules/naming-convention.test.ts @@ -1325,6 +1325,113 @@ ruleTester.run('naming-convention', rule, { }, ], }, + { + code: ` + const ignored1 = { + 'a a': 1, + 'b b'() {}, + get 'c c'() { + return 1; + }, + set 'd d'(value: string) {}, + }; + class ignored2 { + 'a a' = 1; + 'b b'() {} + get 'c c'() { + return 1; + } + set 'd d'(value: string) {} + } + interface ignored3 { + 'a a': 1; + 'b b'(): void; + } + type ignored4 = { + 'a a': 1; + 'b b'(): void; + }; + enum ignored5 { + 'a a', + } + `, + options: [ + { + selector: 'default', + format: ['snake_case'], + }, + { + selector: 'default', + format: null, + modifiers: ['requiresQuotes'], + }, + ], + }, + { + code: ` + const ignored1 = { + 'a a': 1, + 'b b'() {}, + get 'c c'() { + return 1; + }, + set 'd d'(value: string) {}, + }; + class ignored2 { + 'a a' = 1; + 'b b'() {} + get 'c c'() { + return 1; + } + set 'd d'(value: string) {} + } + interface ignored3 { + 'a a': 1; + 'b b'(): void; + } + type ignored4 = { + 'a a': 1; + 'b b'(): void; + }; + enum ignored5 { + 'a a', + } + `, + options: [ + { + selector: 'default', + format: ['snake_case'], + }, + { + selector: [ + 'classProperty', + 'objectLiteralProperty', + 'typeProperty', + 'classMethod', + 'objectLiteralMethod', + 'typeMethod', + 'accessor', + 'enumMember', + ], + format: null, + modifiers: ['requiresQuotes'], + }, + // making sure the `requoresQuotes` modifier appropriately overrides this + { + selector: [ + 'classProperty', + 'objectLiteralProperty', + 'typeProperty', + 'classMethod', + 'objectLiteralMethod', + 'typeMethod', + 'accessor', + 'enumMember', + ], + format: ['PascalCase'], + }, + ], + }, ], invalid: [ { @@ -2000,5 +2107,48 @@ ruleTester.run('naming-convention', rule, { ], errors: Array(7).fill({ messageId: 'doesNotMatchFormat' }), }, + { + code: ` + const ignored1 = { + 'a a': 1, + 'b b'() {}, + get 'c c'() { + return 1; + }, + set 'd d'(value: string) {}, + }; + class ignored2 { + 'a a' = 1; + 'b b'() {} + get 'c c'() { + return 1; + } + set 'd d'(value: string) {} + } + interface ignored3 { + 'a a': 1; + 'b b'(): void; + } + type ignored4 = { + 'a a': 1; + 'b b'(): void; + }; + enum ignored5 { + 'a a', + } + `, + options: [ + { + selector: 'default', + format: ['snake_case'], + }, + { + selector: 'default', + format: ['PascalCase'], + modifiers: ['requiresQuotes'], + }, + ], + errors: Array(13).fill({ messageId: 'doesNotMatchFormat' }), + }, ], });