From 0972a89c72be2bc79fc8308c93f0a98094c9cd8a Mon Sep 17 00:00:00 2001 From: fisker Cheung Date: Sun, 8 Mar 2020 19:00:32 +0800 Subject: [PATCH] Add `string-content` rule (#496) Co-authored-by: Sindre Sorhus --- docs/rules/string-content.md | 78 ++++++++ index.js | 1 + readme.md | 2 + rules/better-regex.js | 8 +- rules/string-content.js | 157 ++++++++++++++++ rules/utils/escape-template-element-raw.js | 6 + rules/utils/quote-string.js | 14 +- test/prefer-replace-all.js | 2 +- test/string-content.js | 204 +++++++++++++++++++++ 9 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 docs/rules/string-content.md create mode 100644 rules/string-content.js create mode 100644 rules/utils/escape-template-element-raw.js create mode 100644 test/string-content.js diff --git a/docs/rules/string-content.md b/docs/rules/string-content.md new file mode 100644 index 0000000000..d651937fba --- /dev/null +++ b/docs/rules/string-content.md @@ -0,0 +1,78 @@ +# Enforce better string content + +Enforce certain things about the contents of strings. For example, you can enforce using `’` instead of `'` to avoid escaping. Or you could block some words. The possibilities are endless. + +This rule is fixable. + +*It only reports one pattern per AST node at the time.* + +## Fail + +```js +const foo = 'Someone\'s coming!'; +``` + +## Pass + +```js +const foo = 'Someone’s coming!'; +``` + +## Options + +Type: `object` + +### patterns + +Type: `object` + +Extend [default patterns](#default-pattern). + +The example below: + +- Disables the default `'` → `’` replacement. +- Adds a custom `unicorn` → `🦄` replacement. +- Adds a custom `awesome` → `😎` replacement and a custom message. +- Adds a custom `cool` → `😎` replacement, but disables auto fix. + +```json +{ + "unicorn/string-content": [ + "error", + { + "patterns": { + "'": false, + "unicorn": "🦄", + "awesome": { + "suggest": "😎", + "message": "Please use `😎` instead of `awesome`." + }, + "cool": { + "suggest": "😎", + "fix": false + } + } + } + ] +} +``` + +The key of `patterns` is treated as a regex, so you must escape special characters. + +For example, if you want to enforce `...` → `…`: + +```json +{ + "patterns": { + "\\.\\.\\.": "…" + } +} +``` + +## Default Pattern + +```json +{ + "'": "’" +} +``` diff --git a/index.js b/index.js index dd0c36d826..b6b74f212d 100644 --- a/index.js +++ b/index.js @@ -64,6 +64,7 @@ module.exports = { 'unicorn/prefer-trim-start-end': 'error', 'unicorn/prefer-type-error': 'error', 'unicorn/prevent-abbreviations': 'error', + 'unicorn/string-content': 'off', 'unicorn/throw-new-error': 'error' } } diff --git a/readme.md b/readme.md index 7cc0a5a0ab..775c5a1014 100644 --- a/readme.md +++ b/readme.md @@ -79,6 +79,7 @@ Configure it in `package.json`. "unicorn/prefer-trim-start-end": "error", "unicorn/prefer-type-error": "error", "unicorn/prevent-abbreviations": "error", + "unicorn/string-content": "off", "unicorn/throw-new-error": "error" } } @@ -132,6 +133,7 @@ Configure it in `package.json`. - [prefer-trim-start-end](docs/rules/prefer-trim-start-end.md) - Prefer `String#trimStart()` / `String#trimEnd()` over `String#trimLeft()` / `String#trimRight()`. *(fixable)* - [prefer-type-error](docs/rules/prefer-type-error.md) - Enforce throwing `TypeError` in type checking conditions. *(fixable)* - [prevent-abbreviations](docs/rules/prevent-abbreviations.md) - Prevent abbreviations. *(partly fixable)* +- [string-content](docs/rules/string-content.md) - Enforce better string content. *(fixable)* - [throw-new-error](docs/rules/throw-new-error.md) - Require `new` when throwing an error. *(fixable)* ## Deprecated Rules diff --git a/rules/better-regex.js b/rules/better-regex.js index 2b4fbfd9ad..22b635ce6b 100644 --- a/rules/better-regex.js +++ b/rules/better-regex.js @@ -62,9 +62,6 @@ const create = context => { const newPattern = cleanRegexp(oldPattern, flags); if (oldPattern !== newPattern) { - // Escape backslash - const fixed = quoteString(newPattern.replace(/\\/g, '\\\\')); - context.report({ node, message, @@ -72,7 +69,10 @@ const create = context => { original: oldPattern, optimized: newPattern }, - fix: fixer => fixer.replaceText(patternNode, fixed) + fix: fixer => fixer.replaceText( + patternNode, + quoteString(newPattern) + ) }); } } diff --git a/rules/string-content.js b/rules/string-content.js new file mode 100644 index 0000000000..2fb7832921 --- /dev/null +++ b/rules/string-content.js @@ -0,0 +1,157 @@ +'use strict'; +const getDocumentationUrl = require('./utils/get-documentation-url'); +const quoteString = require('./utils/quote-string'); +const replaceTemplateElement = require('./utils/replace-template-element'); +const escapeTemplateElementRaw = require('./utils/escape-template-element-raw'); + +const defaultPatterns = { + '\'': '’' +}; + +const defaultMessage = 'Prefer `{{suggest}}` over `{{match}}`.'; + +function getReplacements(patterns) { + return Object.entries({ + ...defaultPatterns, + ...patterns + }) + .filter(([, options]) => options !== false) + .map(([match, options]) => { + if (typeof options === 'string') { + options = { + suggest: options + }; + } + + return { + match, + regex: new RegExp(match, 'gu'), + fix: true, + ...options + }; + }); +} + +const create = context => { + const {patterns} = { + patterns: {}, + ...context.options[0] + }; + const replacements = getReplacements(patterns); + + if (replacements.length === 0) { + return {}; + } + + return { + 'Literal, TemplateElement': node => { + const {type} = node; + + let string; + if (type === 'Literal') { + string = node.value; + if (typeof string !== 'string') { + return; + } + } else { + string = node.value.raw; + } + + if (!string) { + return; + } + + const replacement = replacements.find(({regex}) => regex.test(string)); + + if (!replacement) { + return; + } + + const {fix, message = defaultMessage, match, suggest} = replacement; + const problem = { + node, + message, + data: { + match, + suggest + } + }; + + if (!fix) { + context.report(problem); + return; + } + + const fixed = string.replace(replacement.regex, suggest); + if (type === 'Literal') { + problem.fix = fixer => fixer.replaceText( + node, + quoteString(fixed, node.raw[0]) + ); + } else { + problem.fix = fixer => replaceTemplateElement( + fixer, + node, + escapeTemplateElementRaw(fixed) + ); + } + + context.report(problem); + } + }; +}; + +const schema = [ + { + type: 'object', + properties: { + patterns: { + type: 'object', + additionalProperties: { + anyOf: [ + { + enum: [ + false + ] + }, + { + type: 'string' + }, + { + type: 'object', + required: [ + 'suggest' + ], + properties: { + suggest: { + type: 'string' + }, + fix: { + type: 'boolean' + // Default: true + }, + message: { + type: 'string' + // Default: '' + } + }, + additionalProperties: false + } + ] + }} + }, + additionalProperties: false + } +]; + +module.exports = { + create, + meta: { + type: 'suggestion', + docs: { + url: getDocumentationUrl(__filename) + }, + fixable: 'code', + schema + } +}; diff --git a/rules/utils/escape-template-element-raw.js b/rules/utils/escape-template-element-raw.js new file mode 100644 index 0000000000..2f9c7e8a39 --- /dev/null +++ b/rules/utils/escape-template-element-raw.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = string => string.replace( + /(?<=(?:^|[^\\])(?:\\\\)*)(?(?:`|\$(?={)))/g, + '\\$' +); diff --git a/rules/utils/quote-string.js b/rules/utils/quote-string.js index eee76eb6f3..40186423e6 100644 --- a/rules/utils/quote-string.js +++ b/rules/utils/quote-string.js @@ -1,9 +1,17 @@ 'use strict'; /** -Escape apostrophe and wrap the result in single quotes. +Escape string and wrap the result in quotes. @param {string} string - The string to be quoted. -@returns {string} - The quoted string. +@param {string} quote - The quote character. +@returns {string} - The quoted and escaped string. */ -module.exports = string => `'${string.replace(/'/g, '\\\'')}'`; +module.exports = (string, quote = '\'') => { + const escaped = string + .replace(/\\/g, '\\\\') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(new RegExp(quote, 'g'), `\\${quote}`); + return quote + escaped + quote; +}; diff --git a/test/prefer-replace-all.js b/test/prefer-replace-all.js index 21b4e11fdd..93c748fc62 100644 --- a/test/prefer-replace-all.js +++ b/test/prefer-replace-all.js @@ -86,7 +86,7 @@ ruleTester.run('prefer-replace-all', rule, { }, { code: 'foo.replace(/\\\\\\./g, bar)', - output: 'foo.replaceAll(\'\\.\', bar)', + output: 'foo.replaceAll(\'\\\\.\', bar)', errors: [error] } ] diff --git a/test/string-content.js b/test/string-content.js new file mode 100644 index 0000000000..40e2435a57 --- /dev/null +++ b/test/string-content.js @@ -0,0 +1,204 @@ +import test from 'ava'; +import avaRuleTester from 'eslint-ava-rule-tester'; +import rule from '../rules/string-content'; + +const ruleTester = avaRuleTester(test, { + env: { + es6: true + } +}); + +const patterns = { + unicorn: { + suggest: '🦄' + }, + awesome: { + suggest: '😎' + }, + quote: {suggest: '\'"'} +}; + +const createError = (match, suggest) => [ + { + message: `Prefer \`${suggest}\` over \`${match}\`.` + } +]; + +ruleTester.run('string-content', rule, { + valid: [ + // `Literal` string + 'const foo = \'🦄\';', + // Not `a string` + 'const foo = 0;', + // Not `Literal` + 'const foo = bar;', + // Disable default patterns + { + code: 'const foo = \'\\\'\'', + options: [{patterns: {'\'': false}}] + }, + /* eslint-disable no-template-curly-in-string */ + // `TemplateLiteral` + 'const foo = `🦄`', + // Should not escape + 'const foo = `\\`\\${1}`' + /* eslint-enable no-template-curly-in-string */ + ], + invalid: [ + // `Literal` string + { + code: 'const foo = \'\\\'\'', + output: 'const foo = \'’\'', + errors: createError('\'', '’') + }, + // Custom patterns + { + code: 'const foo = \'unicorn\'', + output: 'const foo = \'🦄\'', + options: [{patterns}], + errors: createError('unicorn', '🦄') + }, + // Custom patterns should not override default patterns + { + code: 'const foo = \'\\\'\'', + output: 'const foo = \'’\'', + options: [{patterns}], + errors: createError('\'', '’') + }, + // Escape single quote + { + code: 'const foo = \'quote\'', + output: 'const foo = \'\\\'"\'', + options: [{patterns}], + errors: createError('quote', '\'"') + }, + { + code: 'const foo = \'\\\\quote\\\\\'', + output: 'const foo = \'\\\\\\\'"\\\\\'', + options: [{patterns}], + errors: createError('quote', '\'"') + }, + // Escape double quote + { + code: 'const foo = "quote"', + output: 'const foo = "\'\\""', + options: [{patterns}], + errors: createError('quote', '\'"') + }, + { + code: 'const foo = "\\\\quote\\\\"', + output: 'const foo = "\\\\\'\\"\\\\"', + options: [{patterns}], + errors: createError('quote', '\'"') + }, + // Not fix + { + code: 'const foo = "unicorn"', + options: [{patterns: {unicorn: {...patterns.unicorn, fix: false}}}], + errors: createError('unicorn', '🦄') + }, + // Conflict patterns + { + code: 'const foo = "a"', + output: 'const foo = "A"', + options: [{patterns: {a: 'A', A: 'a'}}], + errors: createError('a', 'A') + }, + { + code: 'const foo = "A"', + output: 'const foo = "a"', + options: [{patterns: {a: 'A', A: 'a'}}], + errors: createError('A', 'a') + }, + { + code: 'const foo = "aA"', + output: 'const foo = "AA"', + options: [{patterns: {a: 'A', A: 'a'}}], + errors: createError('a', 'A') + }, + { + code: 'const foo = "aA"', + output: 'const foo = "aa"', + options: [{patterns: {A: 'a', a: 'A'}}], + errors: createError('A', 'a') + }, + + // Escaped pattern + { + code: 'const foo = "foo.bar"', + output: 'const foo = "_______"', + options: [{patterns: {'.': '_'}}], // <- not escaped + errors: createError('.', '_') + }, + { + code: 'const foo = "foo.bar"', + output: 'const foo = "foo_bar"', + options: [{patterns: {'\\.': '_'}}], // <- escaped + errors: createError('\\.', '_') + }, + + // Custom message + { + code: 'const foo = "foo"', + output: 'const foo = "bar"', + options: [{patterns: {foo: {suggest: 'bar', message: '`bar` is better than `foo`.'}}}], + errors: [{message: '`bar` is better than `foo`.'}] + }, + + // Should not crash on multiline string + // https://github.com/avajs/ava/blob/7f99aef61f3aed2389ca9407115ad4c9aecada92/test/assert.js#L1477 + { + code: 'const foo = "\'\\n"', + output: 'const foo = "’\\n"', + options: [{patterns}], + errors: createError('\'', '’') + }, + // https://github.com/sindresorhus/execa/blob/df08cfb2d849adb31dc764ca3ab5f29e5b191d50/test/error.js#L20 + { + code: 'const foo = "\'\\r"', + output: 'const foo = "’\\r"', + options: [{patterns}], + errors: createError('\'', '’') + }, + + /* eslint-disable no-template-curly-in-string */ + // `TemplateLiteral` + { + code: 'const foo = `\'`', + output: 'const foo = `’`', + errors: createError('\'', '’') + }, + // `TemplateElement` position + { + code: 'const foo = `\'${foo}\'${foo}\'`', + output: 'const foo = `’${foo}’${foo}’`', + errors: Array.from({length: 3}).fill(createError('\'', '’')) + }, + // Escape + { + code: 'const foo = `foo_foo`', + output: 'const foo = `bar\\`bar_bar\\`bar`', + options: [{patterns: {foo: 'bar`bar'}}], + errors: createError('foo', 'bar`bar') + }, + { + code: 'const foo = `foo_foo`', + output: 'const foo = `\\${bar}_\\${bar}`', + options: [{patterns: {foo: '${bar}'}}], + errors: createError('foo', '${bar}') + }, + { + code: 'const foo = `$foo`', // <-- not escaped $ + output: 'const foo = `\\${bar}`', + options: [{patterns: {foo: '{bar}'}}], + errors: createError('foo', '{bar}') + }, + { + code: 'const foo = `\\\\$foo`', // <-- escaped $ + output: 'const foo = `\\\\\\${bar}`', + options: [{patterns: {foo: '{bar}'}}], + errors: createError('foo', '{bar}') + } + /* eslint-enable no-template-curly-in-string */ + ] +});