diff --git a/.cspell.json b/.cspell.json index 39e8c795e7a..0c045aaa4bc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -9,7 +9,8 @@ "**/**/CHANGELOG.md", "**/**/CONTRIBUTORS.md", "**/**/ROADMAP.md", - "**/*.{json,snap}" + "**/*.{json,snap}", + ".cspell.json" ], "dictionaries": [ "typescript", @@ -54,6 +55,8 @@ "destructure", "destructured", "erroring", + "ESLint", + "ESLint's", "espree", "estree", "linebreaks", diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 21100c9cb56..2e4c9c5ae46 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -101,20 +101,16 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int | [`@typescript-eslint/ban-ts-ignore`](./docs/rules/ban-ts-ignore.md) | Bans “// @ts-ignore” comments from being used | :heavy_check_mark: | | | | [`@typescript-eslint/ban-types`](./docs/rules/ban-types.md) | Bans specific types from being used | :heavy_check_mark: | :wrench: | | | [`@typescript-eslint/brace-style`](./docs/rules/brace-style.md) | Enforce consistent brace style for blocks | | :wrench: | | -| [`@typescript-eslint/camelcase`](./docs/rules/camelcase.md) | Enforce camelCase naming convention | :heavy_check_mark: | | | -| [`@typescript-eslint/class-name-casing`](./docs/rules/class-name-casing.md) | Require PascalCased class and interface names | :heavy_check_mark: | | | | [`@typescript-eslint/consistent-type-assertions`](./docs/rules/consistent-type-assertions.md) | Enforces consistent usage of type assertions | :heavy_check_mark: | | | | [`@typescript-eslint/consistent-type-definitions`](./docs/rules/consistent-type-definitions.md) | Consistent with type definition either `interface` or `type` | | :wrench: | | | [`@typescript-eslint/default-param-last`](./docs/rules/default-param-last.md) | Enforce default parameters to be last | | | | | [`@typescript-eslint/explicit-function-return-type`](./docs/rules/explicit-function-return-type.md) | Require explicit return types on functions and class methods | :heavy_check_mark: | | | | [`@typescript-eslint/explicit-member-accessibility`](./docs/rules/explicit-member-accessibility.md) | Require explicit accessibility modifiers on class properties and methods | | | | | [`@typescript-eslint/func-call-spacing`](./docs/rules/func-call-spacing.md) | Require or disallow spacing between function identifiers and their invocations | | :wrench: | | -| [`@typescript-eslint/generic-type-naming`](./docs/rules/generic-type-naming.md) | Enforces naming of generic type variables | | | | | [`@typescript-eslint/indent`](./docs/rules/indent.md) | Enforce consistent indentation | | :wrench: | | -| [`@typescript-eslint/interface-name-prefix`](./docs/rules/interface-name-prefix.md) | Require that interface names should or should not prefixed with `I` | :heavy_check_mark: | | | | [`@typescript-eslint/member-delimiter-style`](./docs/rules/member-delimiter-style.md) | Require a specific member delimiter style for interfaces and type literals | :heavy_check_mark: | :wrench: | | -| [`@typescript-eslint/member-naming`](./docs/rules/member-naming.md) | Enforces naming conventions for class members by visibility | | | | | [`@typescript-eslint/member-ordering`](./docs/rules/member-ordering.md) | Require a consistent member declaration order | | | | +| [`@typescript-eslint/naming-convention`](./docs/rules/naming-convention.md) | Enforces naming conventions for everything across a codebase | | | :thought_balloon: | | [`@typescript-eslint/no-array-constructor`](./docs/rules/no-array-constructor.md) | Disallow generic `Array` constructors | :heavy_check_mark: | :wrench: | | | [`@typescript-eslint/no-dynamic-delete`](./docs/rules/no-dynamic-delete.md) | Disallow the delete operator with computed key expressions | | :wrench: | | | [`@typescript-eslint/no-empty-function`](./docs/rules/no-empty-function.md) | Disallow empty functions | :heavy_check_mark: | | | diff --git a/packages/eslint-plugin/docs/rules/naming-convention.md b/packages/eslint-plugin/docs/rules/naming-convention.md new file mode 100644 index 00000000000..5186c98eb6d --- /dev/null +++ b/packages/eslint-plugin/docs/rules/naming-convention.md @@ -0,0 +1,338 @@ +# Enforces naming conventions for everything across a codebase (`naming-convention`) + +Enforcing naming conventions helps keep the codebase consistent, and reduces overhead when thinking about how to name a variable. +Additionally, a well designed style guide can help communicate intent, such as by enforcing all private properties begin with an `_`, and all global-level constants are written in `UPPER_CASE`. + +There are many different rules that have existed over time, but they have had the problem of not having enough granularity, meaning it was hard to have a well defined style guide, and most of the time you needed 3 or more rules at once to enforce different conventions, hoping they didn't conflict. + +## Rule Details + +This rule allows you to enforce conventions for any identifier, using granular selectors to create a fine-grained style guide. + +### Note - this rule only needs type information in specific cases, detailed below + +## Options + +This rule accepts an array of objects, with each object describing a different naming convention. +Each property will be described in detail below. Also see the examples section below for illustrated examples. + +```ts +type Options = { + // format options + format: ( + | 'camelCase' + | 'strictCamelCase' + | 'PascalCase' + | 'StrictPascalCase' + | 'snake_case' + | 'UPPER_CASE' + )[]; + custom?: { + regex: string; + match: boolean; + }; + leadingUnderscore?: 'forbid' | 'allow' | 'require'; + trailingUnderscore?: 'forbid' | 'allow' | 'require'; + prefix?: string[]; + suffix?: string[]; + + // selector options + selector: Selector; + filter?: string; + // the allowed values for these are dependent on the selector - see below + modifiers?: Modifiers[]; + types?: Types[]; +}[]; + +// the default config essentially does the same thing as ESLint's camelcase rule +const defaultOptions: Options = [ + { + selector: 'default', + format: ['camelCase'], + leadingUnderscore: 'allow', + trailingUnderscore: 'allow', + }, + + { + selector: 'variable', + format: ['camelCase', 'UPPER_CASE'], + leadingUnderscore: 'allow', + trailingUnderscore: 'allow', + }, + + { + selector: 'typeLike', + format: ['PascalCase'], + }, +]; +``` + +### 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 + +At each step, 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 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. + +#### `format` + +The `format` option defines the allowed formats for the identifier. This option accepts an array of the following values, and the identifier can match any of them: + +- `camelCase` - standard camelCase format - no underscores are allowed between characters, and consecutive capitals are allowed (i.e. both `myID` and `myId` are valid). +- `strictCamelCase` - same as `camelCase`, but consecutive capitals are not allowed (i.e. `myId` is valid, but `myID` is not). +- `PascalCase` - same as `camelCase`, except the first character must be upper-case. +- `StrictPascalCase` - same as `strictCamelCase`, except the first character must be upper-case. +- `snake_case` - standard snake_case format - all characters must be lower-case, and underscores are allowed. +- `UPPER_CASE` - same as `snake_case`, except all characters must be upper-case. + +### `custom` + +The `custom` option defines a custom regex that the identifier must (or must not) match. This option allows you to have a bit more finer-grained control over identifiers, letting you ban (or force) certain patterns and substrings. +Accepts an object with the following properties: + +- `regex` - accepts a regular expression (anything accepted into `new RegExp(filter)`). +- `match` - true if the identifier _must_ match the `regex`, false if the identifier _must not_ match the `regex`. + +#### `leadingUnderscore` / `trailingUnderscore` + +The `leadingUnderscore` / `trailingUnderscore` options control whether leading/trailing underscores are considered valid. Accepts one of the following values: + +- `forbid` - a leading/trailing underscore is not allowed at all. +- `allow` - existence of a leading/trailing underscore is not explicitly enforced. +- `require` - a leading/trailing underscore must be included. + +#### `prefix` / `suffix` + +The `prefix` / `suffix` options control which prefix/suffix strings must exist for the identifier. Accepts an array of strings. + +If these are provided, the identifier must start with one of the provided values. For example, if you provide `{ prefix: ['IFace', 'Class', 'Type'] }`, then the following names are valid: `IFaceFoo`, `ClassBar`, `TypeBaz`, but the name `Bang` is not valid, as it contains none of the prefixes. + +### Selector Options + +- `selector` (see "Allowed Selectors, Modifiers and Types" below). +- `filter` accepts a regular expression (anything accepted into `new RegExp(filter)`). It allows you to limit the scope of this configuration to names that match this regex. +- `modifiers` allows you to specify which modifiers to granularly apply to, such as the accessibility (`private`/`public`/`protected`), or if the thing is `static`, etc. + - The name must match _all_ of the modifiers. + - For example, if you provide `{ modifiers: ['private', 'static', 'readonly'] }`, then it will only match something that is `private static readonly`, and something that is just `private` will not match. +- `types` allows you to specify which types to match. This option supports simple, primitive types only (`boolean`, `string`, `number`, `array`, `function`). + - The name must match _one_ of the types. + - **_NOTE - Using this option will require that you lint with type information._** + - For example, this lets you do things like enforce that `boolean` variables are prefixed with a verb. + - `boolean` matches any type assignable to `boolean | null | undefined` + - `string` matches any type assignable to `string | null | undefined` + - `number` matches any type assignable to `number | null | undefined` + - `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', type: ['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`. + +#### Allowed Selectors, Modifiers and Types + +There are two types of selectors, individual selectors, and grouped selectors. + +##### Individual Selectors + +Individual Selectors match specific, well-defined sets. There is no overlap between each of the individual selectors. + +- `variable` - matches any `var` / `let` / `const` variable name. + - Allowed `modifiers`: none. + - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. +- `function` - matches any named function declaration or named function expression. + - Allowed `modifiers`: none. + - Allowed `types`: none. +- `parameter` - matches any function parameter. Does not match parameter properties. + - Allowed `modifiers`: none. + - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. +- `property` - matches any object, class, or object type property. Does not match properties that have direct function expression or arrow function expression values. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. +- `parameterProperty` - matches any parameter property. + - Allowed `modifiers`: `private`, `protected`, `public`, `readonly`. + - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. +- `method` - matches any object, class, or object type method. Also matches properties that have direct function expression or arrow function expression values. Does not match accessors. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `types`: none. +- `accessor` - matches any accessor. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `types`: `boolean`, `string`, `number`, `function`, `array`. +- `enumMember` - matches any enum member. + - Allowed `modifiers`: none. + - Allowed `types`: none. +- `class` - matches any class declaration. + - Allowed `modifiers`: `abstract`. + - Allowed `types`: none. +- `interface` - matches any interface declaration. + - Allowed `modifiers`: none. + - Allowed `types`: none. +- `typeAlias` - matches any type alias declaration. + - Allowed `modifiers`: none. + - Allowed `types`: none. +- `enum` - matches any enum declaration. + - Allowed `modifiers`: none. + - Allowed `types`: none. +- `typeParameter` - matches any generic type parameter declaration. + - Allowed `modifiers`: none. + - Allowed `types`: none. + +##### Group Selectors + +Group Selectors are provided for convenience, and essentially bundle up sets of individual selectors. + +- `default` - matches everything. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `types`: none. +- `variableLike` - matches the same as `variable`, `function` and `parameter`. + - Allowed `modifiers`: none. + - Allowed `types`: none. +- `memberLike` - matches the same as `property`, `parameterProperty`, `method`, `accessor`, `enumMember`. + - Allowed `modifiers`: `private`, `protected`, `public`, `static`, `readonly`, `abstract`. + - Allowed `types`: none. +- `typeLike` - matches the same as `class`, `interface`, `typeAlias`, `enum`, `typeParameter`. + - Allowed `modifiers`: `abstract`. + - Allowed `types`: none. + +## Examples + +### Enforce that all variables, functions and properties follow are camelCase + +```json +{ + "@typescript-eslint/naming-conventions": [ + "error", + { "selector": "variableLike", "format": ["camelCase"] } + ] +} +``` + +### Enforce that private members are prefixed with an underscore + +```json +{ + "@typescript-eslint/naming-conventions": [ + "error", + { + "selector": "memberLike", + "modifier": ["private"], + "format": ["camelCase"], + "leadingUnderscore": "require" + } + ] +} +``` + +### Enforce that boolean variables are prefixed with an allowed verb + +```json +{ + "@typescript-eslint/naming-conventions": [ + "error", + { + "selector": "variable", + "types": ["boolean"], + "format": ["PascalCase"], + "prefix": ["is", "should", "has", "can", "did", "will"] + } + ] +} +``` + +### Enforce that all variables are either in camelCase or UPPER_CASE + +```json +{ + "@typescript-eslint/naming-conventions": [ + "error", + { + "selector": "variable", + "format": ["camelCase", "UPPER_CASE"] + } + ] +} +``` + +### Enforce that type parameters (generics) are prefixed with `T` + +```json +{ + "@typescript-eslint/naming-conventions": [ + "error", + { + "selector": "typeParameter", + "format": ["PascalCase"], + "prefix": ["T"] + } + ] +} +``` + +### Enforce the codebase follows ESLint's `camelcase` conventions + +```json +{ + "@typescript-eslint/naming-conventions": [ + "error", + { + "selector": "default", + "format": ["camelCase"] + }, + + { + "selector": "variable", + "format": ["camelCase", "UPPER_CASE"] + }, + { + "selector": "parameter", + "format": ["camelCase"], + "leadingUnderscore": "allow" + }, + + { + "selector": "memberLike", + "modifiers": ["private"], + "format": ["camelCase"], + "leadingUnderscore": "require" + }, + + { + "selector": "typeLike", + "format": ["PascalCase"] + } + ] +} +``` + +## When Not To Use It + +If you do not want to enforce naming conventions for anything. diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json index d046a2c9c0b..b8eff6e78b9 100644 --- a/packages/eslint-plugin/src/configs/all.json +++ b/packages/eslint-plugin/src/configs/all.json @@ -8,9 +8,6 @@ "@typescript-eslint/ban-types": "error", "brace-style": "off", "@typescript-eslint/brace-style": "error", - "camelcase": "off", - "@typescript-eslint/camelcase": "error", - "@typescript-eslint/class-name-casing": "error", "@typescript-eslint/consistent-type-assertions": "error", "@typescript-eslint/consistent-type-definitions": "error", "@typescript-eslint/default-param-last": "error", @@ -18,13 +15,11 @@ "@typescript-eslint/explicit-member-accessibility": "error", "func-call-spacing": "off", "@typescript-eslint/func-call-spacing": "error", - "@typescript-eslint/generic-type-naming": "error", "indent": "off", "@typescript-eslint/indent": "error", - "@typescript-eslint/interface-name-prefix": "error", "@typescript-eslint/member-delimiter-style": "error", - "@typescript-eslint/member-naming": "error", "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/naming-convention": "error", "no-array-constructor": "off", "@typescript-eslint/no-array-constructor": "error", "@typescript-eslint/no-dynamic-delete": "error", diff --git a/packages/eslint-plugin/src/rules/camelcase.ts b/packages/eslint-plugin/src/rules/camelcase.ts index 17b88d7e93d..37047b9b4ff 100644 --- a/packages/eslint-plugin/src/rules/camelcase.ts +++ b/packages/eslint-plugin/src/rules/camelcase.ts @@ -30,6 +30,8 @@ export default util.createRule({ category: 'Stylistic Issues', recommended: 'error', }, + deprecated: true, + replacedBy: ['naming-convention'], schema: [schema], messages: baseRule.meta.messages, }, diff --git a/packages/eslint-plugin/src/rules/class-name-casing.ts b/packages/eslint-plugin/src/rules/class-name-casing.ts index cbcc805630d..6326f1910e1 100644 --- a/packages/eslint-plugin/src/rules/class-name-casing.ts +++ b/packages/eslint-plugin/src/rules/class-name-casing.ts @@ -20,6 +20,8 @@ export default util.createRule({ category: 'Best Practices', recommended: 'error', }, + deprecated: true, + replacedBy: ['naming-convention'], messages: { notPascalCased: "{{friendlyName}} '{{name}}' must be PascalCased.", }, diff --git a/packages/eslint-plugin/src/rules/generic-type-naming.ts b/packages/eslint-plugin/src/rules/generic-type-naming.ts index 95516174448..14a697f2e60 100644 --- a/packages/eslint-plugin/src/rules/generic-type-naming.ts +++ b/packages/eslint-plugin/src/rules/generic-type-naming.ts @@ -13,6 +13,8 @@ export default util.createRule({ // too opinionated to be recommended recommended: false, }, + deprecated: true, + replacedBy: ['naming-convention'], messages: { paramNotMatchRule: 'Type parameter {{name}} does not match rule {{rule}}.', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index bb5589a6220..de4aa662ea3 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -18,6 +18,7 @@ import interfaceNamePrefix from './interface-name-prefix'; import memberDelimiterStyle from './member-delimiter-style'; import memberNaming from './member-naming'; import memberOrdering from './member-ordering'; +import namingConvention from './naming-convention'; import noArrayConstructor from './no-array-constructor'; import noDynamicDelete from './no-dynamic-delete'; import noEmptyFunction from './no-empty-function'; @@ -98,6 +99,7 @@ export default { 'member-delimiter-style': memberDelimiterStyle, 'member-naming': memberNaming, 'member-ordering': memberOrdering, + 'naming-convention': namingConvention, 'no-array-constructor': noArrayConstructor, 'no-dynamic-delete': noDynamicDelete, 'no-empty-function': noEmptyFunction, diff --git a/packages/eslint-plugin/src/rules/interface-name-prefix.ts b/packages/eslint-plugin/src/rules/interface-name-prefix.ts index 6f2c57dc315..039c0d7d67c 100644 --- a/packages/eslint-plugin/src/rules/interface-name-prefix.ts +++ b/packages/eslint-plugin/src/rules/interface-name-prefix.ts @@ -49,6 +49,8 @@ export default util.createRule({ // https://github.com/typescript-eslint/typescript-eslint/issues/374 recommended: 'error', }, + deprecated: true, + replacedBy: ['naming-convention'], messages: { noPrefix: 'Interface name must not be prefixed with "I".', alwaysPrefix: 'Interface name must be prefixed with "I".', diff --git a/packages/eslint-plugin/src/rules/member-naming.ts b/packages/eslint-plugin/src/rules/member-naming.ts index f2e4567dcb4..4facce887e5 100644 --- a/packages/eslint-plugin/src/rules/member-naming.ts +++ b/packages/eslint-plugin/src/rules/member-naming.ts @@ -23,6 +23,8 @@ export default util.createRule({ category: 'Stylistic Issues', recommended: false, }, + deprecated: true, + replacedBy: ['naming-convention'], messages: { incorrectName: '{{accessibility}} property {{name}} should match {{convention}}.', diff --git a/packages/eslint-plugin/src/rules/naming-convention.ts b/packages/eslint-plugin/src/rules/naming-convention.ts new file mode 100644 index 00000000000..50acf190d04 --- /dev/null +++ b/packages/eslint-plugin/src/rules/naming-convention.ts @@ -0,0 +1,1294 @@ +import { + AST_NODE_TYPES, + JSONSchema, + TSESTree, + TSESLint, +} from '@typescript-eslint/experimental-utils'; +import * as ts from 'typescript'; +import * as util from '../util'; + +type MessageIds = + | 'unexpectedUnderscore' + | 'missingUnderscore' + | 'missingAffix' + | 'satisfyCustom' + | 'doesNotMatchFormat'; + +// #region Options Type Config + +enum PredefinedFormats { + camelCase = 1 << 0, + strictCamelCase = 1 << 1, + PascalCase = 1 << 2, + StrictPascalCase = 1 << 3, + // eslint-disable-next-line @typescript-eslint/camelcase + snake_case = 1 << 4, + UPPER_CASE = 1 << 5, +} +type PredefinedFormatsString = keyof typeof PredefinedFormats; + +enum UnderscoreOptions { + forbid = 1 << 0, + allow = 1 << 1, + require = 1 << 2, +} +type UnderscoreOptionsString = keyof typeof UnderscoreOptions; + +enum Selectors { + // variableLike + variable = 1 << 0, + function = 1 << 1, + parameter = 1 << 2, + + // memberLike + property = 1 << 3, + parameterProperty = 1 << 4, + method = 1 << 5, + accessor = 1 << 6, + enumMember = 1 << 7, + + // typeLike + class = 1 << 8, + interface = 1 << 9, + typeAlias = 1 << 10, + enum = 1 << 11, + typeParameter = 1 << 12, +} +type SelectorsString = keyof typeof Selectors; +const SELECTOR_COUNT = util.getEnumNames(Selectors).length; + +enum MetaSelectors { + default = -1, + variableLike = 0 | + Selectors.variable | + Selectors.function | + Selectors.parameter, + memberLike = 0 | + Selectors.property | + Selectors.parameterProperty | + Selectors.enumMember | + Selectors.method | + Selectors.accessor, + typeLike = 0 | + Selectors.class | + Selectors.interface | + Selectors.typeAlias | + Selectors.enum | + Selectors.typeParameter, +} +type MetaSelectorsString = keyof typeof MetaSelectors; +type IndividualAndMetaSelectorsString = SelectorsString | MetaSelectorsString; + +enum Modifiers { + readonly = 1 << 0, + static = 1 << 1, + public = 1 << 2, + protected = 1 << 3, + private = 1 << 4, + abstract = 1 << 5, +} +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[]; + custom?: { + regex: string; + match: boolean; + }; + leadingUnderscore?: UnderscoreOptionsString; + trailingUnderscore?: UnderscoreOptionsString; + prefix?: string[]; + suffix?: string[]; + // selector options + selector: IndividualAndMetaSelectorsString; + modifiers?: ModifiersString[]; + types?: TypeModifiersString[]; + filter?: string; +} +interface NormalizedSelector { + // format options + format: PredefinedFormats[]; + 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: RegExp | 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, + }, + minItems: 1, + additionalItems: false, +}; +type JSONSchemaProperties = Record; +const FORMAT_OPTIONS_PROPERTIES: JSONSchemaProperties = { + format: { + type: 'array', + items: { + type: 'string', + enum: util.getEnumNames(PredefinedFormats), + }, + minItems: 1, + additionalItems: false, + }, + custom: { + type: 'object', + properties: { + regex: { + type: 'string', + }, + match: { + type: 'boolean', + }, + }, + required: ['regex', 'match'], + }, + 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: { + type: 'string', + minLength: 1, + }, + 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, + }, + ]; +} +const SCHEMA: JSONSchema.JSONSchema4 = { + type: 'array', + minItems: 1, + items: { + oneOf: [ + ...selectorSchema('default', false, util.getEnumNames(Modifiers)), + + ...selectorSchema('variableLike', false), + ...selectorSchema('variable', true), + ...selectorSchema('function', false), + ...selectorSchema('parameter', true), + + ...selectorSchema('memberLike', false, [ + 'private', + 'protected', + 'public', + 'static', + 'readonly', + 'abstract', + ]), + ...selectorSchema('property', true, [ + 'private', + 'protected', + 'public', + 'static', + 'readonly', + 'abstract', + ]), + ...selectorSchema('parameterProperty', true, [ + 'private', + 'protected', + 'public', + 'readonly', + ]), + ...selectorSchema('method', false, [ + 'private', + 'protected', + 'public', + 'static', + 'abstract', + ]), + ...selectorSchema('accessor', true, [ + 'private', + 'protected', + 'public', + 'static', + 'abstract', + ]), + ...selectorSchema('enumMember', false), + + ...selectorSchema('typeLike', false, ['abstract']), + ...selectorSchema('class', false, ['abstract']), + ...selectorSchema('interface', false), + ...selectorSchema('typeAlias', false), + ...selectorSchema('enum', false), + ...selectorSchema('typeParameter', false), + ], + }, + 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 = [ + { + selector: 'default', + format: ['camelCase'], + leadingUnderscore: 'allow', + trailingUnderscore: 'allow', + }, + + { + selector: 'variable', + format: ['camelCase', 'UPPER_CASE'], + leadingUnderscore: 'allow', + trailingUnderscore: 'allow', + }, + + { + selector: 'typeLike', + format: ['PascalCase'], + }, +]; + +export default util.createRule({ + name: 'naming-convention', + meta: { + docs: { + category: 'Variables', + description: + 'Enforces naming conventions for everything across a codebase', + recommended: false, + // technically only requires type checking if the user uses "type" modifiers + requiresTypeChecking: true, + }, + type: 'suggestion', + messages: { + unexpectedUnderscore: + '{{type}} name {{name}} must not have a {{position}} underscore.', + missingUnderscore: + '{{type}} name {{name}} must have a {{position}} underscore', + missingAffix: + '{{type}} name {{name}} must have one of the following {{position}}es: {{affixes}}', + satisfyCustom: + '{{type}} name {{name}} must {{regexMatch}} the RegExp: {{regex}}', + doesNotMatchFormat: + '{{type}} name {{name}} must match one of the following formats: {{formats}}', + }, + schema: SCHEMA, + }, + defaultOptions: defaultCamelCaseAllTheThingsConfig, + create(contextWithoutDefaults) { + const context: Context = contextWithoutDefaults.options + ? contextWithoutDefaults + : // only apply the defaults when the user provides no config + Object.setPrototypeOf( + { + options: defaultCamelCaseAllTheThingsConfig, + }, + contextWithoutDefaults, + ); + + const validators = parseOptions(context); + + function handleMember( + validator: ValidatorFunction | null, + node: + | TSESTree.PropertyNonComputedName + | TSESTree.ClassPropertyNonComputedName + | TSESTree.TSAbstractClassPropertyNonComputedName + | TSESTree.TSPropertySignatureNonComputedName + | TSESTree.MethodDefinitionNonComputedName + | TSESTree.TSAbstractMethodDefinitionNonComputedName + | TSESTree.TSMethodSignatureNonComputedName, + modifiers: Set, + ): void { + if (!validator) { + return; + } + + const key = node.key; + validator(key, modifiers); + } + + function getMemberModifiers( + node: + | TSESTree.ClassProperty + | TSESTree.TSAbstractClassProperty + | TSESTree.MethodDefinition + | TSESTree.TSAbstractMethodDefinition + | TSESTree.TSParameterProperty, + ): Set { + const modifiers = new Set(); + if (node.accessibility) { + modifiers.add(Modifiers[node.accessibility]); + } else { + modifiers.add(Modifiers.public); + } + if (node.static) { + modifiers.add(Modifiers.static); + } + if ('readonly' in node && node.readonly) { + modifiers.add(Modifiers.readonly); + } + if ( + node.type === AST_NODE_TYPES.TSAbstractClassProperty || + node.type === AST_NODE_TYPES.TSAbstractMethodDefinition + ) { + modifiers.add(Modifiers.abstract); + } + + return modifiers; + } + + return { + // #region variable + + VariableDeclarator(node: TSESTree.VariableDeclarator): void { + const validator = validators.variable; + if (!validator) { + return; + } + + const identifiers: TSESTree.Identifier[] = []; + getIdentifiersFromPattern(node.id, identifiers); + + identifiers.forEach(i => { + validator(i); + }); + }, + + // #endregion + + // #region function + + 'FunctionDeclaration, TSDeclareFunction, FunctionExpression'( + node: + | TSESTree.FunctionDeclaration + | TSESTree.TSDeclareFunction + | TSESTree.FunctionExpression, + ): void { + const validator = validators.function; + if (!validator || node.id === null) { + return; + } + + validator(node.id); + }, + + // #endregion function + + // #region parameter + + 'FunctionDeclaration, TSDeclareFunction, FunctionExpression, ArrowFunctionExpression'( + node: + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression, + ): void { + const validator = validators.parameter; + if (!validator) { + return; + } + + node.params.forEach(param => { + if (param.type === AST_NODE_TYPES.TSParameterProperty) { + return; + } + + const identifiers: TSESTree.Identifier[] = []; + getIdentifiersFromPattern(param, identifiers); + + identifiers.forEach(i => { + validator(i); + }); + }); + }, + + // #endregion parameter + + // #region parameterProperty + + TSParameterProperty(node): void { + const validator = validators.parameterProperty; + if (!validator) { + return; + } + + const modifiers = getMemberModifiers(node); + + const identifiers: TSESTree.Identifier[] = []; + getIdentifiersFromPattern(node.parameter, identifiers); + + identifiers.forEach(i => { + validator(i, modifiers); + }); + }, + + // #endregion parameterProperty + + // #region property + + 'Property[computed = false][kind = "init"][value.type != "ArrowFunctionExpression"][value.type != "FunctionExpression"][value.type != "TSEmptyBodyFunctionExpression"]'( + node: TSESTree.PropertyNonComputedName, + ): void { + const modifiers = new Set([Modifiers.public]); + handleMember(validators.property, node, modifiers); + }, + + ':matches(ClassProperty, TSAbstractClassProperty)[computed = false][value.type != "ArrowFunctionExpression"][value.type != "FunctionExpression"][value.type != "TSEmptyBodyFunctionExpression"]'( + node: + | TSESTree.ClassPropertyNonComputedName + | TSESTree.TSAbstractClassPropertyNonComputedName, + ): void { + const modifiers = getMemberModifiers(node); + handleMember(validators.property, node, modifiers); + }, + + 'TSPropertySignature[computed = false]'( + node: TSESTree.TSPropertySignatureNonComputedName, + ): void { + const modifiers = new Set([Modifiers.public]); + if (node.readonly) { + modifiers.add(Modifiers.readonly); + } + + handleMember(validators.property, node, modifiers); + }, + + // #endregion property + + // #region method + + [[ + 'Property[computed = false][kind = "init"][value.type = "ArrowFunctionExpression"]', + 'Property[computed = false][kind = "init"][value.type = "FunctionExpression"]', + 'Property[computed = false][kind = "init"][value.type = "TSEmptyBodyFunctionExpression"]', + 'TSMethodSignature[computed = false]', + ].join(', ')]( + node: + | TSESTree.PropertyNonComputedName + | TSESTree.TSMethodSignatureNonComputedName, + ): void { + const modifiers = new Set([Modifiers.public]); + handleMember(validators.method, node, modifiers); + }, + + [[ + ':matches(ClassProperty, TSAbstractClassProperty)[computed = false][value.type = "ArrowFunctionExpression"]', + ':matches(ClassProperty, TSAbstractClassProperty)[computed = false][value.type = "FunctionExpression"]', + ':matches(ClassProperty, TSAbstractClassProperty)[computed = false][value.type = "TSEmptyBodyFunctionExpression"]', + ':matches(MethodDefinition, TSAbstractMethodDefinition)[computed = false][kind = "method"]', + ].join(', ')]( + node: + | TSESTree.ClassPropertyNonComputedName + | TSESTree.TSAbstractClassPropertyNonComputedName + | TSESTree.MethodDefinitionNonComputedName + | TSESTree.TSAbstractMethodDefinitionNonComputedName, + ): void { + const modifiers = getMemberModifiers(node); + handleMember(validators.method, node, modifiers); + }, + + // #endregion method + + // #region accessor + + 'Property[computed = false]:matches([kind = "get"], [kind = "set"])'( + node: TSESTree.PropertyNonComputedName, + ): void { + const modifiers = new Set([Modifiers.public]); + handleMember(validators.accessor, node, modifiers); + }, + + 'MethodDefinition[computed = false]:matches([kind = "get"], [kind = "set"])'( + node: TSESTree.MethodDefinitionNonComputedName, + ): void { + const modifiers = getMemberModifiers(node); + handleMember(validators.accessor, node, modifiers); + }, + + // #endregion accessor + + // #region enumMember + + // computed is optional, so can't do [computed = false] + 'TSEnumMember[computed != true]'( + node: TSESTree.TSEnumMemberNonComputedName, + ): void { + const validator = validators.enumMember; + if (!validator) { + return; + } + + const id = node.id; + validator(id); + }, + + // #endregion enumMember + + // #region class + + 'ClassDeclaration, ClassExpression'( + node: TSESTree.ClassDeclaration | TSESTree.ClassExpression, + ): void { + const validator = validators.class; + if (!validator) { + return; + } + + const id = node.id; + if (id === null) { + return; + } + + const modifiers = new Set(); + if (node.abstract) { + modifiers.add(Modifiers.abstract); + } + + validator(id, modifiers); + }, + + // #endregion class + + // #region interface + + TSInterfaceDeclaration(node): void { + const validator = validators.interface; + if (!validator) { + return; + } + + validator(node.id); + }, + + // #endregion interface + + // #region typeAlias + + TSTypeAliasDeclaration(node): void { + const validator = validators.typeAlias; + if (!validator) { + return; + } + + validator(node.id); + }, + + // #endregion typeAlias + + // #region enum + + TSEnumDeclaration(node): void { + const validator = validators.enum; + if (!validator) { + return; + } + + validator(node.id); + }, + + // #endregion enum + + // #region typeParameter + + 'TSTypeParameterDeclaration > TSTypeParameter'( + node: TSESTree.TSTypeParameter, + ): void { + const validator = validators.typeParameter; + if (!validator) { + return; + } + + validator(node.name); + }, + + // #endregion typeParameter + }; + }, +}); + +function getIdentifiersFromPattern( + pattern: TSESTree.DestructuringPattern, + identifiers: TSESTree.Identifier[], +): void { + switch (pattern.type) { + case AST_NODE_TYPES.Identifier: + identifiers.push(pattern); + break; + + case AST_NODE_TYPES.ArrayPattern: + pattern.elements.forEach(element => { + getIdentifiersFromPattern(element, identifiers); + }); + break; + + case AST_NODE_TYPES.ObjectPattern: + pattern.properties.forEach(property => { + if (property.type === AST_NODE_TYPES.RestElement) { + getIdentifiersFromPattern(property, identifiers); + } else { + // this is a bit weird, but it's because ESTree doesn't have a new node type + // for object destructuring properties - it just reuses Property... + // https://github.com/estree/estree/blob/9ae284b71130d53226e7153b42f01bf819e6e657/es2015.md#L206-L211 + // However, the parser guarantees this is safe (and there is error handling) + getIdentifiersFromPattern( + property.value as TSESTree.DestructuringPattern, + identifiers, + ); + } + }); + break; + + case AST_NODE_TYPES.RestElement: + getIdentifiersFromPattern(pattern.argument, identifiers); + break; + + case AST_NODE_TYPES.AssignmentPattern: + getIdentifiersFromPattern(pattern.left, identifiers); + break; + + case AST_NODE_TYPES.MemberExpression: + // ignore member expressions, as the everything must already be defined + break; + + default: + // https://github.com/typescript-eslint/typescript-eslint/issues/1282 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + throw new Error(`Unexpected pattern type ${pattern!.type}`); + } +} + +type ValidatorFunction = ( + node: TSESTree.Identifier | TSESTree.Literal, + modifiers?: Set, +) => void; +type ParsedOptions = Record; +type Context = TSESLint.RuleContext; +function parseOptions(context: Context): ParsedOptions { + const normalizedOptions = context.options.map(opt => normalizeOption(opt)); + const parsedOptions = util.getEnumNames(Selectors).reduce((acc, k) => { + acc[k] = createValidator(k, context, normalizedOptions); + return acc; + }, {} as ParsedOptions); + + return 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 ascending - the type modifiers are "more important" + return a.modifierWeight - b.modifierWeight; + } + + /* + meta selectors will always be larger numbers than the normal selectors they contain, as they are the sum of all + of the selectors that they contain. + to give normal selectors a higher priority, shift them all SELECTOR_COUNT bits to the left before comparison, so + they are instead always guaranteed to be larger than the meta selectors. + */ + const aSelector = isMetaSelector(a.selector) + ? a.selector + : a.selector << SELECTOR_COUNT; + const bSelector = isMetaSelector(b.selector) + ? b.selector + : b.selector << SELECTOR_COUNT; + + // sort descending - the meta selectors are "least important" + return bSelector - aSelector; + }); + + 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?.test(originalName)) { + // 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, + position, + custom, + }: { + affixes?: string[]; + formats?: PredefinedFormats[]; + originalName: string; + position?: 'leading' | 'trailing' | 'prefix' | 'suffix'; + custom?: NonNullable; + }): Record { + return { + type: selectorTypeToMessageString(type), + name: originalName, + position, + 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 hasUnderscore = + position === 'leading' ? name.startsWith('_') : name.endsWith('_'); + const trimUnderscore = + position === 'leading' + ? (): string => name.slice(1) + : (): string => name.slice(0, -1); + + switch (option) { + case UnderscoreOptions.allow: + // no check - the user doesn't care if it's there or not + break; + + case UnderscoreOptions.forbid: + if (hasUnderscore) { + context.report({ + node, + messageId: 'unexpectedUnderscore', + data: formatReportData({ + originalName, + position, + }), + }); + return null; + } + break; + + case UnderscoreOptions.require: + if (!hasUnderscore) { + context.report({ + node, + messageId: 'missingUnderscore', + data: formatReportData({ + originalName, + position, + }), + }); + return null; + } + } + + return hasUnderscore ? trimUnderscore() : name; + } + + /** + * @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.length === 0) { + return true; + } + + for (const format of formats) { + const checker = PredefinedFormatToCheckFunction[format]; + if (checker(name)) { + return true; + } + } + + context.report({ + node, + messageId: 'doesNotMatchFormat', + data: formatReportData({ + originalName, + 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 || + // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with + (name[0] === name[0].toUpperCase() && !name.includes('_')) + ); +} +function isStrictPascalCase(name: string): boolean { + return ( + name.length === 0 || + // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with + (name[0] === name[0].toUpperCase() && hasStrictCamelHumps(name, true)) + ); +} + +function isCamelCase(name: string): boolean { + return ( + name.length === 0 || + // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with + (name[0] === name[0].toLowerCase() && !name.includes('_')) + ); +} +function isStrictCamelCase(name: string): boolean { + return ( + name.length === 0 || + // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with + (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; + } + + return { + // format options + format: option.format.map(f => PredefinedFormats[f]), + custom: option.custom + ? { + regex: new RegExp(option.custom.regex), + match: option.custom.match, + } + : null, + leadingUnderscore: + option.leadingUnderscore !== undefined + ? UnderscoreOptions[option.leadingUnderscore] + : null, + trailingUnderscore: + option.trailingUnderscore !== undefined + ? UnderscoreOptions[option.trailingUnderscore] + : null, + prefix: option.prefix ?? null, + suffix: option.suffix ?? null, + // selector options + selector: isMetaSelector(option.selector) + ? MetaSelectors[option.selector] + : Selectors[option.selector], + modifiers: option.modifiers?.map(m => Modifiers[m]) ?? null, + types: option.types?.map(m => TypeModifiers[m]) ?? null, + filter: option.filter !== undefined ? new RegExp(option.filter) : null, + // calculated ordering weight based on modifiers + modifierWeight: weight, + }; +} + +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, +}; diff --git a/packages/eslint-plugin/src/util/misc.ts b/packages/eslint-plugin/src/util/misc.ts index 10a12a95056..c024b6a845c 100644 --- a/packages/eslint-plugin/src/util/misc.ts +++ b/packages/eslint-plugin/src/util/misc.ts @@ -126,11 +126,16 @@ type RequireKeys< TKeys extends keyof TObj > = ExcludeKeys & { [k in TKeys]-?: Exclude }; +function getEnumNames(myEnum: Record): T[] { + return Object.keys(myEnum).filter(x => isNaN(parseInt(x))) as T[]; +} + export { arraysAreEqual, Equal, ExcludeKeys, findFirstResult, + getEnumNames, getNameFromMember, InferMessageIdsTypeFromRule, InferOptionsTypeFromRule, diff --git a/packages/eslint-plugin/tests/configs.test.ts b/packages/eslint-plugin/tests/configs.test.ts index 3ec22f349e8..eea9079f8ba 100644 --- a/packages/eslint-plugin/tests/configs.test.ts +++ b/packages/eslint-plugin/tests/configs.test.ts @@ -8,10 +8,6 @@ function entriesToObject(value: [string, T][]): Record { }, {}); } -const notDeprecatedRules = Object.entries(rules).filter( - ([, rule]) => !rule.meta.deprecated, -); - function filterRules(values: Record): [string, string][] { return Object.entries(values).filter(([name]) => name.startsWith(RULE_NAME_PREFIX), @@ -22,10 +18,10 @@ const RULE_NAME_PREFIX = '@typescript-eslint/'; describe('all.json config', () => { const configRules = filterRules(plugin.configs.all.rules); - const ruleConfigs = notDeprecatedRules.map<[string, string]>(([name]) => [ - `${RULE_NAME_PREFIX}${name}`, - 'error', - ]); + // note: exclude deprecated rules, this config is allowed to change between minor versions + const ruleConfigs = Object.entries(rules) + .filter(([, rule]) => !rule.meta.deprecated) + .map<[string, string]>(([name]) => [`${RULE_NAME_PREFIX}${name}`, 'error']); it('contains all of the rules, excluding the deprecated ones', () => { expect(entriesToObject(ruleConfigs)).toEqual(entriesToObject(configRules)); @@ -34,7 +30,8 @@ describe('all.json config', () => { describe('recommended.json config', () => { const configRules = filterRules(plugin.configs.recommended.rules); - const ruleConfigs = notDeprecatedRules + // note: include deprecated rules so that the config doesn't change between major bumps + const ruleConfigs = Object.entries(rules) .filter( ([, rule]) => rule.meta.docs.recommended !== false && @@ -54,7 +51,8 @@ describe('recommended-requiring-type-checking.json config', () => { const configRules = filterRules( plugin.configs['recommended-requiring-type-checking'].rules, ); - const ruleConfigs = notDeprecatedRules + // note: include deprecated rules so that the config doesn't change between major bumps + const ruleConfigs = Object.entries(rules) .filter( ([, rule]) => rule.meta.docs.recommended !== false && diff --git a/packages/eslint-plugin/tests/rules/naming-convention.test.ts b/packages/eslint-plugin/tests/rules/naming-convention.test.ts new file mode 100644 index 00000000000..de4625fbeb2 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/naming-convention.test.ts @@ -0,0 +1,865 @@ +import { TSESLint } from '@typescript-eslint/experimental-utils'; +import rule, { + MessageIds, + Options, + PredefinedFormatsString, + Selector, + selectorTypeToMessageString, +} from '../../src/rules/naming-convention'; +import { RuleTester, getFixturesRootDir } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +// only need parserOptions for the `type` option tests +const rootDir = getFixturesRootDir(); +const parserOptions = { + tsconfigRootDir: rootDir, + project: './tsconfig.json', +}; + +const formatTestNames: Readonly +>> = { + camelCase: { + valid: ['strictCamelCase', 'lower', 'camelCaseUNSTRICT'], + invalid: ['snake_case', 'UPPER_CASE', 'UPPER', 'StrictPascalCase'], + }, + strictCamelCase: { + valid: ['strictCamelCase', 'lower'], + invalid: [ + 'snake_case', + 'UPPER_CASE', + 'UPPER', + 'StrictPascalCase', + 'camelCaseUNSTRICT', + ], + }, + PascalCase: { + valid: [ + 'StrictPascalCase', + 'Pascal', + 'I18n', + 'PascalCaseUNSTRICT', + 'UPPER', + ], + invalid: ['snake_case', 'UPPER_CASE', 'strictCamelCase'], + }, + StrictPascalCase: { + valid: ['StrictPascalCase', 'Pascal', 'I18n'], + invalid: [ + 'snake_case', + 'UPPER_CASE', + 'UPPER', + 'strictCamelCase', + 'PascalCaseUNSTRICT', + ], + }, + UPPER_CASE: { + valid: ['UPPER_CASE', 'UPPER'], + invalid: [ + 'lower', + 'snake_case', + 'SNAKE_case_UNSTRICT', + 'strictCamelCase', + 'StrictPascalCase', + ], + }, + // eslint-disable-next-line @typescript-eslint/camelcase + snake_case: { + valid: ['snake_case', 'lower'], + invalid: [ + 'UPPER_CASE', + 'SNAKE_case_UNSTRICT', + 'strictCamelCase', + 'StrictPascalCase', + ], + }, +}; + +const REPLACE_REGEX = /%/g; + +type Cases = { + code: string[]; + options: Omit; +}[]; +function createValidTestCases(cases: Cases): TSESLint.ValidTestCase[] { + const newCases: TSESLint.ValidTestCase[] = []; + + for (const test of cases) { + for (const [formatLoose, names] of Object.entries(formatTestNames)) { + const format = [formatLoose as PredefinedFormatsString]; + for (const name of names.valid) { + const createCase = ( + preparedName: string, + options: Selector, + ): TSESLint.ValidTestCase => ({ + options: [ + { + ...options, + filter: '[iI]gnored', + }, + ], + code: `// ${JSON.stringify(options)}\n${test.code + .map(code => code.replace(REPLACE_REGEX, preparedName)) + .join('\n')}`, + }); + + newCases.push( + createCase(name, { + ...test.options, + format, + }), + + // leadingUnderscore + createCase(name, { + ...test.options, + format, + leadingUnderscore: 'forbid', + }), + createCase(`_${name}`, { + ...test.options, + format, + leadingUnderscore: 'require', + }), + createCase(`_${name}`, { + ...test.options, + format, + leadingUnderscore: 'allow', + }), + createCase(name, { + ...test.options, + format, + leadingUnderscore: 'allow', + }), + + // trailingUnderscore + createCase(name, { + ...test.options, + format, + trailingUnderscore: 'forbid', + }), + createCase(`${name}_`, { + ...test.options, + format, + trailingUnderscore: 'require', + }), + createCase(`${name}_`, { + ...test.options, + format, + trailingUnderscore: 'allow', + }), + createCase(name, { + ...test.options, + format, + trailingUnderscore: 'allow', + }), + + // prefix + createCase(`MyPrefix${name}`, { + ...test.options, + format, + prefix: ['MyPrefix'], + }), + createCase(`MyPrefix2${name}`, { + ...test.options, + format, + prefix: ['MyPrefix1', 'MyPrefix2'], + }), + + // suffix + createCase(`${name}MySuffix`, { + ...test.options, + format, + suffix: ['MySuffix'], + }), + createCase(`${name}MySuffix2`, { + ...test.options, + format, + suffix: ['MySuffix1', 'MySuffix2'], + }), + ); + } + } + } + + return newCases; +} +function createInvalidTestCases( + cases: Cases, +): TSESLint.InvalidTestCase[] { + const newCases: TSESLint.InvalidTestCase[] = []; + + for (const test of cases) { + for (const [formatLoose, names] of Object.entries(formatTestNames)) { + const format = [formatLoose as PredefinedFormatsString]; + for (const name of names.invalid) { + const createCase = ( + preparedName: string, + options: Selector, + messageId: MessageIds, + data: Record = {}, + ): TSESLint.InvalidTestCase => ({ + options: [ + { + ...options, + filter: '[iI]gnored', + }, + ], + code: `// ${JSON.stringify(options)}\n${test.code + .map(code => code.replace(REPLACE_REGEX, preparedName)) + .join('\n')}`, + errors: test.code.map(() => ({ + messageId, + ...(test.options.selector !== 'default' && + test.options.selector !== 'variableLike' && + test.options.selector !== 'memberLike' && + test.options.selector !== 'typeLike' + ? { + data: { + type: selectorTypeToMessageString(test.options.selector), + name: preparedName, + ...data, + }, + } + : // meta-types will use the correct selector, so don't assert on data shape + {}), + })), + }); + + const prefixSingle = ['MyPrefix']; + const prefixMulti = ['MyPrefix1', 'MyPrefix2']; + const suffixSingle = ['MySuffix']; + const suffixMulti = ['MySuffix1', 'MySuffix2']; + + newCases.push( + createCase( + name, + { + ...test.options, + format, + }, + 'doesNotMatchFormat', + { formats: format.join(', ') }, + ), + + // leadingUnderscore + createCase( + `_${name}`, + { + ...test.options, + format, + leadingUnderscore: 'forbid', + }, + 'unexpectedUnderscore', + { position: 'leading' }, + ), + createCase( + name, + { + ...test.options, + format, + leadingUnderscore: 'require', + }, + 'missingUnderscore', + { position: 'leading' }, + ), + + // trailingUnderscore + createCase( + `${name}_`, + { + ...test.options, + format, + trailingUnderscore: 'forbid', + }, + 'unexpectedUnderscore', + { position: 'trailing' }, + ), + createCase( + name, + { + ...test.options, + format, + trailingUnderscore: 'require', + }, + 'missingUnderscore', + { position: 'trailing' }, + ), + + // prefix + createCase( + name, + { + ...test.options, + format, + prefix: prefixSingle, + }, + 'missingAffix', + { position: 'prefix', affixes: prefixSingle.join(', ') }, + ), + createCase( + name, + { + ...test.options, + format, + prefix: prefixMulti, + }, + 'missingAffix', + { + position: 'prefix', + affixes: prefixMulti.join(', '), + }, + ), + + // suffix + createCase( + name, + { + ...test.options, + format, + suffix: suffixSingle, + }, + 'missingAffix', + { position: 'suffix', affixes: suffixSingle.join(', ') }, + ), + createCase( + name, + { + ...test.options, + format, + suffix: suffixMulti, + }, + 'missingAffix', + { + position: 'suffix', + affixes: suffixMulti.join(', '), + }, + ), + ); + } + } + } + + return newCases; +} + +const cases: Cases = [ + // #region default + { + code: [ + 'const % = 1;', + 'function % () {}', + '(function (%) {});', + 'class Ignored { constructor(private %) {} }', + 'const ignored = { % };', + 'interface Ignored { %: string }', + 'type Ignored = { %: string }', + 'class Ignored { private % = 1 }', + 'class Ignored { constructor(private %) {} }', + 'class Ignored { private %() {} }', + 'const ignored = { %() {} };', + 'class Ignored { private get %() {} }', + 'enum Ignored { % }', + 'abstract class % {}', + 'interface % { }', + 'type % = { };', + 'enum % {}', + 'interface Ignored<%> extends Ignored {}', + ], + options: { + selector: 'default', + filter: '[iI]gnored', + }, + }, + // #endregion default + + // #region variable + { + code: [ + 'const % = 1;', + 'let % = 1;', + 'var % = 1;', + 'const {%} = {ignored: 1};', + 'const {% = 2} = {ignored: 1};', + 'const {...%} = {ignored: 1};', + 'const [%] = [1];', + 'const [% = 1] = [1];', + 'const [...%] = [1];', + ], + options: { + selector: 'variable', + }, + }, + // #endregion variable + + // #region function + { + code: ['function % () {}', '(function % () {});', 'declare function % ();'], + options: { + selector: 'function', + }, + }, + // #endregion function + + // #region parameter + { + code: [ + 'function ignored(%) {}', + '(function (%) {});', + 'declare function ignored(%);', + 'function ignored({%}) {}', + 'function ignored(...%) {}', + 'function ignored({% = 1}) {}', + 'function ignored({...%}) {}', + 'function ignored([%]) {}', + 'function ignored([% = 1]) {}', + 'function ignored([...%]) {}', + ], + options: { + selector: 'parameter', + }, + }, + // #endregion parameter + + // #region property + { + code: [ + 'const ignored = { % };', + 'const ignored = { "%": 1 };', + 'interface Ignored { % }', + 'interface Ignored { "%": string }', + 'type Ignored = { % }', + 'type Ignored = { "%": string }', + 'class Ignored { private % }', + 'class Ignored { private "%" = 1 }', + 'class Ignored { private readonly % = 1 }', + 'class Ignored { private static % }', + 'class Ignored { private static readonly % = 1 }', + 'class Ignored { abstract % = 1 }', + 'class Ignored { declare % }', + ], + options: { + selector: 'property', + }, + }, + { + code: [ + 'class Ignored { abstract private static readonly % = 1; ignoredDueToModifiers = 1; }', + ], + options: { + selector: 'property', + modifiers: ['static', 'readonly'], + }, + }, + // #endregion property + + // #region parameterProperty + { + code: [ + 'class Ignored { constructor(private %) {} }', + 'class Ignored { constructor(readonly %) {} }', + 'class Ignored { constructor(private readonly %) {} }', + ], + options: { + selector: 'parameterProperty', + }, + }, + { + code: ['class Ignored { constructor(private readonly %) {} }'], + options: { + selector: 'parameterProperty', + modifiers: ['readonly'], + }, + }, + // #endregion parameterProperty + + // #region method + { + code: [ + 'const ignored = { %() {} };', + 'const ignored = { "%"() {} };', + 'const ignored = { %: () => {} };', + 'interface Ignored { %(): string }', + 'interface Ignored { "%"(): string }', + 'type Ignored = { %(): string }', + 'type Ignored = { "%"(): string }', + 'class Ignored { private %() {} }', + 'class Ignored { private "%"() {} }', + 'class Ignored { private readonly %() {} }', + 'class Ignored { private static %() {} }', + 'class Ignored { private static readonly %() {} }', + 'class Ignored { private % = () => {} }', + 'class Ignored { abstract %() }', + 'class Ignored { declare %() }', + ], + options: { + selector: 'method', + }, + }, + { + code: [ + 'class Ignored { abstract private static %() {}; ignoredDueToModifiers() {}; }', + ], + options: { + selector: 'method', + modifiers: ['abstract', 'static'], + }, + }, + // #endregion method + + // #region accessor + { + code: [ + 'const ignored = { get %() {} };', + 'const ignored = { set "%"(ignored) {} };', + 'class Ignored { private get %() {} }', + 'class Ignored { private set "%"(ignored) {} }', + 'class Ignored { private static get %() {} }', + ], + options: { + selector: 'accessor', + }, + }, + { + code: [ + 'class Ignored { private static get %() {}; get ignoredDueToModifiers() {}; }', + ], + options: { + selector: 'accessor', + modifiers: ['private', 'static'], + }, + }, + // #endregion accessor + + // #region enumMember + { + code: ['enum Ignored { % }', 'enum Ignored { "%" }'], + options: { + selector: 'enumMember', + }, + }, + // #endregion enumMember + + // #region class + { + code: ['class % {}', 'abstract class % {}', 'const ignored = class % {}'], + options: { + selector: 'class', + }, + }, + { + code: ['abstract class % {}; class ignoredDueToModifier {}'], + options: { + selector: 'class', + modifiers: ['abstract'], + }, + }, + // #endregion class + + // #region interface + { + code: ['interface % {}'], + options: { + selector: 'interface', + }, + }, + // #endregion interface + + // #region typeAlias + { + code: ['type % = {};', 'type % = 1;'], + options: { + selector: 'typeAlias', + }, + }, + // #endregion typeAlias + + // #region enum + { + code: ['enum % {}'], + options: { + selector: 'enum', + }, + }, + // #endregion enum + + // #region typeParameter + { + code: [ + 'class Ignored<%> {}', + 'function ignored<%>() {}', + 'type Ignored<%> = { ignored: % };', + 'interface Ignored<%> extends Ignored {}', + ], + options: { + selector: 'typeParameter', + }, + }, + // #endregion typeParameter +]; + +ruleTester.run('naming-convention', rule, { + valid: [ + ...createValidTestCases(cases), + { + code: ` + declare const string_camelCase: string; + declare const string_camelCase: string | null; + declare const string_camelCase: string | null | undefined; + declare const string_camelCase: 'a' | null | undefined; + declare const string_camelCase: string | 'a' | null | undefined; + + declare const number_camelCase: number; + declare const number_camelCase: number | null; + declare const number_camelCase: number | null | undefined; + declare const number_camelCase: 1 | null | undefined; + declare const number_camelCase: number | 2 | null | undefined; + + declare const boolean_camelCase: boolean; + declare const boolean_camelCase: boolean | null; + declare const boolean_camelCase: boolean | null | undefined; + declare const boolean_camelCase: true | null | undefined; + declare const boolean_camelCase: false | null | undefined; + declare const boolean_camelCase: true | false | null | undefined; + `, + parserOptions, + options: [ + { + selector: 'variable', + types: ['string'], + format: ['camelCase'], + prefix: ['string_'], + }, + { + selector: 'variable', + types: ['number'], + format: ['camelCase'], + prefix: ['number_'], + }, + { + selector: 'variable', + types: ['boolean'], + format: ['camelCase'], + prefix: ['boolean_'], + }, + ], + }, + { + code: ` + let foo = 'a'; + const _foo = 1; + interface Foo {} + class Bar {} + function foo_function_bar() {} + `, + options: [ + { + selector: 'default', + format: ['camelCase'], + custom: { + regex: /^unused_\w/.source, + match: false, + }, + leadingUnderscore: 'allow', + }, + { + selector: 'typeLike', + format: ['PascalCase'], + custom: { + regex: /^I[A-Z]/.source, + match: false, + }, + }, + { + selector: 'function', + format: ['snake_case'], + custom: { + regex: /_function_/.source, + match: true, + }, + leadingUnderscore: 'allow', + }, + ], + }, + ], + invalid: [ + ...createInvalidTestCases(cases), + { + code: ` + declare const string_camelCase01: string; + declare const string_camelCase02: string | null; + declare const string_camelCase03: string | null | undefined; + declare const string_camelCase04: 'a' | null | undefined; + declare const string_camelCase05: string | 'a' | null | undefined; + + declare const number_camelCase06: number; + declare const number_camelCase07: number | null; + declare const number_camelCase08: number | null | undefined; + declare const number_camelCase09: 1 | null | undefined; + declare const number_camelCase10: number | 2 | null | undefined; + + declare const boolean_camelCase11: boolean; + declare const boolean_camelCase12: boolean | null; + declare const boolean_camelCase13: boolean | null | undefined; + declare const boolean_camelCase14: true | null | undefined; + declare const boolean_camelCase15: false | null | undefined; + declare const boolean_camelCase16: true | false | null | undefined; + `, + options: [ + { + selector: 'variable', + types: ['string'], + format: ['snake_case'], + prefix: ['string_'], + }, + { + selector: 'variable', + types: ['number'], + format: ['snake_case'], + prefix: ['number_'], + }, + { + selector: 'variable', + types: ['boolean'], + format: ['snake_case'], + prefix: ['boolean_'], + }, + ], + parserOptions, + errors: Array(16).fill({ messageId: 'doesNotMatchFormat' }), + }, + { + code: ` + declare const function_camelCase1: (() => void); + declare const function_camelCase2: (() => void) | null; + declare const function_camelCase3: (() => void) | null | undefined; + declare const function_camelCase4: (() => void) | (() => string) | null | undefined; + `, + options: [ + { + selector: 'variable', + types: ['function'], + format: ['snake_case'], + prefix: ['function_'], + }, + ], + parserOptions, + errors: Array(4).fill({ messageId: 'doesNotMatchFormat' }), + }, + { + code: ` + declare const array_camelCase1: Array; + declare const array_camelCase2: ReadonlyArray | null; + declare const array_camelCase3: number[] | null | undefined; + declare const array_camelCase4: readonly number[] | null | undefined; + declare const array_camelCase5: number[] | (number | string)[] | null | undefined; + declare const array_camelCase6: [] | null | undefined; + declare const array_camelCase7: [number] | null | undefined; + + declare const array_camelCase8: readonly number[] | Array | [boolean] | null | undefined; + `, + options: [ + { + selector: 'variable', + types: ['array'], + format: ['snake_case'], + prefix: ['array_'], + }, + ], + parserOptions, + errors: Array(8).fill({ messageId: 'doesNotMatchFormat' }), + }, + { + code: ` + let unused_foo = 'a'; + const _unused_foo = 1; + interface IFoo {} + class IBar {} + function fooBar() {} + `, + options: [ + { + selector: 'default', + format: ['snake_case'], + custom: { + regex: /^unused_\w/.source, + match: false, + }, + leadingUnderscore: 'allow', + }, + { + selector: 'typeLike', + format: ['PascalCase'], + custom: { + regex: /^I[A-Z]/.source, + match: false, + }, + }, + { + selector: 'function', + format: ['camelCase'], + custom: { + regex: /function/.source, + match: true, + }, + leadingUnderscore: 'allow', + }, + ], + errors: [ + { + messageId: 'satisfyCustom', + line: 2, + data: { + type: 'Variable', + name: 'unused_foo', + regex: '/^unused_\\w/', + regexMatch: 'not match', + }, + }, + { + messageId: 'satisfyCustom', + line: 3, + data: { + type: 'Variable', + name: '_unused_foo', + regex: '/^unused_\\w/', + regexMatch: 'not match', + }, + }, + { + messageId: 'satisfyCustom', + line: 4, + data: { + type: 'Interface', + name: 'IFoo', + regex: '/^I[A-Z]/', + regexMatch: 'not match', + }, + }, + { + messageId: 'satisfyCustom', + line: 5, + data: { + type: 'Class', + name: 'IBar', + regex: '/^I[A-Z]/', + regexMatch: 'not match', + }, + }, + { + messageId: 'satisfyCustom', + line: 6, + data: { + type: 'Function', + name: 'fooBar', + regex: '/function/', + regexMatch: 'match', + }, + }, + ], + }, + ], +});