From 0c4f474ccba2fd329cb43ae2309e786b51889a81 Mon Sep 17 00:00:00 2001 From: Pete Gonzalez <4673363+octogonz@users.noreply.github.com> Date: Tue, 13 Aug 2019 12:52:28 -0700 Subject: [PATCH] feat(eslint-plugin): [interface-name-prefix, class-name-casing] Add allowUnderscorePrefix option to support private declarations (#790) --- .../docs/rules/class-name-casing.md | 16 +++ .../docs/rules/interface-name-prefix.md | 85 +++++++++++-- .../src/rules/class-name-casing.ts | 32 ++++- .../src/rules/interface-name-prefix.ts | 113 ++++++++++++++++-- .../tests/rules/class-name-casing.test.ts | 18 +++ .../tests/rules/interface-name-prefix.test.ts | 63 +++++++++- 6 files changed, 302 insertions(+), 25 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/class-name-casing.md b/packages/eslint-plugin/docs/rules/class-name-casing.md index a1fba58e323..f594a9854f1 100644 --- a/packages/eslint-plugin/docs/rules/class-name-casing.md +++ b/packages/eslint-plugin/docs/rules/class-name-casing.md @@ -5,6 +5,17 @@ This rule enforces PascalCased names for classes and interfaces. ## Rule Details This rule aims to make it easy to differentiate classes from regular variables at a glance. +The `_` prefix is sometimes used to designate a private declaration, so the rule also supports a name +that might be `_Example` instead of `Example`. + +## Options + +This rule has an object option: + +- `"allowUnderscorePrefix": false`: (default) does not allow the name to have an underscore prefix +- `"allowUnderscorePrefix": true`: allows the name to optionally have an underscore prefix + +## Examples Examples of **incorrect** code for this rule: @@ -16,6 +27,8 @@ class Another_Invalid_Class_Name {} var bar = class invalidName {}; interface someInterface {} + +class _InternalClass {} ``` Examples of **correct** code for this rule: @@ -28,6 +41,9 @@ export default class {} var foo = class {}; interface SomeInterface {} + +/* eslint @typescript-eslint/class-name-casing: { "allowUnderscorePrefix": true } */ +class _InternalClass {} ``` ## When Not To Use It diff --git a/packages/eslint-plugin/docs/rules/interface-name-prefix.md b/packages/eslint-plugin/docs/rules/interface-name-prefix.md index c6d907568a1..309dcbe4f24 100644 --- a/packages/eslint-plugin/docs/rules/interface-name-prefix.md +++ b/packages/eslint-plugin/docs/rules/interface-name-prefix.md @@ -1,22 +1,35 @@ # Require that interface names be prefixed with `I` (interface-name-prefix) -It can be hard to differentiate between classes and interfaces. -Prefixing interfaces with "I" can help telling them apart at a glance. +Interfaces often represent important software contracts, so it can be helpful to prefix their names with `I`. +The unprefixed name is then available for a class that provides a standard implementation of the interface. +Alternatively, the contributor guidelines for the TypeScript repo suggest +[never prefixing](https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines#names) interfaces with `I`. ## Rule Details -This rule enforces consistency of interface naming prefix conventions. +This rule enforces whether or not the `I` prefix is required for interface names. +The `_` prefix is sometimes used to designate a private declaration, so the rule also supports a private interface +that might be named `_IAnimal` instead of `IAnimal`. ## Options -This rule has a string option. +This rule has an object option: -- `"never"` (default) disallows all interfaces being prefixed with `"I"` -- `"always"` requires all interfaces be prefixed with `"I"` +- `{ "prefixWithI": "never" }`: (default) disallows all interfaces being prefixed with `"I"` or `"_I"` +- `{ "prefixWithI": "always" }`: requires all interfaces be prefixed with `"I"` (but does not allow `"_I"`) +- `{ "prefixWithI": "always", "allowUnderscorePrefix": true }`: requires all interfaces be prefixed with + either `"I"` or `"_I"` + +For backwards compatibility, this rule supports a string option instead: + +- `"never"`: Equivalent to `{ "prefixWithI": "never" }` +- `"always"`: Equivalent to `{ "prefixWithI": "always" }` + +## Examples ### never -TypeScript suggests [never prefixing](https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines#names) interfaces with "I". +**Configuration:** `{ "prefixWithI": "never" }` The following patterns are considered warnings: @@ -24,6 +37,14 @@ The following patterns are considered warnings: interface IAnimal { name: string; } + +interface IIguana { + name: string; +} + +interface _IAnimal { + name: string; +} ``` The following patterns are not warnings: @@ -32,16 +53,30 @@ The following patterns are not warnings: interface Animal { name: string; } + +interface Iguana { + name: string; +} ``` ### always +**Configuration:** `{ "prefixWithI": "always" }` + The following patterns are considered warnings: ```ts interface Animal { name: string; } + +interface Iguana { + name: string; +} + +interface _IAnimal { + name: string; +} ``` The following patterns are not warnings: @@ -50,6 +85,42 @@ The following patterns are not warnings: interface IAnimal { name: string; } + +interface IIguana { + name: string; +} +``` + +### always and allowing underscores + +**Configuration:** `{ "prefixWithI": "always", "allowUnderscorePrefix": true }` + +The following patterns are considered warnings: + +```ts +interface Animal { + name: string; +} + +interface Iguana { + name: string; +} +``` + +The following patterns are not warnings: + +```ts +interface IAnimal { + name: string; +} + +interface IIguana { + name: string; +} + +interface _IAnimal { + name: string; +} ``` ## When Not To Use It diff --git a/packages/eslint-plugin/src/rules/class-name-casing.ts b/packages/eslint-plugin/src/rules/class-name-casing.ts index cb238f87ac7..c71a39fd2ac 100644 --- a/packages/eslint-plugin/src/rules/class-name-casing.ts +++ b/packages/eslint-plugin/src/rules/class-name-casing.ts @@ -4,7 +4,14 @@ import { } from '@typescript-eslint/experimental-utils'; import * as util from '../util'; -export default util.createRule({ +type Options = [ + { + allowUnderscorePrefix?: boolean; + }, +]; +type MessageIds = 'notPascalCased'; + +export default util.createRule({ name: 'class-name-casing', meta: { type: 'suggestion', @@ -16,16 +23,31 @@ export default util.createRule({ messages: { notPascalCased: "{{friendlyName}} '{{name}}' must be PascalCased.", }, - schema: [], + schema: [ + { + type: 'object', + properties: { + allowUnderscorePrefix: { + type: 'boolean', + default: false, + }, + }, + additionalProperties: false, + }, + ], }, - defaultOptions: [], - create(context) { + defaultOptions: [{ allowUnderscorePrefix: false }], + create(context, [options]) { /** * Determine if the identifier name is PascalCased * @param name The identifier name */ function isPascalCase(name: string): boolean { - return /^[A-Z][0-9A-Za-z]*$/.test(name); + if (options.allowUnderscorePrefix) { + return /^_?[A-Z][0-9A-Za-z]*$/.test(name); + } else { + return /^[A-Z][0-9A-Za-z]*$/.test(name); + } } /** diff --git a/packages/eslint-plugin/src/rules/interface-name-prefix.ts b/packages/eslint-plugin/src/rules/interface-name-prefix.ts index 11976552221..13284fc2aa3 100644 --- a/packages/eslint-plugin/src/rules/interface-name-prefix.ts +++ b/packages/eslint-plugin/src/rules/interface-name-prefix.ts @@ -1,8 +1,43 @@ import * as util from '../util'; -type Options = ['never' | 'always']; +type ParsedOptions = + | { + prefixWithI: 'never'; + } + | { + prefixWithI: 'always'; + allowUnderscorePrefix: boolean; + }; +type Options = [ + + | 'never' + | 'always' + | { + prefixWithI?: 'never'; + } + | { + prefixWithI: 'always'; + allowUnderscorePrefix?: boolean; + }, +]; type MessageIds = 'noPrefix' | 'alwaysPrefix'; +/** + * Parses a given value as options. + */ +export function parseOptions([options]: Options): ParsedOptions { + if (options === 'always') { + return { prefixWithI: 'always', allowUnderscorePrefix: false }; + } + if (options !== 'never' && options.prefixWithI === 'always') { + return { + prefixWithI: 'always', + allowUnderscorePrefix: !!options.allowUnderscorePrefix, + }; + } + return { prefixWithI: 'never' }; +} + export default util.createRule({ name: 'interface-name-prefix', meta: { @@ -21,13 +56,46 @@ export default util.createRule({ }, schema: [ { - enum: ['never', 'always'], + oneOf: [ + { + enum: [ + // Deprecated, equivalent to: { prefixWithI: 'never' } + 'never', + // Deprecated, equivalent to: { prefixWithI: 'always', allowUnderscorePrefix: false } + 'always', + ], + }, + { + type: 'object', + properties: { + prefixWithI: { + type: 'string', + enum: ['never'], + }, + }, + additionalProperties: false, + }, + { + type: 'object', + properties: { + prefixWithI: { + type: 'string', + enum: ['always'], + }, + allowUnderscorePrefix: { + type: 'boolean', + }, + }, + required: ['prefixWithI'], // required to select this "oneOf" alternative + additionalProperties: false, + }, + ], }, ], }, - defaultOptions: ['never'], - create(context, [option]) { - const never = option !== 'always'; + defaultOptions: [{ prefixWithI: 'never' }], + create(context, [options]) { + const parsedOptions = parseOptions([options]); /** * Checks if a string is prefixed with "I". @@ -41,21 +109,42 @@ export default util.createRule({ return /^I[A-Z]/.test(name); } + /** + * Checks if a string is prefixed with "I" or "_I". + * @param name The string to check + */ + function isPrefixedWithIOrUnderscoreI(name: string): boolean { + if (typeof name !== 'string') { + return false; + } + + return /^_?I[A-Z]/.test(name); + } + return { TSInterfaceDeclaration(node): void { - if (never) { - if (isPrefixedWithI(node.id.name)) { + if (parsedOptions.prefixWithI === 'never') { + if (isPrefixedWithIOrUnderscoreI(node.id.name)) { context.report({ node: node.id, messageId: 'noPrefix', }); } } else { - if (!isPrefixedWithI(node.id.name)) { - context.report({ - node: node.id, - messageId: 'alwaysPrefix', - }); + if (parsedOptions.allowUnderscorePrefix) { + if (!isPrefixedWithIOrUnderscoreI(node.id.name)) { + context.report({ + node: node.id, + messageId: 'alwaysPrefix', + }); + } + } else { + if (!isPrefixedWithI(node.id.name)) { + context.report({ + node: node.id, + messageId: 'alwaysPrefix', + }); + } } } }, diff --git a/packages/eslint-plugin/tests/rules/class-name-casing.test.ts b/packages/eslint-plugin/tests/rules/class-name-casing.test.ts index af85e24472f..7409fd927e6 100644 --- a/packages/eslint-plugin/tests/rules/class-name-casing.test.ts +++ b/packages/eslint-plugin/tests/rules/class-name-casing.test.ts @@ -14,6 +14,10 @@ ruleTester.run('class-name-casing', rule, { sourceType: 'module', }, }, + { + code: 'class _NameWithUnderscore {}', + options: [{ allowUnderscorePrefix: true }], + }, 'var Foo = class {};', 'interface SomeInterface {}', 'class ClassNameWithDigit2 {}', @@ -50,6 +54,20 @@ ruleTester.run('class-name-casing', rule, { }, ], }, + { + code: 'class _NameWithUnderscore {}', + errors: [ + { + messageId: 'notPascalCased', + data: { + friendlyName: 'Class', + name: '_NameWithUnderscore', + }, + line: 1, + column: 7, + }, + ], + }, { code: 'var foo = class {};', errors: [ diff --git a/packages/eslint-plugin/tests/rules/interface-name-prefix.test.ts b/packages/eslint-plugin/tests/rules/interface-name-prefix.test.ts index 09cf548c7ac..337a96a368f 100644 --- a/packages/eslint-plugin/tests/rules/interface-name-prefix.test.ts +++ b/packages/eslint-plugin/tests/rules/interface-name-prefix.test.ts @@ -1,6 +1,29 @@ -import rule from '../../src/rules/interface-name-prefix'; +import assert from 'assert'; +import rule, { parseOptions } from '../../src/rules/interface-name-prefix'; import { RuleTester } from '../RuleTester'; +describe('interface-name-prefix', () => { + it('parseOptions', () => { + assert.deepEqual(parseOptions(['never']), { prefixWithI: 'never' }); + assert.deepEqual(parseOptions(['always']), { + prefixWithI: 'always', + allowUnderscorePrefix: false, + }); + assert.deepEqual(parseOptions([{}]), { prefixWithI: 'never' }); + assert.deepEqual(parseOptions([{ prefixWithI: 'never' }]), { + prefixWithI: 'never', + }); + assert.deepEqual(parseOptions([{ prefixWithI: 'always' }]), { + prefixWithI: 'always', + allowUnderscorePrefix: false, + }); + assert.deepEqual( + parseOptions([{ prefixWithI: 'always', allowUnderscorePrefix: true }]), + { prefixWithI: 'always', allowUnderscorePrefix: true }, + ); + }); +}); + const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', }); @@ -22,6 +45,14 @@ interface IAnimal { }, { code: ` +interface _IAnimal { + name: string; +} + `, + options: [{ prefixWithI: 'always', allowUnderscorePrefix: true }], + }, + { + code: ` interface IIguana { name: string; } @@ -85,6 +116,21 @@ interface Animal { }, { code: ` +interface Animal { + name: string; +} + `, + options: [{ prefixWithI: 'always', allowUnderscorePrefix: true }], + errors: [ + { + messageId: 'alwaysPrefix', + line: 2, + column: 11, + }, + ], + }, + { + code: ` interface Iguana { name: string; } @@ -117,6 +163,21 @@ interface IIguana { code: ` interface IAnimal { name: string; +} + `, + options: ['never'], + errors: [ + { + messageId: 'noPrefix', + line: 2, + column: 11, + }, + ], + }, + { + code: ` +interface _IAnimal { + name: string; } `, options: ['never'],