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({