diff --git a/packages/eslint-plugin/docs/rules/naming-convention.md b/packages/eslint-plugin/docs/rules/naming-convention.md index 0cd4d9324be..d57bc1c69da 100644 --- a/packages/eslint-plugin/docs/rules/naming-convention.md +++ b/packages/eslint-plugin/docs/rules/naming-convention.md @@ -1,7 +1,7 @@ # Enforces naming conventions for everything across a codebase (`naming-convention`) Enforcing naming conventions helps keep the codebase consistent, and reduces overhead when thinking about how to name a variable. -Additionally, a well designed style guide can help communicate intent, such as by enforcing all private properties begin with an `_`, and all global-level constants are written in `UPPER_CASE`. +Additionally, a well-designed style guide can help communicate intent, such as by enforcing all private properties begin with an `_`, and all global-level constants are written in `UPPER_CASE`. There are many different rules that have existed over time, but they have had the problem of not having enough granularity, meaning it was hard to have a well defined style guide, and most of the time you needed 3 or more rules at once to enforce different conventions, hoping they didn't conflict. @@ -39,7 +39,7 @@ type Options = { suffix?: string[]; // selector options - selector: Selector; + selector: Selector | Selector[]; filter?: | string | { @@ -155,6 +155,8 @@ If these are provided, the identifier must start with one of the provided values ### Selector Options - `selector` (see "Allowed Selectors, Modifiers and Types" below). + - Accepts one or array of selectors to define an option block that applies to one or multiple selectors. + - For example, if you provide `{ selector: ['variable', 'function'] }`, then it will apply the same option to variable and function nodes. - `modifiers` allows you to specify which modifiers to granularly apply to, such as the accessibility (`private`/`public`/`protected`), or if the thing is `static`, etc. - The name must match _all_ of the modifiers. - 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. @@ -361,6 +363,23 @@ This allows you to emulate the old `interface-name-prefix` rule. } ``` +### Enforce that variable and function names are in camelCase + +This allows you to lint multiple type with same pattern. + +```json +{ + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": ["variable", "function"], + "format": ["camelCase"], + "leadingUnderscore": "allow" + } + ] +} +``` + ### Ignore properties that require quotes Sometimes you have to use a quoted name that breaks the convention (for example, HTTP headers). diff --git a/packages/eslint-plugin/src/rules/naming-convention.ts b/packages/eslint-plugin/src/rules/naming-convention.ts index fdf1f061788..7d40e0862cb 100644 --- a/packages/eslint-plugin/src/rules/naming-convention.ts +++ b/packages/eslint-plugin/src/rules/naming-convention.ts @@ -1,8 +1,8 @@ import { AST_NODE_TYPES, JSONSchema, - TSESTree, TSESLint, + TSESTree, } from '@typescript-eslint/experimental-utils'; import * as ts from 'typescript'; import * as util from '../util'; @@ -111,7 +111,9 @@ interface Selector { prefix?: string[]; suffix?: string[]; // selector options - selector: IndividualAndMetaSelectorsString; + selector: + | IndividualAndMetaSelectorsString + | IndividualAndMetaSelectorsString[]; modifiers?: ModifiersString[]; types?: TypeModifiersString[]; filter?: @@ -249,10 +251,60 @@ function selectorSchema( }, ]; } + +function selectorsSchema(): JSONSchema.JSONSchema4 { + return { + type: 'object', + properties: { + ...FORMAT_OPTIONS_PROPERTIES, + ...{ + filter: { + oneOf: [ + { + type: 'string', + minLength: 1, + }, + MATCH_REGEX_SCHEMA, + ], + }, + selector: { + type: 'array', + items: { + type: 'string', + enum: [ + ...util.getEnumNames(MetaSelectors), + ...util.getEnumNames(Selectors), + ], + }, + additionalItems: false, + }, + }, + }, + modifiers: { + type: 'array', + items: { + type: 'string', + enum: util.getEnumNames(Modifiers), + }, + additionalItems: false, + }, + types: { + type: 'array', + items: { + type: 'string', + enum: util.getEnumNames(TypeModifiers), + }, + additionalItems: false, + }, + required: ['selector', 'format'], + additionalProperties: false, + }; +} const SCHEMA: JSONSchema.JSONSchema4 = { type: 'array', items: { oneOf: [ + selectorsSchema(), ...selectorSchema('default', false, util.getEnumNames(Modifiers)), ...selectorSchema('variableLike', false), @@ -765,15 +817,15 @@ type ValidatorFunction = ( ) => void; type ParsedOptions = Record; type Context = Readonly>; + function parseOptions(context: Context): ParsedOptions { const normalizedOptions = context.options.map(opt => normalizeOption(opt)); - const parsedOptions = util.getEnumNames(Selectors).reduce((acc, k) => { + return util.getEnumNames(Selectors).reduce((acc, k) => { acc[k] = createValidator(k, context, normalizedOptions); return acc; }, {} as ParsedOptions); - - return parsedOptions; } + function createValidator( type: SelectorsString, context: Context, @@ -1219,7 +1271,7 @@ function normalizeOption(option: Selector): NormalizedSelector { weight |= 1 << 30; } - return { + const normalizedOption = { // format options format: option.format ? option.format.map(f => PredefinedFormats[f]) : null, custom: option.custom @@ -1238,10 +1290,6 @@ function normalizeOption(option: Selector): NormalizedSelector { : null, prefix: option.prefix && option.prefix.length > 0 ? option.prefix : null, suffix: option.suffix && option.suffix.length > 0 ? option.suffix : null, - // selector options - selector: isMetaSelector(option.selector) - ? MetaSelectors[option.selector] - : Selectors[option.selector], modifiers: option.modifiers?.map(m => Modifiers[m]) ?? null, types: option.types?.map(m => TypeModifiers[m]) ?? null, filter: @@ -1256,6 +1304,21 @@ function normalizeOption(option: Selector): NormalizedSelector { // calculated ordering weight based on modifiers modifierWeight: weight, }; + + const selectors = Array.isArray(option.selector) + ? option.selector + : [option.selector]; + + return { + selector: selectors + .map(selector => + isMetaSelector(selector) + ? MetaSelectors[selector] + : Selectors[selector], + ) + .reduce((accumulator, selector) => accumulator | selector), + ...normalizedOption, + }; } function isCorrectType( diff --git a/packages/eslint-plugin/tests/rules/naming-convention.test.ts b/packages/eslint-plugin/tests/rules/naming-convention.test.ts index 84e48af1a58..040c2f7eba4 100644 --- a/packages/eslint-plugin/tests/rules/naming-convention.test.ts +++ b/packages/eslint-plugin/tests/rules/naming-convention.test.ts @@ -205,33 +205,46 @@ function createInvalidTestCases( options: Selector, messageId: MessageIds, data: Record = {}, - ): TSESLint.InvalidTestCase => ({ - options: [ - { - ...options, - filter: IGNORED_FILTER, - }, - ], - code: `// ${JSON.stringify(options)}\n${test.code - .map(code => code.replace(REPLACE_REGEX, preparedName)) - .join('\n')}`, - errors: test.code.map(() => ({ + ): TSESLint.InvalidTestCase => { + const selectors = Array.isArray(test.options.selector) + ? test.options.selector + : [test.options.selector]; + const errorsTemplate = selectors.map(selector => ({ messageId, - ...(test.options.selector !== 'default' && - test.options.selector !== 'variableLike' && - test.options.selector !== 'memberLike' && - test.options.selector !== 'typeLike' + ...(selector !== 'default' && + selector !== 'variableLike' && + selector !== 'memberLike' && + selector !== 'typeLike' ? { data: { - type: selectorTypeToMessageString(test.options.selector), + type: selectorTypeToMessageString(selector), name: preparedName, ...data, }, } : // meta-types will use the correct selector, so don't assert on data shape {}), - })), - }); + })); + + const errors: { + data?: { type: string; name: string }; + messageId: MessageIds; + }[] = []; + test.code.forEach(() => errors.push(...errorsTemplate)); + + return { + options: [ + { + ...options, + filter: IGNORED_FILTER, + }, + ], + code: `// ${JSON.stringify(options)}\n${test.code + .map(code => code.replace(REPLACE_REGEX, preparedName)) + .join('\n')}`, + errors: errors, + }; + }; const prefixSingle = ['MyPrefix']; const prefixMulti = ['MyPrefix1', 'MyPrefix2']; @@ -714,6 +727,27 @@ ruleTester.run('naming-convention', rule, { }, ], }, + { + code: ` + let foo = 'a'; + const _foo = 1; + interface foo {} + class bar {} + function fooFunctionBar() {} + function _fooFunctionBar() {} + `, + options: [ + { + selector: ['default', 'typeLike', 'function'], + format: ['camelCase'], + custom: { + regex: /^unused_\w/.source, + match: false, + }, + leadingUnderscore: 'allow', + }, + ], + }, { code: ` const match = 'test'.match(/test/); @@ -1029,6 +1063,80 @@ ruleTester.run('naming-convention', rule, { }, ], }, + { + code: ` + let unused_foo = 'a'; + const _unused_foo = 1; + function foo_bar() {} + interface IFoo {} + class IBar {} + `, + options: [ + { + selector: ['variable', 'function'], + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + { + selector: ['class', 'interface'], + format: ['PascalCase'], + custom: { + regex: /^I[A-Z]/.source, + match: false, + }, + }, + ], + errors: [ + { + messageId: 'doesNotMatchFormat', + line: 2, + data: { + type: 'Variable', + name: 'unused_foo', + formats: 'camelCase', + }, + }, + { + messageId: 'doesNotMatchFormatTrimmed', + line: 3, + data: { + type: 'Variable', + name: '_unused_foo', + processedName: 'unused_foo', + formats: 'camelCase', + }, + }, + { + messageId: 'doesNotMatchFormat', + line: 4, + data: { + type: 'Function', + name: 'foo_bar', + formats: 'camelCase', + }, + }, + { + messageId: 'satisfyCustom', + line: 5, + data: { + type: 'Interface', + name: 'IFoo', + regex: '/^I[A-Z]/u', + regexMatch: 'not match', + }, + }, + { + messageId: 'satisfyCustom', + line: 6, + data: { + type: 'Class', + name: 'IBar', + regex: '/^I[A-Z]/u', + regexMatch: 'not match', + }, + }, + ], + }, { code: ` const foo = {