diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/enums.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/enums.ts index c9460305847a..bc2547ffa3f6 100644 --- a/packages/eslint-plugin/src/rules/naming-convention-utils/enums.ts +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/enums.ts @@ -102,6 +102,10 @@ enum Modifiers { unused = 1 << 10, // properties that require quoting requiresQuotes = 1 << 11, + // class members that are overridden + override = 1 << 12, + // class methods, object function properties, or functions that are async via the `async` keyword + async = 1 << 13, // make sure TypeModifiers starts at Modifiers + 1 or else sorting won't work } diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/schema.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/schema.ts index 4136f7186bf4..188f2fe9b61c 100644 --- a/packages/eslint-plugin/src/rules/naming-convention-utils/schema.ts +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/schema.ts @@ -1,4 +1,6 @@ import { JSONSchema } from '@typescript-eslint/utils'; + +import * as util from '../../util'; import { IndividualAndMetaSelectorsString, MetaSelectors, @@ -9,7 +11,6 @@ import { TypeModifiers, UnderscoreOptions, } from './enums'; -import * as util from '../../util'; const UNDERSCORE_SCHEMA: JSONSchema.JSONSchema4 = { type: 'string', @@ -167,15 +168,21 @@ const SCHEMA: JSONSchema.JSONSchema4 = { selectorsSchema(), ...selectorSchema('default', false, util.getEnumNames(Modifiers)), - ...selectorSchema('variableLike', false, ['unused']), + ...selectorSchema('variableLike', false, ['unused', 'async']), ...selectorSchema('variable', true, [ 'const', 'destructured', 'exported', 'global', 'unused', + 'async', + ]), + ...selectorSchema('function', false, [ + 'exported', + 'global', + 'unused', + 'async', ]), - ...selectorSchema('function', false, ['exported', 'global', 'unused']), ...selectorSchema('parameter', true, ['destructured', 'unused']), ...selectorSchema('memberLike', false, [ @@ -186,6 +193,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'readonly', 'requiresQuotes', 'static', + 'override', ]), ...selectorSchema('classProperty', true, [ 'abstract', @@ -195,6 +203,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'readonly', 'requiresQuotes', 'static', + 'override', ]), ...selectorSchema('objectLiteralProperty', true, [ 'public', @@ -210,6 +219,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'protected', 'public', 'readonly', + 'override', ]), ...selectorSchema('property', true, [ 'abstract', @@ -219,6 +229,7 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'readonly', 'requiresQuotes', 'static', + 'override', ]), ...selectorSchema('classMethod', false, [ @@ -228,10 +239,13 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'public', 'requiresQuotes', 'static', + 'override', + 'async', ]), ...selectorSchema('objectLiteralMethod', false, [ 'public', 'requiresQuotes', + 'async', ]), ...selectorSchema('typeMethod', false, ['public', 'requiresQuotes']), ...selectorSchema('method', false, [ @@ -241,6 +255,8 @@ const SCHEMA: JSONSchema.JSONSchema4 = { 'public', 'requiresQuotes', 'static', + 'override', + 'async', ]), ...selectorSchema('accessor', true, [ 'abstract', diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/types.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/types.ts index 9a45bc0aeef5..2b13c11cfa61 100644 --- a/packages/eslint-plugin/src/rules/naming-convention-utils/types.ts +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/types.ts @@ -1,4 +1,6 @@ import { TSESLint, TSESTree } from '@typescript-eslint/utils'; + +import { NamingConventionRuleMessageIds, Options } from '../naming-convention'; import { IndividualAndMetaSelectorsString, MetaSelectors, @@ -13,7 +15,6 @@ import { UnderscoreOptions, UnderscoreOptionsString, } from './enums'; -import { MessageIds, Options } from '../naming-convention'; interface MatchRegex { regex: string; @@ -64,7 +65,9 @@ type ValidatorFunction = ( modifiers?: Set, ) => void; type ParsedOptions = Record; -type Context = Readonly>; +type Context = Readonly< + TSESLint.RuleContext +>; export type { Context, diff --git a/packages/eslint-plugin/src/rules/naming-convention.ts b/packages/eslint-plugin/src/rules/naming-convention.ts index a27d9b09df0f..5d6c5b336eb7 100644 --- a/packages/eslint-plugin/src/rules/naming-convention.ts +++ b/packages/eslint-plugin/src/rules/naming-convention.ts @@ -1,6 +1,6 @@ -import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { PatternVisitor } from '@typescript-eslint/scope-manager'; -import type { ScriptTarget } from 'typescript'; +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; + import * as util from '../util'; import { Context, @@ -11,6 +11,8 @@ import { ValidatorFunction, } from './naming-convention-utils'; +import type { ScriptTarget } from 'typescript'; + type MessageIds = | 'unexpectedUnderscore' | 'missingUnderscore' @@ -138,11 +140,16 @@ export default util.createRule({ if ('readonly' in node && node.readonly) { modifiers.add(Modifiers.readonly); } - if ( - node.type === AST_NODE_TYPES.TSAbstractPropertyDefinition || - node.type === AST_NODE_TYPES.TSAbstractMethodDefinition - ) { - modifiers.add(Modifiers.abstract); + if ('override' in node && node.override) { + modifiers.add(Modifiers.override); + } + { + if ( + node.type === AST_NODE_TYPES.TSAbstractPropertyDefinition || + node.type === AST_NODE_TYPES.TSAbstractMethodDefinition + ) { + modifiers.add(Modifiers.abstract); + } } return modifiers; @@ -182,9 +189,35 @@ export default util.createRule({ ); } - return { - // #region variable + function isAsyncMemberOrProperty( + propertyOrMemberNode: + | TSESTree.PropertyNonComputedName + | TSESTree.TSMethodSignatureNonComputedName + | TSESTree.PropertyDefinitionNonComputedName + | TSESTree.TSAbstractPropertyDefinitionNonComputedName + | TSESTree.MethodDefinitionNonComputedName + | TSESTree.TSAbstractMethodDefinitionNonComputedName, + ): boolean { + return Boolean( + 'value' in propertyOrMemberNode && + propertyOrMemberNode.value && + 'async' in propertyOrMemberNode.value && + propertyOrMemberNode.value.async, + ); + } + function isAsyncVariableIdentifier(id: TSESTree.Identifier): boolean { + return Boolean( + id.parent && + (('async' in id.parent && id.parent.async) || + ('init' in id.parent && + id.parent.init && + 'async' in id.parent.init && + id.parent.init.async)), + ); + } + + return { VariableDeclarator(node: TSESTree.VariableDeclarator): void { const validator = validators.variable; if (!validator) { @@ -219,14 +252,14 @@ export default util.createRule({ modifiers.add(Modifiers.unused); } + if (isAsyncVariableIdentifier(id)) { + modifiers.add(Modifiers.async); + } + validator(id, modifiers); }); }, - // #endregion - - // #region function - 'FunctionDeclaration, TSDeclareFunction, FunctionExpression'( node: | TSESTree.FunctionDeclaration @@ -254,12 +287,12 @@ export default util.createRule({ modifiers.add(Modifiers.unused); } + if (node.async) { + modifiers.add(Modifiers.async); + } + validator(node.id, modifiers); }, - - // #endregion function - - // #region parameter 'FunctionDeclaration, TSDeclareFunction, TSEmptyBodyFunctionExpression, FunctionExpression, ArrowFunctionExpression'( node: | TSESTree.FunctionDeclaration @@ -291,15 +324,15 @@ export default util.createRule({ modifiers.add(Modifiers.unused); } + if (node.async) { + modifiers.add(Modifiers.async); + } + validator(i, modifiers); }); }); }, - // #endregion parameter - - // #region parameterProperty - TSParameterProperty(node): void { const validator = validators.parameterProperty; if (!validator) { @@ -315,10 +348,6 @@ export default util.createRule({ }); }, - // #endregion parameterProperty - - // #region property - ':not(ObjectPattern) > Property[computed = false][kind = "init"][value.type != "ArrowFunctionExpression"][value.type != "FunctionExpression"][value.type != "TSEmptyBodyFunctionExpression"]'( node: TSESTree.PropertyNonComputedName, ): void { @@ -346,10 +375,6 @@ export default util.createRule({ handleMember(validators.typeProperty, node, modifiers); }, - // #endregion property - - // #region method - [[ 'Property[computed = false][kind = "init"][value.type = "ArrowFunctionExpression"]', 'Property[computed = false][kind = "init"][value.type = "FunctionExpression"]', @@ -360,6 +385,11 @@ export default util.createRule({ | TSESTree.TSMethodSignatureNonComputedName, ): void { const modifiers = new Set([Modifiers.public]); + + if (isAsyncMemberOrProperty(node)) { + modifiers.add(Modifiers.async); + } + handleMember(validators.objectLiteralMethod, node, modifiers); }, @@ -376,6 +406,11 @@ export default util.createRule({ | TSESTree.TSAbstractMethodDefinitionNonComputedName, ): void { const modifiers = getMemberModifiers(node); + + if (isAsyncMemberOrProperty(node)) { + modifiers.add(Modifiers.async); + } + handleMember(validators.classMethod, node, modifiers); }, @@ -386,10 +421,6 @@ export default util.createRule({ handleMember(validators.typeMethod, node, modifiers); }, - // #endregion method - - // #region accessor - 'Property[computed = false]:matches([kind = "get"], [kind = "set"])'( node: TSESTree.PropertyNonComputedName, ): void { @@ -404,10 +435,6 @@ export default util.createRule({ handleMember(validators.accessor, node, modifiers); }, - // #endregion accessor - - // #region enumMember - // computed is optional, so can't do [computed = false] 'TSEnumMember[computed != true]'( node: TSESTree.TSEnumMemberNonComputedName, @@ -427,10 +454,6 @@ export default util.createRule({ validator(id, modifiers); }, - // #endregion enumMember - - // #region class - 'ClassDeclaration, ClassExpression'( node: TSESTree.ClassDeclaration | TSESTree.ClassExpression, ): void { @@ -463,10 +486,6 @@ export default util.createRule({ validator(id, modifiers); }, - // #endregion class - - // #region interface - TSInterfaceDeclaration(node): void { const validator = validators.interface; if (!validator) { @@ -487,10 +506,6 @@ export default util.createRule({ validator(node.id, modifiers); }, - // #endregion interface - - // #region typeAlias - TSTypeAliasDeclaration(node): void { const validator = validators.typeAlias; if (!validator) { @@ -511,10 +526,6 @@ export default util.createRule({ validator(node.id, modifiers); }, - // #endregion typeAlias - - // #region enum - TSEnumDeclaration(node): void { const validator = validators.enum; if (!validator) { @@ -536,10 +547,6 @@ export default util.createRule({ validator(node.id, modifiers); }, - // #endregion enum - - // #region typeParameter - 'TSTypeParameterDeclaration > TSTypeParameter'( node: TSESTree.TSTypeParameter, ): void { @@ -557,8 +564,6 @@ export default util.createRule({ validator(node.name, modifiers); }, - - // #endregion typeParameter }; }, }); diff --git a/packages/eslint-plugin/tests/rules/naming-convention/cases/createTestCases.ts b/packages/eslint-plugin/tests/rules/naming-convention/cases/createTestCases.ts index 9d2abfa13383..7e510fd5a9ed 100644 --- a/packages/eslint-plugin/tests/rules/naming-convention/cases/createTestCases.ts +++ b/packages/eslint-plugin/tests/rules/naming-convention/cases/createTestCases.ts @@ -1,6 +1,7 @@ import { TSESLint } from '@typescript-eslint/utils'; + import rule, { - MessageIds, + NamingConventionRuleMessageIds, Options, } from '../../../../src/rules/naming-convention'; import { @@ -264,10 +265,13 @@ export function createTestCases(cases: Cases): void { } function createInvalidTestCases(): TSESLint.InvalidTestCase< - MessageIds, + NamingConventionRuleMessageIds, Options >[] { - const newCases: TSESLint.InvalidTestCase[] = []; + const newCases: TSESLint.InvalidTestCase< + NamingConventionRuleMessageIds, + Options + >[] = []; for (const test of cases) { for (const [formatLoose, names] of Object.entries(formatTestNames)) { @@ -276,9 +280,12 @@ export function createTestCases(cases: Cases): void { const createCase = ( preparedName: string, options: Selector, - messageId: MessageIds, + messageId: NamingConventionRuleMessageIds, data: Record = {}, - ): TSESLint.InvalidTestCase => { + ): TSESLint.InvalidTestCase< + NamingConventionRuleMessageIds, + Options + > => { const selectors = Array.isArray(test.options.selector) ? test.options.selector : [test.options.selector]; @@ -303,7 +310,7 @@ export function createTestCases(cases: Cases): void { const errors: { data?: { type: string; name: string }; - messageId: MessageIds; + messageId: NamingConventionRuleMessageIds; }[] = []; test.code.forEach(() => errors.push(...errorsTemplate)); diff --git a/packages/eslint-plugin/tests/rules/naming-convention/naming-convention.test.ts b/packages/eslint-plugin/tests/rules/naming-convention/naming-convention.test.ts index 4c496b6c127b..e57c6ac77c84 100644 --- a/packages/eslint-plugin/tests/rules/naming-convention/naming-convention.test.ts +++ b/packages/eslint-plugin/tests/rules/naming-convention/naming-convention.test.ts @@ -1,5 +1,12 @@ /* eslint-disable @typescript-eslint/internal/prefer-ast-types-enum */ -import rule from '../../../src/rules/naming-convention'; +import { TestCaseError } from '@typescript-eslint/utils/src/ts-eslint'; + +import rule, { MessageIds } from '../../../src/rules/naming-convention'; +import { + PredefinedFormats, + PredefinedFormatsString, + SelectorsString, +} from '../../../src/rules/naming-convention-utils/enums'; import { getFixturesRootDir, noFormat, RuleTester } from '../../RuleTester'; const ruleTester = new RuleTester({ @@ -13,6 +20,37 @@ const parserOptions = { project: './tsconfig.json', }; +/** Union of all case error configs with `messageId` as discriminant property for type narrowing */ +type CaseErrorWithDataConfig = { + messageId: 'doesNotMatchFormat'; + type: Capitalize; + name: string; + expectedFormats: PredefinedFormatsString[]; +}; +// todo add more union members as required + +function createExpectedTestCaseErrorsWithData( + caseErrorConfigs: CaseErrorWithDataConfig[], +): TestCaseError[] { + return caseErrorConfigs.map(errorConfig => { + if (errorConfig.messageId === 'doesNotMatchFormat') { + const typeNotCamelCase = errorConfig.type + .replace(/([A-Z])/g, ' $1') + .trim(); + return { + messageId: 'doesNotMatchFormat', + data: { + type: typeNotCamelCase, + name: errorConfig.name, + formats: errorConfig.expectedFormats.join(','), + }, + }; + } + + throw Error(`${errorConfig.messageId} not yet supported`); + }); +} + ruleTester.run('naming-convention', rule, { valid: [ { @@ -768,6 +806,105 @@ ruleTester.run('naming-convention', rule, { }, ], }, + { + code: ` + const obj = { + Bar() { + return 42; + }, + async async_bar() { + return 42; + }, + }; + class foo { + public Bar() { + return 42; + } + public async async_bar() { + return 42; + } + } + abstract class foo { + public abstract Bar() { + return 42; + } + public abstract async async_bar() { + return 42; + } + } + `, + parserOptions, + options: [ + { + selector: 'memberLike', + format: ['camelCase'], + }, + { + selector: ['method', 'objectLiteralMethod'], + format: ['snake_case'], + modifiers: ['async'], + }, + { + selector: 'method', + format: ['PascalCase'], + }, + ], + }, + { + code: ` + const async_bar1 = async () => {}; + async function async_bar2() {} + const async_bar3 = async function async_bar4() {}; + `, + parserOptions, + options: [ + { + selector: 'memberLike', + format: ['camelCase'], + }, + { + selector: 'method', + format: ['PascalCase'], + }, + { + selector: ['variable'], + format: ['snake_case'], + modifiers: ['async'], + }, + ], + }, + { + code: ` + class foo extends bar { + public someAttribute = 1; + public override some_attribute_override = 1; + public someMethod() { + return 42; + } + public override some_method_override2() { + return 42; + } + } + abstract class foo extends bar { + public abstract someAttribute: string; + public abstract override some_attribute_override: string; + public abstract someMethod(): string; + public abstract override some_method_override2(): string; + } + `, + parserOptions, + options: [ + { + selector: 'memberLike', + format: ['camelCase'], + }, + { + selector: ['memberLike'], + modifiers: ['override'], + format: ['snake_case'], + }, + ], + }, ], invalid: [ { @@ -1526,5 +1663,262 @@ ruleTester.run('naming-convention', rule, { // 6, not 7 because 'foo' is valid errors: Array(6).fill({ messageId: 'doesNotMatchFormat' }), }, + { + code: ` + class foo { + public Bar() { + return 42; + } + public async async_bar() { + return 42; + } + // ❌ error + public async asyncBar() { + return 42; + } + // ❌ error + public AsyncBar2 = async () => { + return 42; + }; + // ❌ error + public AsyncBar3 = async function () { + return 42; + }; + } + abstract class foo { + public abstract Bar(): number; + public abstract async async_bar(): number; + // ❌ error + public abstract async ASYNC_BAR(): number; + } + `, + parserOptions, + options: [ + { + selector: 'memberLike', + format: ['camelCase'], + }, + { + selector: 'method', + format: ['PascalCase'], + }, + { + selector: ['method', 'objectLiteralMethod'], + format: ['snake_case'], + modifiers: ['async'], + }, + ], + errors: createExpectedTestCaseErrorsWithData([ + { + messageId: 'doesNotMatchFormat', + name: 'asyncBar', + type: 'ClassMethod', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'AsyncBar2', + type: 'ClassMethod', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'AsyncBar3', + type: 'ClassMethod', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'ASYNC_BAR', + type: 'ClassMethod', + expectedFormats: ['snake_case'], + }, + ]), + }, + { + code: ` + const obj = { + Bar() { + return 42; + }, + async async_bar() { + return 42; + }, + // ❌ error + async AsyncBar() { + return 42; + }, + // ❌ error + AsyncBar2: async () => { + return 42; + }, + // ❌ error + AsyncBar3: async function () { + return 42; + }, + }; + `, + parserOptions, + options: [ + { + selector: 'memberLike', + format: ['camelCase'], + }, + { + selector: 'method', + format: ['PascalCase'], + }, + { + selector: ['method', 'objectLiteralMethod'], + format: ['snake_case'], + modifiers: ['async'], + }, + ], + errors: createExpectedTestCaseErrorsWithData([ + { + messageId: 'doesNotMatchFormat', + name: 'AsyncBar', + type: 'ObjectLiteralMethod', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'AsyncBar2', + type: 'ObjectLiteralMethod', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'AsyncBar3', + type: 'ObjectLiteralMethod', + expectedFormats: ['snake_case'], + }, + ]), + }, + { + code: ` + const syncbar1 = () => {}; + function syncBar2() {} + const syncBar3 = function syncBar4() {}; + + // ❌ error + const AsyncBar1 = async () => {}; + const async_bar1 = async () => {}; + // ❌ error + async function asyncBar2() {} + const async_bar3 = async function async_bar4() {}; + async function async_bar2() {} + // ❌ error + const async_bar3 = async function ASYNC_BAR4() {}; + // ❌ error + const asyncBar5 = async function async_bar6() {}; + `, + parserOptions, + options: [ + { + selector: 'variableLike', + format: ['camelCase'], + }, + { + selector: ['variableLike'], + modifiers: ['async'], + format: ['snake_case'], + }, + ], + errors: createExpectedTestCaseErrorsWithData([ + { + messageId: 'doesNotMatchFormat', + name: 'AsyncBar1', + type: 'Variable', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'asyncBar2', + type: 'Function', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'ASYNC_BAR4', + type: 'Function', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'asyncBar5', + type: 'Variable', + expectedFormats: ['snake_case'], + }, + ]), + }, + { + code: ` + class foo extends bar { + public someAttribute = 1; + public override some_attribute_override = 1; + // ❌ error + public override someAttributeOverride = 1; + public someMethod() { + return 42; + } + public override some_method_override2() { + return 42; + } + // ❌ error + public override someMethodOverride2() { + return 42; + } + } + abstract class foo extends bar { + public abstract someAttribute: string; + public abstract override some_attribute_override: string; + // ❌ error + public abstract override someAttributeOverride: string; + public abstract someMethod(): string; + public abstract override some_method_override2(): string; + // ❌ error + public abstract override someMethodOverride2(): string; + } + `, + parserOptions, + options: [ + { + selector: 'memberLike', + format: ['camelCase'], + }, + { + selector: ['memberLike'], + modifiers: ['override'], + format: ['snake_case'], + }, + ], + errors: createExpectedTestCaseErrorsWithData([ + { + messageId: 'doesNotMatchFormat', + name: 'someAttributeOverride', + type: 'ClassProperty', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'someMethodOverride2', + type: 'ClassMethod', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'someAttributeOverride', + type: 'ClassProperty', + expectedFormats: ['snake_case'], + }, + { + messageId: 'doesNotMatchFormat', + name: 'someMethodOverride2', + type: 'ClassMethod', + expectedFormats: ['snake_case'], + }, + ]), + }, ], });