From 14758d2df6339f011514fd034e09a17a6345b667 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Wed, 25 Nov 2020 12:51:27 -0800 Subject: [PATCH] chore(eslint-plugin): [naming-convention] refactor rule to split it up (#2816) The rule file was up to 1700 LOC. It was a pain in the butt to scroll around it and find pieces. I'm pretty sure ESLint / TypeScript gets a bit choked up on a file that large as well (I've been running into all sorts of slowness with it). So simply isolate each part into a module to better separate things and reduce the total LOC per file. Also moved some stuff around in the docs to try and focus each section, and add an FAQ section for the misc stuff --- .../docs/rules/naming-convention.md | 213 +++- .../rules/naming-convention-utils/enums.ts | 133 ++ .../rules/naming-convention-utils/format.ts | 111 ++ .../rules/naming-convention-utils/index.ts | 6 + .../naming-convention-utils/parse-options.ts | 92 ++ .../rules/naming-convention-utils/schema.ts | 286 +++++ .../rules/naming-convention-utils/shared.ts | 20 + .../rules/naming-convention-utils/types.ts | 75 ++ .../naming-convention-utils/validator.ts | 474 +++++++ .../src/rules/naming-convention.ts | 1121 +---------------- .../tests/rules/naming-convention.test.ts | 9 +- 11 files changed, 1389 insertions(+), 1151 deletions(-) create mode 100644 packages/eslint-plugin/src/rules/naming-convention-utils/enums.ts create mode 100644 packages/eslint-plugin/src/rules/naming-convention-utils/format.ts create mode 100644 packages/eslint-plugin/src/rules/naming-convention-utils/index.ts create mode 100644 packages/eslint-plugin/src/rules/naming-convention-utils/parse-options.ts create mode 100644 packages/eslint-plugin/src/rules/naming-convention-utils/schema.ts create mode 100644 packages/eslint-plugin/src/rules/naming-convention-utils/shared.ts create mode 100644 packages/eslint-plugin/src/rules/naming-convention-utils/types.ts create mode 100644 packages/eslint-plugin/src/rules/naming-convention-utils/validator.ts diff --git a/packages/eslint-plugin/docs/rules/naming-convention.md b/packages/eslint-plugin/docs/rules/naming-convention.md index 6a116ebece6..e5bbfb018df 100644 --- a/packages/eslint-plugin/docs/rules/naming-convention.md +++ b/packages/eslint-plugin/docs/rules/naming-convention.md @@ -89,29 +89,7 @@ const defaultOptions: Options = [ ### Format Options Every single selector can have the same set of format options. -When the format of an identifier is checked, it is checked in the following order: - -1. validate leading underscore -1. validate trailing underscore -1. validate prefix -1. validate suffix -1. validate custom -1. validate format - -For steps 1-4, if the identifier matches the option, the matching part will be removed. -For example, if you provide the following formatting option: `{ leadingUnderscore: 'allow', prefix: ['I'], format: ['StrictPascalCase'] }`, for the identifier `_IMyInterface`, then the following checks will occur: - -1. `name = _IMyInterface` -1. validate leading underscore - pass - - Trim leading underscore - `name = IMyInterface` -1. validate trailing underscore - no check -1. validate prefix - pass - - Trim prefix - `name = MyInterface` -1. validate suffix - no check -1. validate custom - no check -1. validate format - pass - -One final note is that if the name were to become empty via this trimming process, it is considered to match all `format`s. An example of where this might be useful is for generic type parameters, where you want all names to be prefixed with `T`, but also want to allow for the single character `T` name. +For information about how each selector is applied, see ["How does the rule evaluate a name's format?"](#how-does-the-rule-evaluate-a-names-format). #### `format` @@ -197,20 +175,7 @@ If these are provided, the identifier must start with one of the provided values - `array` matches any type assignable to `Array | null | undefined` - `function` matches any type assignable to `Function | null | undefined` -The ordering of selectors does not matter. The implementation will automatically sort the selectors to ensure they match from most-specific to least specific. It will keep checking selectors in that order until it finds one that matches the name. - -For example, if you provide the following config: - -```ts -[ - /* 1 */ { selector: 'default', format: ['camelCase'] }, - /* 2 */ { selector: 'variable', format: ['snake_case'] }, - /* 3 */ { selector: 'variable', types: ['boolean'], format: ['UPPER_CASE'] }, - /* 4 */ { selector: 'variableLike', format: ['PascalCase'] }, -]; -``` - -Then for the code `const x = 1`, the rule will validate the selectors in the following order: `3`, `2`, `4`, `1`. +The ordering of selectors does not matter. The implementation will automatically sort the selectors to ensure they match from most-specific to least specific. It will keep checking selectors in that order until it finds one that matches the name. See ["How does the rule automatically order selectors?"](#how-does-the-rule-automatically-order-selectors) #### Allowed Selectors, Modifiers and Types @@ -295,6 +260,180 @@ Group Selectors are provided for convenience, and essentially bundle up sets of - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`, `requiresQuotes`. - Allowed `types`: none. +## FAQ + +This is a big rule, and there's a lot of docs. Here are a few clarifications that people often ask about or figure out via trial-and-error. + +### How does the rule evaluate a selector? + +Each selector is checked in the following way: + +1. check the `selector` + 1. if `selector` is one individual selector → the name's type must be of that type. + 1. if `selector` is a group selector → the name's type must be one of the grouped types. + 1. if `selector` is an array of selectors → apply the above for each selector in the array. +1. check the `filter` + 1. if `filter` is omitted → skip this step. + 1. if the name matches the `filter` → continue evaluating this selector. + 1. if the name does not match the `filter` → skip this selector and continue to the next selector. +1. check the `types` + 1. if `types` is omitted → skip this step. + 1. if the name has a type in `types` → continue evaluating this selector. + 1. if the name does not have a type in `types` → skip this selector and continue to the next selector. + +A name is considered to pass the config if it: + +1. Matches one selector and passes all of that selector's format checks. +2. Matches no selectors. + +A name is considered to fail the config if it matches one selector and fails one that selector's format checks. + +### How does the rule automatically order selectors? + +Each identifier should match exactly one selector. It may match multiple group selectors - but only ever one selector. +With that in mind - the base sort order works out to be: + +1. Individual Selectors +2. Grouped Selectors +3. Default Selector + +Within each of these categories, some further sorting occurs based on what selector options are supplied: + +1. `filter` is given the highest priority above all else. +2. `types` +3. `modifiers` +4. everything else + +For example, if you provide the following config: + +```ts +[ + /* 1 */ { selector: 'default', format: ['camelCase'] }, + /* 2 */ { selector: 'variable', format: ['snake_case'] }, + /* 3 */ { selector: 'variable', types: ['boolean'], format: ['UPPER_CASE'] }, + /* 4 */ { selector: 'variableLike', format: ['PascalCase'] }, +]; +``` + +Then for the code `const x = 1`, the rule will validate the selectors in the following order: `3`, `2`, `4`, `1`. +To clearly spell it out: + +- (3) is tested first because it has `types` and is an individual selector. +- (2) is tested next because it is an individual selector. +- (1) is tested next as it is a grouped selector. +- (4) is tested last as it is the base default selector. + +Its worth noting that whilst this order is applied, all selectors may not run on a name. +This is explained in ["How does the rule evaluate a name's format?"](#how-does-the-rule-evaluate-a-names-format) + +### How does the rule evaluate a name's format? + +When the format of an identifier is checked, it is checked in the following order: + +1. validate leading underscore +1. validate trailing underscore +1. validate prefix +1. validate suffix +1. validate custom +1. validate format + +For steps 1-4, if the identifier matches the option, the matching part will be removed. +This is done so that you can apply formats like PascalCase without worrying about prefixes or underscores causing it to not match. + +One final note is that if the name were to become empty via this trimming process, it is considered to match all `format`s. An example of where this might be useful is for generic type parameters, where you want all names to be prefixed with `T`, but also want to allow for the single character `T` name. + +Here are some examples to help illustrate + +Name: `_IMyInterface` +Selector: + +```json +{ + "leadingUnderscore": "require", + "prefix": ["I"], + "format": ["UPPER_CASE", "StrictPascalCase"] +} +``` + +1. `name = _IMyInterface` +1. validate leading underscore + 1. config is provided + 1. check name → pass + 1. Trim underscore → `name = IMyInterface` +1. validate trailing underscore + 1. config is not provided → skip +1. validate prefix + 1. config is provided + 1. check name → pass + 1. Trim prefix → `name = MyInterface` +1. validate suffix + 1. config is not provided → skip +1. validate custom + 1. config is not provided → skip +1. validate format + 1. for each format... + 1. `format = 'UPPER_CASE'` + 1. check format → fail. + - Important to note that if you supply multiple formats - the name only needs to match _one_ of them! + 1. `format = 'StrictPascalCase'` + 1. check format → success. +1. **_success_** + +Name: `IMyInterface` +Selector: + +```json +{ + "format": ["StrictPascalCase"], + "trailingUnderscore": "allow", + "custom": { + "regex": "^I[A-Z]", + "match": false + } +} +``` + +1. `name = IMyInterface` +1. validate leading underscore + 1. config is not provided → skip +1. validate trailing underscore + 1. config is provided + 1. check name → pass + 1. Trim underscore → `name = IMyInterface` +1. validate prefix + 1. config is not provided → skip +1. validate suffix + 1. config is not provided → skip +1. validate custom + 1. config is provided + 1. `regex = new RegExp("^I[A-Z]")` + 1. `regex.test(name) === custom.match` + 1. **_fail_** → report and exit + +### What happens if I provide a `modifiers` to a Group Selector? + +Some group selectors accept `modifiers`. For the most part these will work exactly the same as with individual selectors. +There is one exception to this in that a modifier might not apply to all individual selectors covered by a group selector. + +For example - `memberLike` includes the `enumMember` selector, and it allows the `protected` modifier. +An `enumMember` can never ever be `protected`, which means that the following config will never match any `enumMember`: + +```json +{ + "selector": "memberLike", + "modifiers": ["protected"] +} +``` + +To help with matching, members that cannot specify an accessibility will always have the `public` modifier. This means that the following config will always match any `enumMember`: + +```json +{ + "selector": "memberLike", + "modifiers": ["public"] +} +``` + ## Examples ### Enforce that all variables, functions and properties follow are camelCase diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/enums.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/enums.ts new file mode 100644 index 00000000000..4dff9611dcf --- /dev/null +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/enums.ts @@ -0,0 +1,133 @@ +enum PredefinedFormats { + camelCase = 1, + strictCamelCase, + PascalCase, + StrictPascalCase, + snake_case, + UPPER_CASE, +} +type PredefinedFormatsString = keyof typeof PredefinedFormats; + +enum UnderscoreOptions { + forbid = 1, + allow, + require, + + // special cases as it's common practice to use double underscore + requireDouble, + allowDouble, + allowSingleOrDouble, +} +type UnderscoreOptionsString = keyof typeof UnderscoreOptions; + +enum Selectors { + // variableLike + variable = 1 << 0, + function = 1 << 1, + parameter = 1 << 2, + + // memberLike + parameterProperty = 1 << 3, + accessor = 1 << 4, + enumMember = 1 << 5, + classMethod = 1 << 6, + objectLiteralMethod = 1 << 7, + typeMethod = 1 << 8, + classProperty = 1 << 9, + objectLiteralProperty = 1 << 10, + typeProperty = 1 << 11, + + // typeLike + class = 1 << 12, + interface = 1 << 13, + typeAlias = 1 << 14, + enum = 1 << 15, + typeParameter = 1 << 17, +} +type SelectorsString = keyof typeof Selectors; + +enum MetaSelectors { + default = -1, + variableLike = 0 | + Selectors.variable | + Selectors.function | + Selectors.parameter, + memberLike = 0 | + Selectors.classProperty | + Selectors.objectLiteralProperty | + Selectors.typeProperty | + Selectors.parameterProperty | + Selectors.enumMember | + Selectors.classMethod | + Selectors.objectLiteralMethod | + Selectors.typeMethod | + Selectors.accessor, + typeLike = 0 | + Selectors.class | + Selectors.interface | + Selectors.typeAlias | + Selectors.enum | + Selectors.typeParameter, + method = 0 | + Selectors.classMethod | + Selectors.objectLiteralMethod | + Selectors.typeProperty, + property = 0 | + Selectors.classProperty | + Selectors.objectLiteralProperty | + Selectors.typeMethod, +} +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, + // things that are unused + unused = 1 << 10, + // properties that require quoting + requiresQuotes = 1 << 11, + + // make sure TypeModifiers starts at Modifiers + 1 or else sorting won't work +} +type ModifiersString = keyof typeof Modifiers; + +enum TypeModifiers { + boolean = 1 << 12, + string = 1 << 13, + number = 1 << 14, + function = 1 << 15, + array = 1 << 16, +} +type TypeModifiersString = keyof typeof TypeModifiers; + +export { + IndividualAndMetaSelectorsString, + MetaSelectors, + MetaSelectorsString, + Modifiers, + ModifiersString, + PredefinedFormats, + PredefinedFormatsString, + Selectors, + SelectorsString, + TypeModifiers, + TypeModifiersString, + UnderscoreOptions, + UnderscoreOptionsString, +}; diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/format.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/format.ts new file mode 100644 index 00000000000..2640aee6ecd --- /dev/null +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/format.ts @@ -0,0 +1,111 @@ +import { PredefinedFormats } from './enums'; + +/* +These format functions are taken from `tslint-consistent-codestyle/naming-convention`: +https://github.com/ajafff/tslint-consistent-codestyle/blob/ab156cc8881bcc401236d999f4ce034b59039e81/rules/namingConventionRule.ts#L603-L645 + +The licence for the code can be viewed here: +https://github.com/ajafff/tslint-consistent-codestyle/blob/ab156cc8881bcc401236d999f4ce034b59039e81/LICENSE +*/ + +/* +Why not regex here? Because it's actually really, really difficult to create a regex to handle +all of the unicode cases, and we have many non-english users that use non-english characters. +https://gist.github.com/mathiasbynens/6334847 +*/ + +function isPascalCase(name: string): boolean { + return ( + name.length === 0 || + (name[0] === name[0].toUpperCase() && !name.includes('_')) + ); +} +function isStrictPascalCase(name: string): boolean { + return ( + name.length === 0 || + (name[0] === name[0].toUpperCase() && hasStrictCamelHumps(name, true)) + ); +} + +function isCamelCase(name: string): boolean { + return ( + name.length === 0 || + (name[0] === name[0].toLowerCase() && !name.includes('_')) + ); +} +function isStrictCamelCase(name: string): boolean { + return ( + name.length === 0 || + (name[0] === name[0].toLowerCase() && hasStrictCamelHumps(name, false)) + ); +} + +function hasStrictCamelHumps(name: string, isUpper: boolean): boolean { + function isUppercaseChar(char: string): boolean { + return char === char.toUpperCase() && char !== char.toLowerCase(); + } + + if (name.startsWith('_')) { + return false; + } + for (let i = 1; i < name.length; ++i) { + if (name[i] === '_') { + return false; + } + if (isUpper === isUppercaseChar(name[i])) { + if (isUpper) { + return false; + } + } else { + isUpper = !isUpper; + } + } + return true; +} + +function isSnakeCase(name: string): boolean { + return ( + name.length === 0 || + (name === name.toLowerCase() && validateUnderscores(name)) + ); +} + +function isUpperCase(name: string): boolean { + return ( + name.length === 0 || + (name === name.toUpperCase() && validateUnderscores(name)) + ); +} + +/** Check for leading trailing and adjacent underscores */ +function validateUnderscores(name: string): boolean { + if (name.startsWith('_')) { + return false; + } + let wasUnderscore = false; + for (let i = 1; i < name.length; ++i) { + if (name[i] === '_') { + if (wasUnderscore) { + return false; + } + wasUnderscore = true; + } else { + wasUnderscore = false; + } + } + return !wasUnderscore; +} + +const PredefinedFormatToCheckFunction: Readonly boolean +>> = { + [PredefinedFormats.PascalCase]: isPascalCase, + [PredefinedFormats.StrictPascalCase]: isStrictPascalCase, + [PredefinedFormats.camelCase]: isCamelCase, + [PredefinedFormats.strictCamelCase]: isStrictCamelCase, + [PredefinedFormats.UPPER_CASE]: isUpperCase, + [PredefinedFormats.snake_case]: isSnakeCase, +}; + +export { PredefinedFormatToCheckFunction }; diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/index.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/index.ts new file mode 100644 index 00000000000..56297213b66 --- /dev/null +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/index.ts @@ -0,0 +1,6 @@ +export { Modifiers } from './enums'; +export type { PredefinedFormatsString } from './enums'; +export type { Context, Selector, ValidatorFunction } from './types'; +export { SCHEMA } from './schema'; +export { selectorTypeToMessageString } from './shared'; +export { parseOptions } from './parse-options'; diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/parse-options.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/parse-options.ts new file mode 100644 index 00000000000..c4e6e36b303 --- /dev/null +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/parse-options.ts @@ -0,0 +1,92 @@ +import * as util from '../../util'; +import { + MetaSelectors, + Modifiers, + PredefinedFormats, + Selectors, + TypeModifiers, + UnderscoreOptions, +} from './enums'; +import { isMetaSelector } from './shared'; +import type { + Context, + NormalizedSelector, + ParsedOptions, + Selector, +} from './types'; +import { createValidator } from './validator'; + +function normalizeOption(option: Selector): NormalizedSelector[] { + let weight = 0; + option.modifiers?.forEach(mod => { + weight |= Modifiers[mod]; + }); + option.types?.forEach(mod => { + weight |= TypeModifiers[mod]; + }); + + // give selectors with a filter the _highest_ priority + if (option.filter) { + weight |= 1 << 30; + } + + const normalizedOption = { + // format options + format: option.format ? option.format.map(f => PredefinedFormats[f]) : null, + custom: option.custom + ? { + regex: new RegExp(option.custom.regex, 'u'), + match: option.custom.match, + } + : null, + leadingUnderscore: + option.leadingUnderscore !== undefined + ? UnderscoreOptions[option.leadingUnderscore] + : null, + trailingUnderscore: + option.trailingUnderscore !== undefined + ? UnderscoreOptions[option.trailingUnderscore] + : null, + prefix: option.prefix && option.prefix.length > 0 ? option.prefix : null, + suffix: option.suffix && option.suffix.length > 0 ? option.suffix : null, + modifiers: option.modifiers?.map(m => Modifiers[m]) ?? null, + types: option.types?.map(m => TypeModifiers[m]) ?? null, + filter: + option.filter !== undefined + ? typeof option.filter === 'string' + ? { + regex: new RegExp(option.filter, 'u'), + match: true, + } + : { + regex: new RegExp(option.filter.regex, 'u'), + match: option.filter.match, + } + : null, + // calculated ordering weight based on modifiers + modifierWeight: weight, + }; + + const selectors = Array.isArray(option.selector) + ? option.selector + : [option.selector]; + + return selectors.map(selector => ({ + selector: isMetaSelector(selector) + ? MetaSelectors[selector] + : Selectors[selector], + ...normalizedOption, + })); +} + +function parseOptions(context: Context): ParsedOptions { + const normalizedOptions = context.options + .map(opt => normalizeOption(opt)) + .reduce((acc, val) => acc.concat(val), []); + return util.getEnumNames(Selectors).reduce((acc, k) => { + acc[k] = createValidator(k, context, normalizedOptions); + return acc; + }, {} as ParsedOptions); +} + +export { parseOptions }; diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/schema.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/schema.ts new file mode 100644 index 00000000000..990017db7c9 --- /dev/null +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/schema.ts @@ -0,0 +1,286 @@ +import { JSONSchema } from '@typescript-eslint/experimental-utils'; +import { + IndividualAndMetaSelectorsString, + MetaSelectors, + Modifiers, + ModifiersString, + PredefinedFormats, + Selectors, + TypeModifiers, + UnderscoreOptions, +} from './enums'; +import * as util from '../../util'; + +const UNDERSCORE_SCHEMA: JSONSchema.JSONSchema4 = { + type: 'string', + enum: util.getEnumNames(UnderscoreOptions), +}; +const PREFIX_SUFFIX_SCHEMA: JSONSchema.JSONSchema4 = { + type: 'array', + items: { + type: 'string', + minLength: 1, + }, + additionalItems: false, +}; +const MATCH_REGEX_SCHEMA: JSONSchema.JSONSchema4 = { + type: 'object', + properties: { + match: { type: 'boolean' }, + regex: { type: 'string' }, + }, + required: ['match', 'regex'], +}; +type JSONSchemaProperties = Record; +const FORMAT_OPTIONS_PROPERTIES: JSONSchemaProperties = { + format: { + oneOf: [ + { + type: 'array', + items: { + type: 'string', + enum: util.getEnumNames(PredefinedFormats), + }, + additionalItems: false, + }, + { + type: 'null', + }, + ], + }, + custom: MATCH_REGEX_SCHEMA, + leadingUnderscore: UNDERSCORE_SCHEMA, + trailingUnderscore: UNDERSCORE_SCHEMA, + prefix: PREFIX_SUFFIX_SCHEMA, + suffix: PREFIX_SUFFIX_SCHEMA, + failureMessage: { + type: 'string', + }, +}; +function selectorSchema( + selectorString: IndividualAndMetaSelectorsString, + allowType: boolean, + modifiers?: ModifiersString[], +): JSONSchema.JSONSchema4[] { + const selector: JSONSchemaProperties = { + filter: { + oneOf: [ + { + type: 'string', + minLength: 1, + }, + MATCH_REGEX_SCHEMA, + ], + }, + selector: { + type: 'string', + enum: [selectorString], + }, + }; + if (modifiers && modifiers.length > 0) { + selector.modifiers = { + type: 'array', + items: { + type: 'string', + enum: modifiers, + }, + additionalItems: false, + }; + } + if (allowType) { + selector.types = { + type: 'array', + items: { + type: 'string', + enum: util.getEnumNames(TypeModifiers), + }, + additionalItems: false, + }; + } + + return [ + { + type: 'object', + properties: { + ...FORMAT_OPTIONS_PROPERTIES, + ...selector, + }, + required: ['selector', 'format'], + additionalProperties: false, + }, + ]; +} + +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, ['unused']), + ...selectorSchema('variable', true, [ + 'const', + 'destructured', + 'global', + 'exported', + 'unused', + ]), + ...selectorSchema('function', false, ['global', 'exported', 'unused']), + ...selectorSchema('parameter', true, ['unused']), + + ...selectorSchema('memberLike', false, [ + 'private', + 'protected', + 'public', + 'static', + 'readonly', + 'abstract', + 'requiresQuotes', + ]), + ...selectorSchema('classProperty', true, [ + 'private', + 'protected', + 'public', + 'static', + 'readonly', + 'abstract', + 'requiresQuotes', + ]), + ...selectorSchema('objectLiteralProperty', true, [ + 'private', + 'protected', + 'public', + 'static', + 'readonly', + 'abstract', + 'requiresQuotes', + ]), + ...selectorSchema('typeProperty', true, [ + 'private', + 'protected', + 'public', + 'static', + 'readonly', + 'abstract', + 'requiresQuotes', + ]), + ...selectorSchema('parameterProperty', true, [ + 'private', + 'protected', + 'public', + 'readonly', + ]), + ...selectorSchema('property', true, [ + 'private', + 'protected', + 'public', + 'static', + 'readonly', + 'abstract', + 'requiresQuotes', + ]), + + ...selectorSchema('classMethod', false, [ + 'private', + 'protected', + 'public', + 'static', + 'abstract', + 'requiresQuotes', + ]), + ...selectorSchema('objectLiteralMethod', false, [ + 'private', + 'protected', + 'public', + 'static', + 'abstract', + 'requiresQuotes', + ]), + ...selectorSchema('typeMethod', false, [ + 'private', + 'protected', + 'public', + 'static', + 'abstract', + 'requiresQuotes', + ]), + ...selectorSchema('method', false, [ + 'private', + 'protected', + 'public', + 'static', + 'abstract', + 'requiresQuotes', + ]), + ...selectorSchema('accessor', true, [ + 'private', + 'protected', + 'public', + 'static', + 'abstract', + 'requiresQuotes', + ]), + ...selectorSchema('enumMember', false, ['requiresQuotes']), + + ...selectorSchema('typeLike', false, ['abstract', 'exported', 'unused']), + ...selectorSchema('class', false, ['abstract', 'exported', 'unused']), + ...selectorSchema('interface', false, ['exported', 'unused']), + ...selectorSchema('typeAlias', false, ['exported', 'unused']), + ...selectorSchema('enum', false, ['exported', 'unused']), + ...selectorSchema('typeParameter', false, ['unused']), + ], + }, + additionalItems: false, +}; + +export { SCHEMA }; diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/shared.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/shared.ts new file mode 100644 index 00000000000..71a6a4b99b7 --- /dev/null +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/shared.ts @@ -0,0 +1,20 @@ +import { + IndividualAndMetaSelectorsString, + MetaSelectors, + MetaSelectorsString, + Selectors, + SelectorsString, +} from './enums'; + +function selectorTypeToMessageString(selectorType: SelectorsString): string { + const notCamelCase = selectorType.replace(/([A-Z])/g, ' $1'); + return notCamelCase.charAt(0).toUpperCase() + notCamelCase.slice(1); +} + +function isMetaSelector( + selector: IndividualAndMetaSelectorsString | Selectors | MetaSelectors, +): selector is MetaSelectorsString { + return selector in MetaSelectors; +} + +export { selectorTypeToMessageString, isMetaSelector }; diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/types.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/types.ts new file mode 100644 index 00000000000..d66f9ad13d4 --- /dev/null +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/types.ts @@ -0,0 +1,75 @@ +import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; +import { + IndividualAndMetaSelectorsString, + MetaSelectors, + Modifiers, + ModifiersString, + PredefinedFormats, + PredefinedFormatsString, + Selectors, + SelectorsString, + TypeModifiers, + TypeModifiersString, + UnderscoreOptions, + UnderscoreOptionsString, +} from './enums'; +import { MessageIds, Options } from '../naming-convention'; + +interface MatchRegex { + regex: string; + match: boolean; +} + +interface Selector { + // format options + format: PredefinedFormatsString[] | null; + custom?: MatchRegex; + leadingUnderscore?: UnderscoreOptionsString; + trailingUnderscore?: UnderscoreOptionsString; + prefix?: string[]; + suffix?: string[]; + // selector options + selector: + | IndividualAndMetaSelectorsString + | IndividualAndMetaSelectorsString[]; + modifiers?: ModifiersString[]; + types?: TypeModifiersString[]; + filter?: string | MatchRegex; +} + +interface NormalizedMatchRegex { + regex: RegExp; + match: boolean; +} + +interface NormalizedSelector { + // format options + format: PredefinedFormats[] | null; + custom: NormalizedMatchRegex | null; + leadingUnderscore: UnderscoreOptions | null; + trailingUnderscore: UnderscoreOptions | null; + prefix: string[] | null; + suffix: string[] | null; + // selector options + selector: Selectors | MetaSelectors; + modifiers: Modifiers[] | null; + types: TypeModifiers[] | null; + filter: NormalizedMatchRegex | null; + // calculated ordering weight based on modifiers + modifierWeight: number; +} + +type ValidatorFunction = ( + node: TSESTree.Identifier | TSESTree.Literal, + modifiers?: Set, +) => void; +type ParsedOptions = Record; +type Context = Readonly>; + +export type { + Context, + NormalizedSelector, + ParsedOptions, + Selector, + ValidatorFunction, +}; diff --git a/packages/eslint-plugin/src/rules/naming-convention-utils/validator.ts b/packages/eslint-plugin/src/rules/naming-convention-utils/validator.ts new file mode 100644 index 00000000000..8945c726368 --- /dev/null +++ b/packages/eslint-plugin/src/rules/naming-convention-utils/validator.ts @@ -0,0 +1,474 @@ +import { + TSESTree, + AST_NODE_TYPES, +} from '@typescript-eslint/experimental-utils'; +import * as ts from 'typescript'; +import { + MetaSelectors, + Modifiers, + PredefinedFormats, + Selectors, + SelectorsString, + TypeModifiers, + UnderscoreOptions, +} from './enums'; +import { PredefinedFormatToCheckFunction } from './format'; +import { isMetaSelector, selectorTypeToMessageString } from './shared'; +import type { Context, NormalizedSelector } from './types'; +import * as util from '../../util'; + +function createValidator( + type: SelectorsString, + context: Context, + allConfigs: NormalizedSelector[], +): (node: TSESTree.Identifier | TSESTree.Literal) => void { + // make sure the "highest priority" configs are checked first + const selectorType = Selectors[type]; + const configs = allConfigs + // gather all of the applicable selectors + .filter( + c => + (c.selector & selectorType) !== 0 || + c.selector === MetaSelectors.default, + ) + .sort((a, b) => { + if (a.selector === b.selector) { + // in the event of the same selector, order by modifier weight + // sort descending - the type modifiers are "more important" + return b.modifierWeight - a.modifierWeight; + } + + const aIsMeta = isMetaSelector(a.selector); + const bIsMeta = isMetaSelector(b.selector); + + // non-meta selectors should go ahead of meta selectors + if (aIsMeta && !bIsMeta) { + return 1; + } + if (!aIsMeta && bIsMeta) { + return -1; + } + + // both aren't meta selectors + // sort descending - the meta selectors are "least important" + return b.selector - a.selector; + }); + + return ( + node: TSESTree.Identifier | TSESTree.Literal, + modifiers: Set = new Set(), + ): void => { + const originalName = + node.type === AST_NODE_TYPES.Identifier ? node.name : `${node.value}`; + + // return will break the loop and stop checking configs + // it is only used when the name is known to have failed or succeeded a config. + for (const config of configs) { + if (config.filter?.regex.test(originalName) !== config.filter?.match) { + // name does not match the filter + continue; + } + + if (config.modifiers?.some(modifier => !modifiers.has(modifier))) { + // does not have the required modifiers + continue; + } + + if (!isCorrectType(node, config, context, selectorType)) { + // is not the correct type + continue; + } + + let name: string | null = originalName; + + name = validateUnderscore('leading', config, name, node, originalName); + if (name === null) { + // fail + return; + } + + name = validateUnderscore('trailing', config, name, node, originalName); + if (name === null) { + // fail + return; + } + + name = validateAffix('prefix', config, name, node, originalName); + if (name === null) { + // fail + return; + } + + name = validateAffix('suffix', config, name, node, originalName); + if (name === null) { + // fail + return; + } + + if (!validateCustom(config, name, node, originalName)) { + // fail + return; + } + + if (!validatePredefinedFormat(config, name, node, originalName)) { + // fail + return; + } + + // it's valid for this config, so we don't need to check any more configs + return; + } + }; + + // centralizes the logic for formatting the report data + function formatReportData({ + affixes, + formats, + originalName, + processedName, + position, + custom, + count, + }: { + affixes?: string[]; + formats?: PredefinedFormats[]; + originalName: string; + processedName?: string; + position?: 'leading' | 'trailing' | 'prefix' | 'suffix'; + custom?: NonNullable; + count?: 'one' | 'two'; + }): Record { + return { + type: selectorTypeToMessageString(type), + name: originalName, + processedName, + position, + count, + affixes: affixes?.join(', '), + formats: formats?.map(f => PredefinedFormats[f]).join(', '), + regex: custom?.regex?.toString(), + regexMatch: + custom?.match === true + ? 'match' + : custom?.match === false + ? 'not match' + : null, + }; + } + + /** + * @returns the name with the underscore removed, if it is valid according to the specified underscore option, null otherwise + */ + function validateUnderscore( + position: 'leading' | 'trailing', + config: NormalizedSelector, + name: string, + node: TSESTree.Identifier | TSESTree.Literal, + originalName: string, + ): string | null { + const option = + position === 'leading' + ? config.leadingUnderscore + : config.trailingUnderscore; + if (!option) { + return name; + } + + const hasSingleUnderscore = + position === 'leading' + ? (): boolean => name.startsWith('_') + : (): boolean => name.endsWith('_'); + const trimSingleUnderscore = + position === 'leading' + ? (): string => name.slice(1) + : (): string => name.slice(0, -1); + + const hasDoubleUnderscore = + position === 'leading' + ? (): boolean => name.startsWith('__') + : (): boolean => name.endsWith('__'); + const trimDoubleUnderscore = + position === 'leading' + ? (): string => name.slice(2) + : (): string => name.slice(0, -2); + + switch (option) { + // ALLOW - no conditions as the user doesn't care if it's there or not + case UnderscoreOptions.allow: { + if (hasSingleUnderscore()) { + return trimSingleUnderscore(); + } + + return name; + } + + case UnderscoreOptions.allowDouble: { + if (hasDoubleUnderscore()) { + return trimDoubleUnderscore(); + } + + return name; + } + + case UnderscoreOptions.allowSingleOrDouble: { + if (hasDoubleUnderscore()) { + return trimDoubleUnderscore(); + } + + if (hasSingleUnderscore()) { + return trimSingleUnderscore(); + } + + return name; + } + + // FORBID + case UnderscoreOptions.forbid: { + if (hasSingleUnderscore()) { + context.report({ + node, + messageId: 'unexpectedUnderscore', + data: formatReportData({ + originalName, + position, + count: 'one', + }), + }); + return null; + } + + return name; + } + + // REQUIRE + case UnderscoreOptions.require: { + if (!hasSingleUnderscore()) { + context.report({ + node, + messageId: 'missingUnderscore', + data: formatReportData({ + originalName, + position, + count: 'one', + }), + }); + return null; + } + + return trimSingleUnderscore(); + } + + case UnderscoreOptions.requireDouble: { + if (!hasDoubleUnderscore()) { + context.report({ + node, + messageId: 'missingUnderscore', + data: formatReportData({ + originalName, + position, + count: 'two', + }), + }); + return null; + } + + return trimDoubleUnderscore(); + } + } + } + + /** + * @returns the name with the affix removed, if it is valid according to the specified affix option, null otherwise + */ + function validateAffix( + position: 'prefix' | 'suffix', + config: NormalizedSelector, + name: string, + node: TSESTree.Identifier | TSESTree.Literal, + originalName: string, + ): string | null { + const affixes = config[position]; + if (!affixes || affixes.length === 0) { + return name; + } + + for (const affix of affixes) { + const hasAffix = + position === 'prefix' ? name.startsWith(affix) : name.endsWith(affix); + const trimAffix = + position === 'prefix' + ? (): string => name.slice(affix.length) + : (): string => name.slice(0, -affix.length); + + if (hasAffix) { + // matches, so trim it and return + return trimAffix(); + } + } + + context.report({ + node, + messageId: 'missingAffix', + data: formatReportData({ + originalName, + position, + affixes, + }), + }); + return null; + } + + /** + * @returns true if the name is valid according to the `regex` option, false otherwise + */ + function validateCustom( + config: NormalizedSelector, + name: string, + node: TSESTree.Identifier | TSESTree.Literal, + originalName: string, + ): boolean { + const custom = config.custom; + if (!custom) { + return true; + } + + const result = custom.regex.test(name); + if (custom.match && result) { + return true; + } + if (!custom.match && !result) { + return true; + } + + context.report({ + node, + messageId: 'satisfyCustom', + data: formatReportData({ + originalName, + custom, + }), + }); + return false; + } + + /** + * @returns true if the name is valid according to the `format` option, false otherwise + */ + function validatePredefinedFormat( + config: NormalizedSelector, + name: string, + node: TSESTree.Identifier | TSESTree.Literal, + originalName: string, + ): boolean { + const formats = config.format; + if (formats === null || formats.length === 0) { + return true; + } + + for (const format of formats) { + const checker = PredefinedFormatToCheckFunction[format]; + if (checker(name)) { + return true; + } + } + + context.report({ + node, + messageId: + originalName === name + ? 'doesNotMatchFormat' + : 'doesNotMatchFormatTrimmed', + data: formatReportData({ + originalName, + processedName: name, + formats, + }), + }); + return false; + } +} + +const SelectorsAllowedToHaveTypes = + Selectors.variable | + Selectors.parameter | + Selectors.classProperty | + Selectors.objectLiteralProperty | + Selectors.typeProperty | + Selectors.parameterProperty | + Selectors.accessor; + +function isCorrectType( + node: TSESTree.Node, + config: NormalizedSelector, + context: Context, + selector: Selectors, +): boolean { + if (config.types === null) { + return true; + } + + if ((SelectorsAllowedToHaveTypes & selector) === 0) { + return true; + } + + const { esTreeNodeToTSNodeMap, program } = util.getParserServices(context); + const checker = program.getTypeChecker(); + const tsNode = esTreeNodeToTSNodeMap.get(node); + const type = checker + .getTypeAtLocation(tsNode) + // remove null and undefined from the type, as we don't care about it here + .getNonNullableType(); + + for (const allowedType of config.types) { + switch (allowedType) { + case TypeModifiers.array: + if ( + isAllTypesMatch( + type, + t => checker.isArrayType(t) || checker.isTupleType(t), + ) + ) { + return true; + } + break; + + case TypeModifiers.function: + if (isAllTypesMatch(type, t => t.getCallSignatures().length > 0)) { + return true; + } + break; + + case TypeModifiers.boolean: + case TypeModifiers.number: + case TypeModifiers.string: { + const typeString = checker.typeToString( + // this will resolve things like true => boolean, 'a' => string and 1 => number + checker.getWidenedType(checker.getBaseTypeOfLiteralType(type)), + ); + const allowedTypeString = TypeModifiers[allowedType]; + if (typeString === allowedTypeString) { + return true; + } + break; + } + } + } + + return false; +} + +/** + * @returns `true` if the type (or all union types) in the given type return true for the callback + */ +function isAllTypesMatch( + type: ts.Type, + cb: (type: ts.Type) => boolean, +): boolean { + if (type.isUnion()) { + return type.types.every(t => cb(t)); + } + + return cb(type); +} + +export { createValidator }; diff --git a/packages/eslint-plugin/src/rules/naming-convention.ts b/packages/eslint-plugin/src/rules/naming-convention.ts index c0211bb64ef..338ebfd866b 100644 --- a/packages/eslint-plugin/src/rules/naming-convention.ts +++ b/packages/eslint-plugin/src/rules/naming-convention.ts @@ -1,12 +1,19 @@ import { AST_NODE_TYPES, - JSONSchema, TSESLint, TSESTree, } from '@typescript-eslint/experimental-utils'; import { PatternVisitor } from '@typescript-eslint/scope-manager'; -import * as ts from 'typescript'; +import type { ScriptTarget } from 'typescript'; import * as util from '../util'; +import { + Context, + Modifiers, + parseOptions, + SCHEMA, + Selector, + ValidatorFunction, +} from './naming-convention-utils'; type MessageIds = | 'unexpectedUnderscore' @@ -16,451 +23,11 @@ type MessageIds = | 'doesNotMatchFormat' | 'doesNotMatchFormatTrimmed'; -// #region Options Type Config - -enum PredefinedFormats { - camelCase = 1, - strictCamelCase, - PascalCase, - StrictPascalCase, - snake_case, - UPPER_CASE, -} -type PredefinedFormatsString = keyof typeof PredefinedFormats; - -enum UnderscoreOptions { - forbid = 1, - allow, - require, - - // special cases as it's common practice to use double underscore - requireDouble, - allowDouble, - allowSingleOrDouble, -} -type UnderscoreOptionsString = keyof typeof UnderscoreOptions; - -enum Selectors { - // variableLike - variable = 1 << 0, - function = 1 << 1, - parameter = 1 << 2, - - // memberLike - parameterProperty = 1 << 3, - accessor = 1 << 4, - enumMember = 1 << 5, - classMethod = 1 << 6, - objectLiteralMethod = 1 << 7, - typeMethod = 1 << 8, - classProperty = 1 << 9, - objectLiteralProperty = 1 << 10, - typeProperty = 1 << 11, - - // typeLike - class = 1 << 12, - interface = 1 << 13, - typeAlias = 1 << 14, - enum = 1 << 15, - typeParameter = 1 << 17, -} -type SelectorsString = keyof typeof Selectors; - -enum MetaSelectors { - default = -1, - variableLike = 0 | - Selectors.variable | - Selectors.function | - Selectors.parameter, - memberLike = 0 | - Selectors.classProperty | - Selectors.objectLiteralProperty | - Selectors.typeProperty | - Selectors.parameterProperty | - Selectors.enumMember | - Selectors.classMethod | - Selectors.objectLiteralMethod | - Selectors.typeMethod | - Selectors.accessor, - typeLike = 0 | - Selectors.class | - Selectors.interface | - Selectors.typeAlias | - Selectors.enum | - Selectors.typeParameter, - method = 0 | - Selectors.classMethod | - Selectors.objectLiteralMethod | - Selectors.typeProperty, - property = 0 | - Selectors.classProperty | - Selectors.objectLiteralProperty | - Selectors.typeMethod, -} -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, - // things that are unused - unused = 1 << 10, - // properties that require quoting - requiresQuotes = 1 << 11, -} -type ModifiersString = keyof typeof Modifiers; - -enum TypeModifiers { - boolean = 1 << 10, - string = 1 << 11, - number = 1 << 12, - function = 1 << 13, - array = 1 << 14, -} -type TypeModifiersString = keyof typeof TypeModifiers; - -interface Selector { - // format options - format: PredefinedFormatsString[] | null; - custom?: { - regex: string; - match: boolean; - }; - leadingUnderscore?: UnderscoreOptionsString; - trailingUnderscore?: UnderscoreOptionsString; - prefix?: string[]; - suffix?: string[]; - // selector options - selector: - | IndividualAndMetaSelectorsString - | IndividualAndMetaSelectorsString[]; - modifiers?: ModifiersString[]; - types?: TypeModifiersString[]; - filter?: - | string - | { - regex: string; - match: boolean; - }; -} -interface NormalizedSelector { - // format options - format: PredefinedFormats[] | null; - custom: { - regex: RegExp; - match: boolean; - } | null; - leadingUnderscore: UnderscoreOptions | null; - trailingUnderscore: UnderscoreOptions | null; - prefix: string[] | null; - suffix: string[] | null; - // selector options - selector: Selectors | MetaSelectors; - modifiers: Modifiers[] | null; - types: TypeModifiers[] | null; - filter: { - regex: RegExp; - match: boolean; - } | null; - // calculated ordering weight based on modifiers - modifierWeight: number; -} - // Note that this intentionally does not strictly type the modifiers/types properties. // This is because doing so creates a huge headache, as the rule's code doesn't need to care. // The JSON Schema strictly types these properties, so we know the user won't input invalid config. type Options = Selector[]; -// #endregion Options Type Config - -// #region Schema Config - -const UNDERSCORE_SCHEMA: JSONSchema.JSONSchema4 = { - type: 'string', - enum: util.getEnumNames(UnderscoreOptions), -}; -const PREFIX_SUFFIX_SCHEMA: JSONSchema.JSONSchema4 = { - type: 'array', - items: { - type: 'string', - minLength: 1, - }, - additionalItems: false, -}; -const MATCH_REGEX_SCHEMA: JSONSchema.JSONSchema4 = { - type: 'object', - properties: { - match: { type: 'boolean' }, - regex: { type: 'string' }, - }, - required: ['match', 'regex'], -}; -type JSONSchemaProperties = Record; -const FORMAT_OPTIONS_PROPERTIES: JSONSchemaProperties = { - format: { - oneOf: [ - { - type: 'array', - items: { - type: 'string', - enum: util.getEnumNames(PredefinedFormats), - }, - additionalItems: false, - }, - { - type: 'null', - }, - ], - }, - custom: MATCH_REGEX_SCHEMA, - leadingUnderscore: UNDERSCORE_SCHEMA, - trailingUnderscore: UNDERSCORE_SCHEMA, - prefix: PREFIX_SUFFIX_SCHEMA, - suffix: PREFIX_SUFFIX_SCHEMA, -}; -function selectorSchema( - selectorString: IndividualAndMetaSelectorsString, - allowType: boolean, - modifiers?: ModifiersString[], -): JSONSchema.JSONSchema4[] { - const selector: JSONSchemaProperties = { - filter: { - oneOf: [ - { - type: 'string', - minLength: 1, - }, - MATCH_REGEX_SCHEMA, - ], - }, - selector: { - type: 'string', - enum: [selectorString], - }, - }; - if (modifiers && modifiers.length > 0) { - selector.modifiers = { - type: 'array', - items: { - type: 'string', - enum: modifiers, - }, - additionalItems: false, - }; - } - if (allowType) { - selector.types = { - type: 'array', - items: { - type: 'string', - enum: util.getEnumNames(TypeModifiers), - }, - additionalItems: false, - }; - } - - return [ - { - type: 'object', - properties: { - ...FORMAT_OPTIONS_PROPERTIES, - ...selector, - }, - required: ['selector', 'format'], - additionalProperties: false, - }, - ]; -} - -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, ['unused']), - ...selectorSchema('variable', true, [ - 'const', - 'destructured', - 'global', - 'exported', - 'unused', - ]), - ...selectorSchema('function', false, ['global', 'exported', 'unused']), - ...selectorSchema('parameter', true, ['unused']), - - ...selectorSchema('memberLike', false, [ - 'private', - 'protected', - 'public', - 'static', - 'readonly', - 'abstract', - 'requiresQuotes', - ]), - ...selectorSchema('classProperty', true, [ - 'private', - 'protected', - 'public', - 'static', - 'readonly', - 'abstract', - 'requiresQuotes', - ]), - ...selectorSchema('objectLiteralProperty', true, [ - 'private', - 'protected', - 'public', - 'static', - 'readonly', - 'abstract', - 'requiresQuotes', - ]), - ...selectorSchema('typeProperty', true, [ - 'private', - 'protected', - 'public', - 'static', - 'readonly', - 'abstract', - 'requiresQuotes', - ]), - ...selectorSchema('parameterProperty', true, [ - 'private', - 'protected', - 'public', - 'readonly', - ]), - ...selectorSchema('property', true, [ - 'private', - 'protected', - 'public', - 'static', - 'readonly', - 'abstract', - 'requiresQuotes', - ]), - - ...selectorSchema('classMethod', false, [ - 'private', - 'protected', - 'public', - 'static', - 'abstract', - 'requiresQuotes', - ]), - ...selectorSchema('objectLiteralMethod', false, [ - 'private', - 'protected', - 'public', - 'static', - 'abstract', - 'requiresQuotes', - ]), - ...selectorSchema('typeMethod', false, [ - 'private', - 'protected', - 'public', - 'static', - 'abstract', - 'requiresQuotes', - ]), - ...selectorSchema('method', false, [ - 'private', - 'protected', - 'public', - 'static', - 'abstract', - 'requiresQuotes', - ]), - ...selectorSchema('accessor', true, [ - 'private', - 'protected', - 'public', - 'static', - 'abstract', - 'requiresQuotes', - ]), - ...selectorSchema('enumMember', false, ['requiresQuotes']), - - ...selectorSchema('typeLike', false, ['abstract', 'exported', 'unused']), - ...selectorSchema('class', false, ['abstract', 'exported', 'unused']), - ...selectorSchema('interface', false, ['exported', 'unused']), - ...selectorSchema('typeAlias', false, ['exported', 'unused']), - ...selectorSchema('enum', false, ['exported', 'unused']), - ...selectorSchema('typeParameter', false, ['unused']), - ], - }, - additionalItems: false, -}; - -// #endregion Schema Config - // This essentially mirrors ESLint's `camelcase` rule // note that that rule ignores leading and trailing underscores and only checks those in the middle of a variable name const defaultCamelCaseAllTheThingsConfig: Options = [ @@ -528,6 +95,7 @@ export default util.createRule({ const validators = parseOptions(context); + // getParserServices(context, false) -- dirty hack to work around the docs checker test... const compilerOptions = util .getParserServices(context, true) .program.getCompilerOptions(); @@ -1047,676 +615,11 @@ function isGlobal(scope: TSESLint.Scope.Scope | null): boolean { function requiresQuoting( node: TSESTree.Identifier | TSESTree.Literal, - target: ts.ScriptTarget | undefined, + target: 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; -type Context = Readonly>; - -function parseOptions(context: Context): ParsedOptions { - const normalizedOptions = context.options - .map(opt => normalizeOption(opt)) - .reduce((acc, val) => acc.concat(val), []); - return util.getEnumNames(Selectors).reduce((acc, k) => { - acc[k] = createValidator(k, context, normalizedOptions); - return acc; - }, {} as ParsedOptions); -} - -function createValidator( - type: SelectorsString, - context: Context, - allConfigs: NormalizedSelector[], -): (node: TSESTree.Identifier | TSESTree.Literal) => void { - // make sure the "highest priority" configs are checked first - const selectorType = Selectors[type]; - const configs = allConfigs - // gather all of the applicable selectors - .filter( - c => - (c.selector & selectorType) !== 0 || - c.selector === MetaSelectors.default, - ) - .sort((a, b) => { - if (a.selector === b.selector) { - // in the event of the same selector, order by modifier weight - // sort descending - the type modifiers are "more important" - return b.modifierWeight - a.modifierWeight; - } - - const aIsMeta = isMetaSelector(a.selector); - const bIsMeta = isMetaSelector(b.selector); - - // non-meta selectors should go ahead of meta selectors - if (aIsMeta && !bIsMeta) { - return 1; - } - if (!aIsMeta && bIsMeta) { - return -1; - } - - // both aren't meta selectors - // sort descending - the meta selectors are "least important" - return b.selector - a.selector; - }); - - return ( - node: TSESTree.Identifier | TSESTree.Literal, - modifiers: Set = new Set(), - ): void => { - const originalName = - node.type === AST_NODE_TYPES.Identifier ? node.name : `${node.value}`; - - // return will break the loop and stop checking configs - // it is only used when the name is known to have failed or succeeded a config. - for (const config of configs) { - if (config.filter?.regex.test(originalName) !== config.filter?.match) { - // name does not match the filter - continue; - } - - if (config.modifiers?.some(modifier => !modifiers.has(modifier))) { - // does not have the required modifiers - continue; - } - - if (!isCorrectType(node, config, context)) { - // is not the correct type - continue; - } - - let name: string | null = originalName; - - name = validateUnderscore('leading', config, name, node, originalName); - if (name === null) { - // fail - return; - } - - name = validateUnderscore('trailing', config, name, node, originalName); - if (name === null) { - // fail - return; - } - - name = validateAffix('prefix', config, name, node, originalName); - if (name === null) { - // fail - return; - } - - name = validateAffix('suffix', config, name, node, originalName); - if (name === null) { - // fail - return; - } - - if (!validateCustom(config, name, node, originalName)) { - // fail - return; - } - - if (!validatePredefinedFormat(config, name, node, originalName)) { - // fail - return; - } - - // it's valid for this config, so we don't need to check any more configs - return; - } - }; - - // centralizes the logic for formatting the report data - function formatReportData({ - affixes, - formats, - originalName, - processedName, - position, - custom, - count, - }: { - affixes?: string[]; - formats?: PredefinedFormats[]; - originalName: string; - processedName?: string; - position?: 'leading' | 'trailing' | 'prefix' | 'suffix'; - custom?: NonNullable; - count?: 'one' | 'two'; - }): Record { - return { - type: selectorTypeToMessageString(type), - name: originalName, - processedName, - position, - count, - affixes: affixes?.join(', '), - formats: formats?.map(f => PredefinedFormats[f]).join(', '), - regex: custom?.regex?.toString(), - regexMatch: - custom?.match === true - ? 'match' - : custom?.match === false - ? 'not match' - : null, - }; - } - - /** - * @returns the name with the underscore removed, if it is valid according to the specified underscore option, null otherwise - */ - function validateUnderscore( - position: 'leading' | 'trailing', - config: NormalizedSelector, - name: string, - node: TSESTree.Identifier | TSESTree.Literal, - originalName: string, - ): string | null { - const option = - position === 'leading' - ? config.leadingUnderscore - : config.trailingUnderscore; - if (!option) { - return name; - } - - const hasSingleUnderscore = - position === 'leading' - ? (): boolean => name.startsWith('_') - : (): boolean => name.endsWith('_'); - const trimSingleUnderscore = - position === 'leading' - ? (): string => name.slice(1) - : (): string => name.slice(0, -1); - - const hasDoubleUnderscore = - position === 'leading' - ? (): boolean => name.startsWith('__') - : (): boolean => name.endsWith('__'); - const trimDoubleUnderscore = - position === 'leading' - ? (): string => name.slice(2) - : (): string => name.slice(0, -2); - - switch (option) { - // ALLOW - no conditions as the user doesn't care if it's there or not - case UnderscoreOptions.allow: { - if (hasSingleUnderscore()) { - return trimSingleUnderscore(); - } - - return name; - } - - case UnderscoreOptions.allowDouble: { - if (hasDoubleUnderscore()) { - return trimDoubleUnderscore(); - } - - return name; - } - - case UnderscoreOptions.allowSingleOrDouble: { - if (hasDoubleUnderscore()) { - return trimDoubleUnderscore(); - } - - if (hasSingleUnderscore()) { - return trimSingleUnderscore(); - } - - return name; - } - - // FORBID - case UnderscoreOptions.forbid: { - if (hasSingleUnderscore()) { - context.report({ - node, - messageId: 'unexpectedUnderscore', - data: formatReportData({ - originalName, - position, - count: 'one', - }), - }); - return null; - } - - return name; - } - - // REQUIRE - case UnderscoreOptions.require: { - if (!hasSingleUnderscore()) { - context.report({ - node, - messageId: 'missingUnderscore', - data: formatReportData({ - originalName, - position, - count: 'one', - }), - }); - return null; - } - - return trimSingleUnderscore(); - } - - case UnderscoreOptions.requireDouble: { - if (!hasDoubleUnderscore()) { - context.report({ - node, - messageId: 'missingUnderscore', - data: formatReportData({ - originalName, - position, - count: 'two', - }), - }); - return null; - } - - return trimDoubleUnderscore(); - } - } - } - - /** - * @returns the name with the affix removed, if it is valid according to the specified affix option, null otherwise - */ - function validateAffix( - position: 'prefix' | 'suffix', - config: NormalizedSelector, - name: string, - node: TSESTree.Identifier | TSESTree.Literal, - originalName: string, - ): string | null { - const affixes = config[position]; - if (!affixes || affixes.length === 0) { - return name; - } - - for (const affix of affixes) { - const hasAffix = - position === 'prefix' ? name.startsWith(affix) : name.endsWith(affix); - const trimAffix = - position === 'prefix' - ? (): string => name.slice(affix.length) - : (): string => name.slice(0, -affix.length); - - if (hasAffix) { - // matches, so trim it and return - return trimAffix(); - } - } - - context.report({ - node, - messageId: 'missingAffix', - data: formatReportData({ - originalName, - position, - affixes, - }), - }); - return null; - } - - /** - * @returns true if the name is valid according to the `regex` option, false otherwise - */ - function validateCustom( - config: NormalizedSelector, - name: string, - node: TSESTree.Identifier | TSESTree.Literal, - originalName: string, - ): boolean { - const custom = config.custom; - if (!custom) { - return true; - } - - const result = custom.regex.test(name); - if (custom.match && result) { - return true; - } - if (!custom.match && !result) { - return true; - } - - context.report({ - node, - messageId: 'satisfyCustom', - data: formatReportData({ - originalName, - custom, - }), - }); - return false; - } - - /** - * @returns true if the name is valid according to the `format` option, false otherwise - */ - function validatePredefinedFormat( - config: NormalizedSelector, - name: string, - node: TSESTree.Identifier | TSESTree.Literal, - originalName: string, - ): boolean { - const formats = config.format; - if (formats === null || formats.length === 0) { - return true; - } - - for (const format of formats) { - const checker = PredefinedFormatToCheckFunction[format]; - if (checker(name)) { - return true; - } - } - - context.report({ - node, - messageId: - originalName === name - ? 'doesNotMatchFormat' - : 'doesNotMatchFormatTrimmed', - data: formatReportData({ - originalName, - processedName: name, - formats, - }), - }); - return false; - } -} - -// #region Predefined Format Functions - -/* -These format functions are taken from `tslint-consistent-codestyle/naming-convention`: -https://github.com/ajafff/tslint-consistent-codestyle/blob/ab156cc8881bcc401236d999f4ce034b59039e81/rules/namingConventionRule.ts#L603-L645 - -The licence for the code can be viewed here: -https://github.com/ajafff/tslint-consistent-codestyle/blob/ab156cc8881bcc401236d999f4ce034b59039e81/LICENSE -*/ - -/* -Why not regex here? Because it's actually really, really difficult to create a regex to handle -all of the unicode cases, and we have many non-english users that use non-english characters. -https://gist.github.com/mathiasbynens/6334847 -*/ - -function isPascalCase(name: string): boolean { - return ( - name.length === 0 || - (name[0] === name[0].toUpperCase() && !name.includes('_')) - ); -} -function isStrictPascalCase(name: string): boolean { - return ( - name.length === 0 || - (name[0] === name[0].toUpperCase() && hasStrictCamelHumps(name, true)) - ); -} - -function isCamelCase(name: string): boolean { - return ( - name.length === 0 || - (name[0] === name[0].toLowerCase() && !name.includes('_')) - ); -} -function isStrictCamelCase(name: string): boolean { - return ( - name.length === 0 || - (name[0] === name[0].toLowerCase() && hasStrictCamelHumps(name, false)) - ); -} - -function hasStrictCamelHumps(name: string, isUpper: boolean): boolean { - function isUppercaseChar(char: string): boolean { - return char === char.toUpperCase() && char !== char.toLowerCase(); - } - - if (name.startsWith('_')) { - return false; - } - for (let i = 1; i < name.length; ++i) { - if (name[i] === '_') { - return false; - } - if (isUpper === isUppercaseChar(name[i])) { - if (isUpper) { - return false; - } - } else { - isUpper = !isUpper; - } - } - return true; -} - -function isSnakeCase(name: string): boolean { - return ( - name.length === 0 || - (name === name.toLowerCase() && validateUnderscores(name)) - ); -} - -function isUpperCase(name: string): boolean { - return ( - name.length === 0 || - (name === name.toUpperCase() && validateUnderscores(name)) - ); -} - -/** Check for leading trailing and adjacent underscores */ -function validateUnderscores(name: string): boolean { - if (name.startsWith('_')) { - return false; - } - let wasUnderscore = false; - for (let i = 1; i < name.length; ++i) { - if (name[i] === '_') { - if (wasUnderscore) { - return false; - } - wasUnderscore = true; - } else { - wasUnderscore = false; - } - } - return !wasUnderscore; -} - -const PredefinedFormatToCheckFunction: Readonly boolean ->> = { - [PredefinedFormats.PascalCase]: isPascalCase, - [PredefinedFormats.StrictPascalCase]: isStrictPascalCase, - [PredefinedFormats.camelCase]: isCamelCase, - [PredefinedFormats.strictCamelCase]: isStrictCamelCase, - [PredefinedFormats.UPPER_CASE]: isUpperCase, - [PredefinedFormats.snake_case]: isSnakeCase, -}; - -// #endregion Predefined Format Functions - -function selectorTypeToMessageString(selectorType: SelectorsString): string { - const notCamelCase = selectorType.replace(/([A-Z])/g, ' $1'); - return notCamelCase.charAt(0).toUpperCase() + notCamelCase.slice(1); -} - -function isMetaSelector( - selector: IndividualAndMetaSelectorsString | Selectors | MetaSelectors, -): selector is MetaSelectorsString { - return selector in MetaSelectors; -} - -function normalizeOption(option: Selector): NormalizedSelector[] { - let weight = 0; - option.modifiers?.forEach(mod => { - weight |= Modifiers[mod]; - }); - option.types?.forEach(mod => { - weight |= TypeModifiers[mod]; - }); - - // give selectors with a filter the _highest_ priority - if (option.filter) { - weight |= 1 << 30; - } - - const normalizedOption = { - // format options - format: option.format ? option.format.map(f => PredefinedFormats[f]) : null, - custom: option.custom - ? { - regex: new RegExp(option.custom.regex, 'u'), - match: option.custom.match, - } - : null, - leadingUnderscore: - option.leadingUnderscore !== undefined - ? UnderscoreOptions[option.leadingUnderscore] - : null, - trailingUnderscore: - option.trailingUnderscore !== undefined - ? UnderscoreOptions[option.trailingUnderscore] - : null, - prefix: option.prefix && option.prefix.length > 0 ? option.prefix : null, - suffix: option.suffix && option.suffix.length > 0 ? option.suffix : null, - modifiers: option.modifiers?.map(m => Modifiers[m]) ?? null, - types: option.types?.map(m => TypeModifiers[m]) ?? null, - filter: - option.filter !== undefined - ? typeof option.filter === 'string' - ? { regex: new RegExp(option.filter, 'u'), match: true } - : { - regex: new RegExp(option.filter.regex, 'u'), - match: option.filter.match, - } - : null, - // calculated ordering weight based on modifiers - modifierWeight: weight, - }; - - const selectors = Array.isArray(option.selector) - ? option.selector - : [option.selector]; - - const selectorsAllowedToHaveTypes = - Selectors.variable | - Selectors.parameter | - Selectors.classProperty | - Selectors.objectLiteralProperty | - Selectors.typeProperty | - Selectors.parameterProperty | - Selectors.accessor; - - const config: NormalizedSelector[] = []; - selectors - .map(selector => - isMetaSelector(selector) ? MetaSelectors[selector] : Selectors[selector], - ) - .forEach(selector => - (selectorsAllowedToHaveTypes & selector) !== 0 - ? config.push({ selector: selector, ...normalizedOption }) - : config.push({ - selector: selector, - ...normalizedOption, - types: null, - }), - ); - - return config; -} - -function isCorrectType( - node: TSESTree.Node, - config: NormalizedSelector, - context: Context, -): boolean { - if (config.types === null) { - return true; - } - - const { esTreeNodeToTSNodeMap, program } = util.getParserServices(context); - const checker = program.getTypeChecker(); - const tsNode = esTreeNodeToTSNodeMap.get(node); - const type = checker - .getTypeAtLocation(tsNode) - // remove null and undefined from the type, as we don't care about it here - .getNonNullableType(); - - for (const allowedType of config.types) { - switch (allowedType) { - case TypeModifiers.array: - if ( - isAllTypesMatch( - type, - t => checker.isArrayType(t) || checker.isTupleType(t), - ) - ) { - return true; - } - break; - - case TypeModifiers.function: - if (isAllTypesMatch(type, t => t.getCallSignatures().length > 0)) { - return true; - } - break; - - case TypeModifiers.boolean: - case TypeModifiers.number: - case TypeModifiers.string: { - const typeString = checker.typeToString( - // this will resolve things like true => boolean, 'a' => string and 1 => number - checker.getWidenedType(checker.getBaseTypeOfLiteralType(type)), - ); - const allowedTypeString = TypeModifiers[allowedType]; - if (typeString === allowedTypeString) { - return true; - } - break; - } - } - } - - return false; -} - -/** - * @returns `true` if the type (or all union types) in the given type return true for the callback - */ -function isAllTypesMatch( - type: ts.Type, - cb: (type: ts.Type) => boolean, -): boolean { - if (type.isUnion()) { - return type.types.every(t => cb(t)); - } - - return cb(type); -} - -export { - MessageIds, - Options, - PredefinedFormatsString, - Selector, - selectorTypeToMessageString, -}; +export { MessageIds, Options }; diff --git a/packages/eslint-plugin/tests/rules/naming-convention.test.ts b/packages/eslint-plugin/tests/rules/naming-convention.test.ts index b53d7b9304c..f78b061a969 100644 --- a/packages/eslint-plugin/tests/rules/naming-convention.test.ts +++ b/packages/eslint-plugin/tests/rules/naming-convention.test.ts @@ -1,11 +1,10 @@ import { TSESLint } from '@typescript-eslint/experimental-utils'; -import rule, { - MessageIds, - Options, +import rule, { MessageIds, Options } from '../../src/rules/naming-convention'; +import { PredefinedFormatsString, - Selector, selectorTypeToMessageString, -} from '../../src/rules/naming-convention'; + Selector, +} from '../../src/rules/naming-convention-utils'; import { RuleTester, getFixturesRootDir } from '../RuleTester'; const ruleTester = new RuleTester({