From 9f8209952c2325763f490b6b283dfa717e3df1b5 Mon Sep 17 00:00:00 2001 From: Alexander T Date: Mon, 19 Aug 2019 12:03:14 +0300 Subject: [PATCH] feat(eslint-plugin): add quotes [extension] (#762) --- packages/eslint-plugin/README.md | 1 + packages/eslint-plugin/docs/rules/quotes.md | 22 + packages/eslint-plugin/src/configs/all.json | 2 + packages/eslint-plugin/src/rules/index.ts | 2 + packages/eslint-plugin/src/rules/quotes.ts | 62 ++ .../eslint-plugin/tests/rules/quotes.test.ts | 662 ++++++++++++++++++ .../eslint-plugin/tools/generate-configs.ts | 1 + .../eslint-plugin/typings/eslint-rules.d.ts | 20 + 8 files changed, 772 insertions(+) create mode 100644 packages/eslint-plugin/docs/rules/quotes.md create mode 100644 packages/eslint-plugin/src/rules/quotes.ts create mode 100644 packages/eslint-plugin/tests/rules/quotes.test.ts diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index e0f32686c9d..a3ce816616b 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -191,6 +191,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e | [`@typescript-eslint/prefer-regexp-exec`](./docs/rules/prefer-regexp-exec.md) | Prefer RegExp#exec() over String#match() if no global flag is provided | :heavy_check_mark: | | :thought_balloon: | | [`@typescript-eslint/prefer-string-starts-ends-with`](./docs/rules/prefer-string-starts-ends-with.md) | Enforce the use of `String#startsWith` and `String#endsWith` instead of other equivalent methods of checking substrings | :heavy_check_mark: | :wrench: | :thought_balloon: | | [`@typescript-eslint/promise-function-async`](./docs/rules/promise-function-async.md) | Requires any function or method that returns a Promise to be marked async | | | :thought_balloon: | +| [`@typescript-eslint/quotes`](./docs/rules/quotes.md) | Enforce the consistent use of either backticks, double, or single quotes | | :wrench: | | | [`@typescript-eslint/require-array-sort-compare`](./docs/rules/require-array-sort-compare.md) | Enforce giving `compare` argument to `Array#sort` | | | :thought_balloon: | | [`@typescript-eslint/require-await`](./docs/rules/require-await.md) | Disallow async functions which have no `await` expression | :heavy_check_mark: | | :thought_balloon: | | [`@typescript-eslint/restrict-plus-operands`](./docs/rules/restrict-plus-operands.md) | When adding two variables, operands must both be of type number or of type string | | | :thought_balloon: | diff --git a/packages/eslint-plugin/docs/rules/quotes.md b/packages/eslint-plugin/docs/rules/quotes.md new file mode 100644 index 00000000000..e707a94b3d3 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/quotes.md @@ -0,0 +1,22 @@ +# Enforce the consistent use of either backticks, double, or single quotes + +## Rule Details + +This rule extends the base [eslint/quotes](https://eslint.org/docs/rules/quotes) rule. +It supports all options and features of the base rule. + +## How to use + +```cjson +{ + // note you must disable the base rule as it can report incorrect errors + "quotes": "off", + "@typescript-eslint/quotes": ["error"] +} +``` + +## Options + +See [eslint/quotes options](https://eslint.org/docs/rules/quotes#options). + +Taken with ❤️ [from ESLint core](https://github.com/eslint/eslint/blob/master/docs/rules/quotes.md) diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json index 30a7cafafb5..cce896718db 100644 --- a/packages/eslint-plugin/src/configs/all.json +++ b/packages/eslint-plugin/src/configs/all.json @@ -62,6 +62,8 @@ "@typescript-eslint/prefer-regexp-exec": "error", "@typescript-eslint/prefer-string-starts-ends-with": "error", "@typescript-eslint/promise-function-async": "error", + "quotes": "off", + "@typescript-eslint/quotes": "error", "@typescript-eslint/require-array-sort-compare": "error", "require-await": "off", "@typescript-eslint/require-await": "error", diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index db155e691bf..bd39c7867e3 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -48,6 +48,7 @@ import preferReadonly from './prefer-readonly'; import preferRegexpExec from './prefer-regexp-exec'; import preferStringStartsEndsWith from './prefer-string-starts-ends-with'; import promiseFunctionAsync from './promise-function-async'; +import quotes from './quotes'; import requireArraySortCompare from './require-array-sort-compare'; import requireAwait from './require-await'; import restrictPlusOperands from './restrict-plus-operands'; @@ -112,6 +113,7 @@ export default { 'prefer-regexp-exec': preferRegexpExec, 'prefer-string-starts-ends-with': preferStringStartsEndsWith, 'promise-function-async': promiseFunctionAsync, + quotes: quotes, 'require-array-sort-compare': requireArraySortCompare, 'require-await': requireAwait, 'restrict-plus-operands': restrictPlusOperands, diff --git a/packages/eslint-plugin/src/rules/quotes.ts b/packages/eslint-plugin/src/rules/quotes.ts new file mode 100644 index 00000000000..97efc04c821 --- /dev/null +++ b/packages/eslint-plugin/src/rules/quotes.ts @@ -0,0 +1,62 @@ +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import baseRule from 'eslint/lib/rules/quotes'; +import * as util from '../util'; + +export type Options = util.InferOptionsTypeFromRule; +export type MessageIds = util.InferMessageIdsTypeFromRule; + +export default util.createRule({ + name: 'quotes', + meta: { + type: 'layout', + docs: { + description: + 'Enforce the consistent use of either backticks, double, or single quotes', + category: 'Stylistic Issues', + recommended: false, + }, + fixable: 'code', + messages: baseRule.meta.messages, + schema: baseRule.meta.schema, + }, + defaultOptions: [ + 'double', + { + allowTemplateLiterals: false, + avoidEscape: false, + }, + ], + create(context, [option]) { + const rules = baseRule.create(context); + + const isModuleDeclaration = (node: TSESTree.Literal): boolean => { + return ( + !!node.parent && node.parent.type === AST_NODE_TYPES.TSModuleDeclaration + ); + }; + + const isTypeLiteral = (node: TSESTree.Literal): boolean => { + return !!node.parent && node.parent.type === AST_NODE_TYPES.TSLiteralType; + }; + + return { + Literal(node) { + if ( + option === 'backtick' && + (isModuleDeclaration(node) || isTypeLiteral(node)) + ) { + return; + } + + rules.Literal(node); + }, + + TemplateLiteral(node) { + rules.TemplateLiteral(node); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/quotes.test.ts b/packages/eslint-plugin/tests/rules/quotes.test.ts new file mode 100644 index 00000000000..bec1f4b7e98 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/quotes.test.ts @@ -0,0 +1,662 @@ +import rule from '../../src/rules/quotes'; +import { RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + ecmaFeatures: {}, + }, +}); + +/** + * the base rule `quotes` doesn't use a message id + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const useDoubleQuote: any = { + message: 'Strings must use doublequote.', +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const useSingleQuote: any = { + message: 'Strings must use singlequote.', +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const useBacktick: any = { + message: 'Strings must use backtick.', +}; + +ruleTester.run('quotes', rule, { + valid: [ + { + code: `declare module '*.html' {}`, + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: ` + class A { + public prop: IProps['prop']; + } + `, + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + + /** ESLint */ + `var foo = "bar";`, + { + code: `var foo = 'bar';`, + options: ['single'], + }, + { + code: `var foo = "bar";`, + options: ['double'], + }, + { + code: `var foo = 1;`, + options: ['single'], + }, + { + code: `var foo = 1;`, + options: ['double'], + }, + { + code: `var foo = "'";`, + options: [ + 'single', + { + avoidEscape: true, + }, + ], + }, + { + code: `var foo = '"';`, + options: [ + 'double', + { + avoidEscape: true, + }, + ], + }, + { + code: `var foo = <>Hello world;`, + options: ['single'], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: `var foo = <>Hello world;`, + options: ['double'], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: `var foo = <>Hello world;`, + options: [ + 'double', + { + avoidEscape: true, + }, + ], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: `var foo = <>Hello world;`, + options: ['backtick'], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: `var foo =
Hello world
;`, + options: ['single'], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: `var foo =
;`, + options: ['single'], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: `var foo =
Hello world
;`, + options: ['double'], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: `var foo =
Hello world
;`, + options: [ + 'double', + { + avoidEscape: true, + }, + ], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: 'var foo = `bar`;', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: "var foo = `bar 'baz'`;", + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = `bar "baz"`;', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: `var foo = 1;`, + options: ['backtick'], + }, + { + code: 'var foo = "a string containing `backtick` quotes";', + options: [ + 'backtick', + { + avoidEscape: true, + }, + ], + }, + { + code: `var foo =
;`, + options: ['backtick'], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: `var foo =
Hello world
;`, + options: ['backtick'], + parserOptions: { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + }, + }, + }, + + // Backticks are only okay if they have substitutions, contain a line break, or are tagged + { + code: 'var foo = `back\ntick`;', + options: ['single'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = `back\rtick`;', + options: ['single'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = `back\u2028tick`;', + options: ['single'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = `back\u2029tick`;', + options: ['single'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = `back\\\\\ntick`;', // 2 backslashes followed by a newline + options: ['single'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = `back\\\\\\\\\ntick`;', + options: ['single'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = `\n`;', + options: ['single'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = `back${x}tick`;', + options: ['double'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = tag`backtick`;', + options: ['double'], + parserOptions: { ecmaVersion: 6 }, + }, + + // Backticks are also okay if allowTemplateLiterals + { + code: "var foo = `bar 'foo' baz` + 'bar';", + options: [ + 'single', + { + allowTemplateLiterals: true, + }, + ], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: 'var foo = `bar \'foo\' baz` + "bar";', + options: [ + 'double', + { + allowTemplateLiterals: true, + }, + ], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: "var foo = `bar 'foo' baz` + `bar`;", + options: [ + 'backtick', + { + allowTemplateLiterals: true, + }, + ], + parserOptions: { ecmaVersion: 6 }, + }, + + // `backtick` should not warn the directive prologues. + { + code: '"use strict"; var foo = `backtick`;', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: '"use strict"; \'use strong\'; "use asm"; var foo = `backtick`;', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: + 'function foo() { "use strict"; "use strong"; "use asm"; var foo = `backtick`; }', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: + "(function() { 'use strict'; 'use strong'; 'use asm'; var foo = `backtick`; })();", + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: + '(() => { "use strict"; "use strong"; "use asm"; var foo = `backtick`; })();', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + + // `backtick` should not warn import/export sources. + { + code: `import "a"; import 'b';`, + options: ['backtick'], + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, + }, + { + code: `import a from "a"; import b from 'b';`, + options: ['backtick'], + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, + }, + { + code: `export * from "a"; export * from 'b';`, + options: ['backtick'], + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, + }, + + // `backtick` should not warn property/method names (not computed). + { + code: `var obj = {"key0": 0, 'key1': 1};`, + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: `class Foo { 'bar'(){} }`, + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + { + code: `class Foo { static ''(){} }`, + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + }, + ], + + invalid: [ + { + code: `var foo = 'bar';`, + output: `var foo = "bar";`, + errors: [useDoubleQuote], + }, + { + code: `var foo = "bar";`, + output: `var foo = 'bar';`, + options: ['single'], + errors: [useSingleQuote], + }, + { + code: 'var foo = `bar`;', + output: `var foo = 'bar';`, + options: ['single'], + parserOptions: { ecmaVersion: 6 }, + errors: [useSingleQuote], + }, + { + code: `var foo = 'don\\'t';`, + output: `var foo = "don't";`, + errors: [useDoubleQuote], + }, + { + code: `var msg = "Plugin '" + name + "' not found"`, + output: `var msg = 'Plugin \\'' + name + '\\' not found'`, + options: ['single'], + errors: [ + { ...useSingleQuote, column: 11 }, + { ...useSingleQuote, column: 31 }, + ], + }, + { + code: `var foo = 'bar';`, + output: `var foo = "bar";`, + options: ['double'], + errors: [useDoubleQuote], + }, + { + code: 'var foo = `bar`;', + output: `var foo = "bar";`, + options: ['double'], + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + { + code: `var foo = "bar";`, + output: `var foo = 'bar';`, + options: [ + 'single', + { + avoidEscape: true, + }, + ], + errors: [useSingleQuote], + }, + { + code: `var foo = 'bar';`, + output: `var foo = "bar";`, + options: [ + 'double', + { + avoidEscape: true, + }, + ], + errors: [useDoubleQuote], + }, + { + code: `var foo = '\\\\';`, + output: `var foo = "\\\\\";`, // eslint-disable-line no-useless-escape + options: [ + 'double', + { + avoidEscape: true, + }, + ], + errors: [useDoubleQuote], + }, + { + code: `var foo = "bar";`, + output: `var foo = 'bar';`, + options: [ + 'single', + { + allowTemplateLiterals: true, + }, + ], + errors: [useSingleQuote], + }, + { + code: `var foo = 'bar';`, + output: `var foo = "bar";`, + options: [ + 'double', + { + allowTemplateLiterals: true, + }, + ], + errors: [useDoubleQuote], + }, + { + code: `var foo = 'bar';`, + output: 'var foo = `bar`;', + options: ['backtick'], + parserOptions: { ecmaVersion: 2015 }, + errors: [useBacktick], + }, + { + code: "var foo = 'b${x}a$r';", + output: 'var foo = `b\\${x}a$r`;', + options: ['backtick'], + parserOptions: { ecmaVersion: 2015 }, + errors: [useBacktick], + }, + { + code: 'var foo = "bar";', + output: 'var foo = `bar`;', + options: ['backtick'], + parserOptions: { ecmaVersion: 2015 }, + errors: [useBacktick], + }, + { + code: `var foo = "bar";`, + output: 'var foo = `bar`;', + options: [ + 'backtick', + { + avoidEscape: true, + }, + ], + parserOptions: { ecmaVersion: 2015 }, + errors: [useBacktick], + }, + { + code: `var foo = 'bar';`, + output: 'var foo = `bar`;', + options: [ + 'backtick', + { + avoidEscape: true, + }, + ], + parserOptions: { ecmaVersion: 2015 }, + errors: [useBacktick], + }, + + // "use strict" is *not* a directive prologue in these statements so is subject to the rule + { + code: 'var foo = `backtick`; "use strict";', + output: 'var foo = `backtick`; `use strict`;', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + errors: [useBacktick], + }, + { + code: '{ "use strict"; var foo = `backtick`; }', + output: '{ `use strict`; var foo = `backtick`; }', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + errors: [useBacktick], + }, + { + code: 'if (1) { "use strict"; var foo = `backtick`; }', + output: 'if (1) { `use strict`; var foo = `backtick`; }', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + errors: [useBacktick], + }, + + // `backtick` should warn computed property names. + { + code: `var obj = {["key0"]: 0, ['key1']: 1};`, + output: 'var obj = {[`key0`]: 0, [`key1`]: 1};', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + errors: [useBacktick, useBacktick], + }, + { + code: `class Foo { ['a'](){} static ['b'](){} }`, + output: 'class Foo { [`a`](){} static [`b`](){} }', + options: ['backtick'], + parserOptions: { ecmaVersion: 6 }, + errors: [useBacktick, useBacktick], + }, + + // https://github.com/eslint/eslint/issues/7084 + { + code: `
`, + output: `
`, + options: [`single`], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + errors: [useSingleQuote], + }, + { + code: `
`, + output: `
`, + options: ['double'], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + errors: [useDoubleQuote], + }, + { + code: `
`, + output: '
', + options: ['backtick'], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2015, + }, + errors: [useBacktick], + }, + + // https://github.com/eslint/eslint/issues/7610 + { + code: '`use strict`;', + output: null, + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + { + code: 'function foo() { `use strict`; foo(); }', + output: null, + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + { + code: 'foo = function() { `use strict`; foo(); }', + output: null, + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + { + code: '() => { `use strict`; foo(); }', + output: null, + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + { + code: '() => { foo(); `use strict`; }', + output: `() => { foo(); "use strict"; }`, + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + { + code: 'foo(); `use strict`;', + output: 'foo(); "use strict";', + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + + // https://github.com/eslint/eslint/issues/7646 + { + code: 'var foo = `foo\\nbar`;', + output: 'var foo = "foo\\nbar";', + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + { + code: 'var foo = `foo\\\nbar`;', // 1 backslash followed by a newline + output: 'var foo = "foo\\\nbar";', + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + { + code: 'var foo = `foo\\\\\\\nbar`;', // 3 backslashes followed by a newline + output: 'var foo = "foo\\\\\\\nbar";', + parserOptions: { ecmaVersion: 6 }, + errors: [useDoubleQuote], + }, + { + code: '````', + output: '""``', + parserOptions: { ecmaVersion: 6 }, + errors: [{ ...useDoubleQuote, line: 1, column: 1 }], + }, + ], +}); diff --git a/packages/eslint-plugin/tools/generate-configs.ts b/packages/eslint-plugin/tools/generate-configs.ts index cda1b0771ea..b400ca05722 100644 --- a/packages/eslint-plugin/tools/generate-configs.ts +++ b/packages/eslint-plugin/tools/generate-configs.ts @@ -29,6 +29,7 @@ const BASE_RULES_TO_BE_OVERRIDDEN = new Set([ 'no-empty-function', 'no-extra-parens', 'no-magic-numbers', + 'quotes', 'no-unused-vars', 'no-use-before-define', 'no-useless-constructor', diff --git a/packages/eslint-plugin/typings/eslint-rules.d.ts b/packages/eslint-plugin/typings/eslint-rules.d.ts index 4e5fe5b6449..dcc37035c9d 100644 --- a/packages/eslint-plugin/typings/eslint-rules.d.ts +++ b/packages/eslint-plugin/typings/eslint-rules.d.ts @@ -461,3 +461,23 @@ declare module 'eslint/lib/rules/semi' { >; export = rule; } + +declare module 'eslint/lib/rules/quotes' { + import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + + const rule: TSESLint.RuleModule< + never, + [ + 'single' | 'double' | 'backtick', + { + allowTemplateLiterals?: boolean; + avoidEscape?: boolean; + }?, + ], + { + Literal(node: TSESTree.Literal): void; + TemplateLiteral(node: TSESTree.TemplateLiteral): void; + } + >; + export = rule; +}