From a85c3e1515d735b6c245cc658cdaec6deb05d630 Mon Sep 17 00:00:00 2001 From: Anix Date: Mon, 27 Apr 2020 01:11:31 +0530 Subject: [PATCH] feat(eslint-plugin): add extension rule `dot-notation` (#1867) --- packages/eslint-plugin/README.md | 1 + .../eslint-plugin/docs/rules/dot-notation.md | 46 ++++ packages/eslint-plugin/src/configs/all.json | 2 + .../eslint-plugin/src/rules/dot-notation.ts | 80 ++++++ packages/eslint-plugin/src/rules/index.ts | 2 + .../tests/rules/dot-notation.test.ts | 259 ++++++++++++++++++ .../eslint-plugin/typings/eslint-rules.d.ts | 19 ++ 7 files changed, 409 insertions(+) create mode 100644 packages/eslint-plugin/docs/rules/dot-notation.md create mode 100644 packages/eslint-plugin/src/rules/dot-notation.ts create mode 100644 packages/eslint-plugin/tests/rules/dot-notation.test.ts diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index a0b38fead9c..f6e70ef98ac 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -182,6 +182,7 @@ In these cases, we create what we call an extension rule; a rule within our plug | [`@typescript-eslint/brace-style`](./docs/rules/brace-style.md) | Enforce consistent brace style for blocks | | :wrench: | | | [`@typescript-eslint/comma-spacing`](./docs/rules/comma-spacing.md) | Enforces consistent spacing before and after commas | | :wrench: | | | [`@typescript-eslint/default-param-last`](./docs/rules/default-param-last.md) | Enforce default parameters to be last | | | | +| [`@typescript-eslint/dot-notation`](./docs/rules/dot-notation.md) | enforce dot notation whenever possible | | :wrench: | :thought_balloon: | | [`@typescript-eslint/func-call-spacing`](./docs/rules/func-call-spacing.md) | Require or disallow spacing between function identifiers and their invocations | | :wrench: | | | [`@typescript-eslint/indent`](./docs/rules/indent.md) | Enforce consistent indentation | | :wrench: | | | [`@typescript-eslint/init-declarations`](./docs/rules/init-declarations.md) | require or disallow initialization in variable declarations | | | | diff --git a/packages/eslint-plugin/docs/rules/dot-notation.md b/packages/eslint-plugin/docs/rules/dot-notation.md new file mode 100644 index 00000000000..f827b003e57 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/dot-notation.md @@ -0,0 +1,46 @@ +# enforce dot notation whenever possible (`dot-notation`) + +## Rule Details + +This rule extends the base [`eslint/dot-notation`](https://eslint.org/docs/rules/dot-notation) rule. +It adds support for optionally ignoring computed `private` member access. + +## How to use + +```cjson +{ + // note you must disable the base rule as it can report incorrect errors + "dot-notation": "off", + "@typescript-eslint/dot-notation": ["error"] +} +``` + +## Options + +See [`eslint/dot-notation`](https://eslint.org/docs/rules/dot-notation#options) options. +This rule adds the following options: + +```ts +interface Options extends BaseDotNotationOptions { + allowPrivateClassPropertyAccess?: boolean; +} +const defaultOptions: Options = { + ...baseDotNotationDefaultOptions, + allowPrivateClassPropertyAccess: false, +}; +``` + +### `allowPrivateClassPropertyAccess` + +Example of a correct code when `allowPrivateClassPropertyAccess` is set to `true` + +```ts +class X { + private priv_prop = 123; +} + +const x = new X(); +x['priv_prop'] = 123; +``` + +Taken with ❤️ [from ESLint core](https://github.com/eslint/eslint/blob/master/docs/rules/dot-notation.md) diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json index 7a6a217140a..0e103162f5f 100644 --- a/packages/eslint-plugin/src/configs/all.json +++ b/packages/eslint-plugin/src/configs/all.json @@ -15,6 +15,8 @@ "@typescript-eslint/consistent-type-definitions": "error", "default-param-last": "off", "@typescript-eslint/default-param-last": "error", + "dot-notation": "off", + "@typescript-eslint/dot-notation": "error", "@typescript-eslint/explicit-function-return-type": "error", "@typescript-eslint/explicit-member-accessibility": "error", "@typescript-eslint/explicit-module-boundary-types": "error", diff --git a/packages/eslint-plugin/src/rules/dot-notation.ts b/packages/eslint-plugin/src/rules/dot-notation.ts new file mode 100644 index 00000000000..fb710187d29 --- /dev/null +++ b/packages/eslint-plugin/src/rules/dot-notation.ts @@ -0,0 +1,80 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import * as ts from 'typescript'; +import baseRule from 'eslint/lib/rules/dot-notation'; +import { + InferOptionsTypeFromRule, + InferMessageIdsTypeFromRule, + createRule, + getParserServices, +} from '../util'; + +export type Options = InferOptionsTypeFromRule; +export type MessageIds = InferMessageIdsTypeFromRule; + +export default createRule({ + name: 'dot-notation', + meta: { + type: 'suggestion', + docs: { + description: 'enforce dot notation whenever possible', + category: 'Best Practices', + recommended: false, + extendsBaseRule: true, + requiresTypeChecking: true, + }, + schema: [ + { + type: 'object', + properties: { + allowKeywords: { + type: 'boolean', + default: true, + }, + allowPattern: { + type: 'string', + default: '', + }, + allowPrivateClassPropertyAccess: { + tyoe: 'boolean', + default: false, + }, + }, + additionalProperties: false, + }, + ], + fixable: baseRule.meta.fixable, + messages: baseRule.meta.messages, + }, + defaultOptions: [ + { + allowPrivateClassPropertyAccess: false, + allowKeywords: true, + allowPattern: '', + }, + ], + create(context, [options]) { + const rules = baseRule.create(context); + const allowPrivateClassPropertyAccess = + options.allowPrivateClassPropertyAccess; + + const parserServices = getParserServices(context); + const typeChecker = parserServices.program.getTypeChecker(); + + return { + MemberExpression(node: TSESTree.MemberExpression): void { + const objectSymbol = typeChecker.getSymbolAtLocation( + parserServices.esTreeNodeToTSNodeMap.get(node.property), + ); + + if ( + allowPrivateClassPropertyAccess && + objectSymbol?.declarations[0]?.modifiers?.[0].kind === + ts.SyntaxKind.PrivateKeyword + ) { + return; + } + rules.MemberExpression(node); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index b6652f79eb4..2d8e6830c4f 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -12,6 +12,7 @@ import commaSpacing from './comma-spacing'; import consistentTypeAssertions from './consistent-type-assertions'; import consistentTypeDefinitions from './consistent-type-definitions'; import defaultParamLast from './default-param-last'; +import dotNotation from './dot-notation'; import explicitFunctionReturnType from './explicit-function-return-type'; import explicitMemberAccessibility from './explicit-member-accessibility'; import explicitModuleBoundaryTypes from './explicit-module-boundary-types'; @@ -114,6 +115,7 @@ export default { 'consistent-type-assertions': consistentTypeAssertions, 'consistent-type-definitions': consistentTypeDefinitions, 'default-param-last': defaultParamLast, + 'dot-notation': dotNotation, 'explicit-function-return-type': explicitFunctionReturnType, 'explicit-member-accessibility': explicitMemberAccessibility, 'explicit-module-boundary-types': explicitModuleBoundaryTypes, diff --git a/packages/eslint-plugin/tests/rules/dot-notation.test.ts b/packages/eslint-plugin/tests/rules/dot-notation.test.ts new file mode 100644 index 00000000000..797b111cb17 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/dot-notation.test.ts @@ -0,0 +1,259 @@ +import rule from '../../src/rules/dot-notation'; +import { RuleTester, getFixturesRootDir } from '../RuleTester'; + +const rootPath = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +/** + * Quote a string in "double quotes" because it’s painful + * with a double-quoted string literal + */ +function q(str: string): string { + return `"${str}"`; +} + +ruleTester.run('dot-notation', rule, { + valid: [ + // baseRule + + 'a.b;', + 'a.b.c;', + "a['12'];", + 'a[b];', + 'a[0];', + { code: 'a.b.c;', options: [{ allowKeywords: false }] }, + { code: 'a.arguments;', options: [{ allowKeywords: false }] }, + { code: 'a.let;', options: [{ allowKeywords: false }] }, + { code: 'a.yield;', options: [{ allowKeywords: false }] }, + { code: 'a.eval;', options: [{ allowKeywords: false }] }, + { code: 'a[0];', options: [{ allowKeywords: false }] }, + { code: "a['while'];", options: [{ allowKeywords: false }] }, + { code: "a['true'];", options: [{ allowKeywords: false }] }, + { code: "a['null'];", options: [{ allowKeywords: false }] }, + { code: 'a[true];', options: [{ allowKeywords: false }] }, + { code: 'a[null];', options: [{ allowKeywords: false }] }, + { code: 'a.true;', options: [{ allowKeywords: true }] }, + { code: 'a.null;', options: [{ allowKeywords: true }] }, + { + code: "a['snake_case'];", + options: [{ allowPattern: '^[a-z]+(_[a-z]+)+$' }], + }, + { + code: "a['lots_of_snake_case'];", + options: [{ allowPattern: '^[a-z]+(_[a-z]+)+$' }], + }, + { code: 'a[`time${range}`];', parserOptions: { ecmaVersion: 6 } }, + { + code: 'a[`while`];', + options: [{ allowKeywords: false }], + parserOptions: { ecmaVersion: 6 }, + }, + { code: 'a[`time range`];', parserOptions: { ecmaVersion: 6 } }, + 'a.true;', + 'a.null;', + 'a[undefined];', + 'a[void 0];', + 'a[b()];', + { code: 'a[/(?0)/];', parserOptions: { ecmaVersion: 2018 } }, + + { + code: ` +class X { + private priv_prop = 123; +} + +const x = new X(); +x['priv_prop'] = 123; + `, + options: [{ allowPrivateClassPropertyAccess: true }], + }, + ], + invalid: [ + { + code: ` +class X { + private priv_prop = 123; +} + +const x = new X(); +x['priv_prop'] = 123; + `, + options: [{ allowPrivateClassPropertyAccess: false }], + output: ` +class X { + private priv_prop = 123; +} + +const x = new X(); +x.priv_prop = 123; + `, + errors: [{ messageId: 'useDot' }], + }, + { + code: ` +class X { + public pub_prop = 123; +} + +const x = new X(); +x['pub_prop'] = 123; + `, + output: ` +class X { + public pub_prop = 123; +} + +const x = new X(); +x.pub_prop = 123; + `, + errors: [{ messageId: 'useDot' }], + }, + // baseRule + + // { + // code: 'a.true;', + // output: "a['true'];", + // options: [{ allowKeywords: false }], + // errors: [{ messageId: "useBrackets", data: { key: "true" } }], + // }, + { + code: "a['true'];", + output: 'a.true;', + errors: [{ messageId: 'useDot', data: { key: q('true') } }], + }, + { + code: "a['time'];", + output: 'a.time;', + parserOptions: { ecmaVersion: 6 }, + errors: [{ messageId: 'useDot', data: { key: '"time"' } }], + }, + { + code: 'a[null];', + output: 'a.null;', + errors: [{ messageId: 'useDot', data: { key: 'null' } }], + }, + { + code: 'a[true];', + output: 'a.true;', + errors: [{ messageId: 'useDot', data: { key: 'true' } }], + }, + { + code: 'a[false];', + output: 'a.false;', + errors: [{ messageId: 'useDot', data: { key: 'false' } }], + }, + { + code: "a['b'];", + output: 'a.b;', + errors: [{ messageId: 'useDot', data: { key: q('b') } }], + }, + { + code: "a.b['c'];", + output: 'a.b.c;', + errors: [{ messageId: 'useDot', data: { key: q('c') } }], + }, + { + code: "a['_dangle'];", + output: 'a._dangle;', + options: [{ allowPattern: '^[a-z]+(_[a-z]+)+$' }], + errors: [{ messageId: 'useDot', data: { key: q('_dangle') } }], + }, + { + code: "a['SHOUT_CASE'];", + output: 'a.SHOUT_CASE;', + options: [{ allowPattern: '^[a-z]+(_[a-z]+)+$' }], + errors: [{ messageId: 'useDot', data: { key: q('SHOUT_CASE') } }], + }, + { + code: 'a\n' + " ['SHOUT_CASE'];", + output: 'a\n' + ' .SHOUT_CASE;', + errors: [ + { + messageId: 'useDot', + data: { key: q('SHOUT_CASE') }, + line: 2, + column: 4, + }, + ], + }, + { + code: + 'getResource()\n' + + ' .then(function(){})\n' + + ' ["catch"](function(){})\n' + + ' .then(function(){})\n' + + ' ["catch"](function(){});', + output: + 'getResource()\n' + + ' .then(function(){})\n' + + ' .catch(function(){})\n' + + ' .then(function(){})\n' + + ' .catch(function(){});', + errors: [ + { + messageId: 'useDot', + data: { key: q('catch') }, + line: 3, + column: 6, + }, + { + messageId: 'useDot', + data: { key: q('catch') }, + line: 5, + column: 6, + }, + ], + }, + { + code: 'foo\n' + ' .while;', + output: 'foo\n' + ' ["while"];', + options: [{ allowKeywords: false }], + errors: [{ messageId: 'useBrackets', data: { key: 'while' } }], + }, + { + code: "foo[/* comment */ 'bar'];", + output: null, // Not fixed due to comment + errors: [{ messageId: 'useDot', data: { key: q('bar') } }], + }, + { + code: "foo['bar' /* comment */];", + output: null, // Not fixed due to comment + errors: [{ messageId: 'useDot', data: { key: q('bar') } }], + }, + { + code: "foo['bar'];", + output: 'foo.bar;', + errors: [{ messageId: 'useDot', data: { key: q('bar') } }], + }, + { + code: 'foo./* comment */ while;', + output: null, // Not fixed due to comment + options: [{ allowKeywords: false }], + errors: [{ messageId: 'useBrackets', data: { key: 'while' } }], + }, + { + code: 'foo[null];', + output: 'foo.null;', + errors: [{ messageId: 'useDot', data: { key: 'null' } }], + }, + { + code: "foo['bar'] instanceof baz;", + output: 'foo.bar instanceof baz;', + errors: [{ messageId: 'useDot', data: { key: q('bar') } }], + }, + { + code: 'let.if();', + output: null, // `let["if"]()` is a syntax error because `let[` indicates a destructuring variable declaration + options: [{ allowKeywords: false }], + errors: [{ messageId: 'useBrackets', data: { key: 'if' } }], + }, + ], +}); diff --git a/packages/eslint-plugin/typings/eslint-rules.d.ts b/packages/eslint-plugin/typings/eslint-rules.d.ts index eb2d1cd9c60..0bac88823e2 100644 --- a/packages/eslint-plugin/typings/eslint-rules.d.ts +++ b/packages/eslint-plugin/typings/eslint-rules.d.ts @@ -640,3 +640,22 @@ declare module 'eslint/lib/rules/init-declarations' { >; export = rule; } + +declare module 'eslint/lib/rules/dot-notation' { + import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + + const rule: TSESLint.RuleModule< + 'useDot' | 'useBrackets', + [ + { + allowKeywords?: boolean; + allowPattern?: string; + allowPrivateClassPropertyAccess?: boolean; + }, + ], + { + MemberExpression(node: TSESTree.MemberExpression): void; + } + >; + export = rule; +}