diff --git a/docs/rules/no-nonoctal-decimal-escape.md b/docs/rules/no-nonoctal-decimal-escape.md new file mode 100644 index 00000000000..930b555607a --- /dev/null +++ b/docs/rules/no-nonoctal-decimal-escape.md @@ -0,0 +1,62 @@ +# Disallow `\8` and `\9` escape sequences in string literals (no-nonoctal-decimal-escape) + +Although not being specified in the language until ECMAScript 2021, `\8` and `\9` escape sequences in string literals were allowed in most JavaScript engines, and treated as "useless" escapes: + +```js +"\8" === "8"; // true +"\9" === "9"; // true +``` + +Since ECMAScript 2021, these escape sequences are specified as [non-octal decimal escape sequences](https://tc39.es/ecma262/#prod-annexB-NonOctalDecimalEscapeSequence), retaining the same behavior. + +Nevertheless, the ECMAScript specification treats `\8` and `\9` in string literals as a legacy feature. This syntax is optional if the ECMAScript host is not a web browser. Browsers still have to support it, but only in non-strict mode. + +Regardless of your targeted environment, these escape sequences shouldn't be used when writing new code. + +## Rule Details + +This rule disallows `\8` and `\9` escape sequences in string literals. + +Examples of **incorrect** code for this rule: + +```js +/*eslint no-nonoctal-decimal-escape: "error"*/ + +"\8"; + +"\9"; + +var foo = "w\8less"; + +var bar = "December 1\9"; + +var baz = "Don't use \8 and \9 escapes."; + +var quux = "\0\8"; +``` + +Examples of **correct** code for this rule: + +```js +/*eslint no-nonoctal-decimal-escape: "error"*/ + +"8"; + +"9"; + +var foo = "w8less"; + +var bar = "December 19"; + +var baz = "Don't use \\8 and \\9 escapes."; + +var quux = "\0\u0038"; +``` + +## Further Reading + +* [NonOctalDecimalEscapeSequence](https://tc39.es/ecma262/#prod-annexB-NonOctalDecimalEscapeSequence) in ECMAScript specification + +## Related Rules + +* [no-octal-escape](no-octal-escape.md) diff --git a/lib/rules/index.js b/lib/rules/index.js index 3cf26e51bc8..84f3480df26 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -169,6 +169,7 @@ module.exports = new LazyLoadingRuleMap(Object.entries({ "no-new-require": () => require("./no-new-require"), "no-new-symbol": () => require("./no-new-symbol"), "no-new-wrappers": () => require("./no-new-wrappers"), + "no-nonoctal-decimal-escape": () => require("./no-nonoctal-decimal-escape"), "no-obj-calls": () => require("./no-obj-calls"), "no-octal": () => require("./no-octal"), "no-octal-escape": () => require("./no-octal-escape"), diff --git a/lib/rules/no-nonoctal-decimal-escape.js b/lib/rules/no-nonoctal-decimal-escape.js new file mode 100644 index 00000000000..a4b46d9591f --- /dev/null +++ b/lib/rules/no-nonoctal-decimal-escape.js @@ -0,0 +1,147 @@ +/** + * @fileoverview Rule to disallow `\8` and `\9` escape sequences in string literals. + * @author Milos Djermanovic + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +const QUICK_TEST_REGEX = /\\[89]/u; + +/** + * Returns unicode escape sequence that represents the given character. + * @param {string} character A single code unit. + * @returns {string} "\uXXXX" sequence. + */ +function getUnicodeEscape(character) { + return `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`; +} + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +module.exports = { + meta: { + type: "suggestion", + + docs: { + description: "disallow `\\8` and `\\9` escape sequences in string literals", + category: "Best Practices", + recommended: false, + url: "https://eslint.org/docs/rules/no-nonoctal-decimal-escape", + suggestion: true + }, + + schema: [], + + messages: { + decimalEscape: "Don't use '{{decimalEscape}}' escape sequence.", + + // suggestions + refactor: "Replace '{{original}}' with '{{replacement}}'. This maintains the current functionality.", + escapeBackslash: "Replace '{{original}}' with '{{replacement}}' to include the actual backslash character." + } + }, + + create(context) { + const sourceCode = context.getSourceCode(); + + /** + * Creates a new Suggestion object. + * @param {string} messageId "refactor" or "escapeBackslash". + * @param {int[]} range The range to replace. + * @param {string} replacement New text for the range. + * @returns {Object} Suggestion + */ + function createSuggestion(messageId, range, replacement) { + return { + messageId, + data: { + original: sourceCode.getText().slice(...range), + replacement + }, + fix(fixer) { + return fixer.replaceTextRange(range, replacement); + } + }; + } + + return { + Literal(node) { + if (typeof node.value !== "string") { + return; + } + + if (!QUICK_TEST_REGEX.test(node.raw)) { + return; + } + + const regex = /(?:[^\\]|(?\\.))*?(?\\[89])/suy; + let match; + + while ((match = regex.exec(node.raw))) { + const { previousEscape, decimalEscape } = match.groups; + const decimalEscapeRangeEnd = node.range[0] + match.index + match[0].length; + const decimalEscapeRangeStart = decimalEscapeRangeEnd - decimalEscape.length; + const decimalEscapeRange = [decimalEscapeRangeStart, decimalEscapeRangeEnd]; + const suggest = []; + + // When `regex` is matched, `previousEscape` can only capture characters adjacent to `decimalEscape` + if (previousEscape === "\\0") { + + /* + * Now we have a NULL escape "\0" immediately followed by a decimal escape, e.g.: "\0\8". + * Fixing this to "\08" would turn "\0" into a legacy octal escape. To avoid producing + * an octal escape while fixing a decimal escape, we provide different suggestions. + */ + suggest.push( + createSuggestion( // "\0\8" -> "\u00008" + "refactor", + [decimalEscapeRangeStart - previousEscape.length, decimalEscapeRangeEnd], + `${getUnicodeEscape("\0")}${decimalEscape[1]}` + ), + createSuggestion( // "\8" -> "\u0038" + "refactor", + decimalEscapeRange, + getUnicodeEscape(decimalEscape[1]) + ) + ); + } else { + suggest.push( + createSuggestion( // "\8" -> "8" + "refactor", + decimalEscapeRange, + decimalEscape[1] + ) + ); + } + + suggest.push( + createSuggestion( // "\8" -> "\\8" + "escapeBackslash", + decimalEscapeRange, + `\\${decimalEscape}` + ) + ); + + context.report({ + node, + loc: { + start: sourceCode.getLocFromIndex(decimalEscapeRangeStart), + end: sourceCode.getLocFromIndex(decimalEscapeRangeEnd) + }, + messageId: "decimalEscape", + data: { + decimalEscape + }, + suggest + }); + } + } + }; + } +}; diff --git a/tests/lib/rules/no-nonoctal-decimal-escape.js b/tests/lib/rules/no-nonoctal-decimal-escape.js new file mode 100644 index 00000000000..ecaf5109fc0 --- /dev/null +++ b/tests/lib/rules/no-nonoctal-decimal-escape.js @@ -0,0 +1,488 @@ +/** + * @fileoverview Tests for the no-nonoctal-decimal-escape rule. + * @author Milos Djermanovic + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require("../../../lib/rules/no-nonoctal-decimal-escape"), + { RuleTester } = require("../../../lib/rule-tester"); + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +/** + * Creates an error object. + * @param {string} decimalEscape Reported escape sequence. + * @param {number} column Reported column. + * @param {string} refactorOutput Output for "refactor" suggestion. + * @param {string} escapeBackslashOutput Output for "escapeBackslash" suggestion. + * @returns {Object} The error object. + */ +function error(decimalEscape, column, refactorOutput, escapeBackslashOutput) { + return { + messageId: "decimalEscape", + data: { decimalEscape }, + type: "Literal", + line: 1, + column, + endLine: 1, + endColumn: column + 2, + suggestions: [ + { + messageId: "refactor", + data: { + original: decimalEscape, + replacement: decimalEscape[1] + }, + output: refactorOutput + }, + { + messageId: "escapeBackslash", + data: { + original: decimalEscape, + replacement: `\\${decimalEscape}` + }, + output: escapeBackslashOutput + } + ] + }; +} + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester(); + +ruleTester.run("no-nonoctal-decimal-escape", rule, { + valid: [ + "8", + "var \\u8888", + "/\\8/", + "''", + "'foo'", + "'8'", + "'9'", + "'foo8'", + "'foo9bar'", + "'\\ '", + "'\\\\'", + "'\\a'", + "'\\n'", + "'\\0'", + "'\\1'", + "'\\7'", + "'\\01'", + "'\\08'", + "'\\19'", + "'\\t9'", + "'\\👍8'", + "'\\\\8'", + "'\\\\9'", + "'\\\\8\\\\9'", + "'\\\\ \\\\8'", + "'\\\\\\\\9'", + "'\\\\9bar'", + "'a\\\\8'", + "'foo\\\\8'", + "'foo\\\\8bar'", + "'9\\\\9'", + "'n\\n8'", + "'n\\nn\\n8'", + "'\\1.8'", + "'\\1\\28'", + "'\\x99'", + "'\\\\\\x38'", + "\\u99999", + "'\\\n8'", + "'\\\n\\\\9'" + ], + + invalid: [ + { + code: "'\\8'", + errors: [error("\\8", 2, "'8'", "'\\\\8'")] + }, + { + code: "'\\9'", + errors: [error("\\9", 2, "'9'", "'\\\\9'")] + }, + { + code: '"\\8"', + errors: [error("\\8", 2, '"8"', '"\\\\8"')] + }, + { + code: "'f\\9'", + errors: [error("\\9", 3, "'f9'", "'f\\\\9'")] + }, + { + code: "'fo\\9'", + errors: [error("\\9", 4, "'fo9'", "'fo\\\\9'")] + }, + { + code: "'foo\\9'", + errors: [error("\\9", 5, "'foo9'", "'foo\\\\9'")] + }, + { + code: "'foo\\8bar'", + errors: [error("\\8", 5, "'foo8bar'", "'foo\\\\8bar'")] + }, + { + code: "'👍\\8'", + errors: [error("\\8", 4, "'👍8'", "'👍\\\\8'")] + }, + { + code: "'\\\\\\8'", + errors: [error("\\8", 4, "'\\\\8'", "'\\\\\\\\8'")] + }, + { + code: "'\\\\\\\\\\9'", + errors: [error("\\9", 6, "'\\\\\\\\9'", "'\\\\\\\\\\\\9'")] + }, + { + code: "'foo\\\\\\8'", + errors: [error("\\8", 7, "'foo\\\\8'", "'foo\\\\\\\\8'")] + }, + { + code: "'\\ \\8'", + errors: [error("\\8", 4, "'\\ 8'", "'\\ \\\\8'")] + }, + { + code: "'\\1\\9'", + errors: [error("\\9", 4, "'\\19'", "'\\1\\\\9'")] + }, + { + code: "'foo\\1\\9'", + errors: [error("\\9", 7, "'foo\\19'", "'foo\\1\\\\9'")] + }, + { + code: "'\\n\\n\\8\\n'", + errors: [error("\\8", 6, "'\\n\\n8\\n'", "'\\n\\n\\\\8\\n'")] + }, + { + code: "'\\n.\\n\\8\\n'", + errors: [error("\\8", 7, "'\\n.\\n8\\n'", "'\\n.\\n\\\\8\\n'")] + }, + { + code: "'\\n.\\nn\\8\\n'", + errors: [error("\\8", 8, "'\\n.\\nn8\\n'", "'\\n.\\nn\\\\8\\n'")] + }, + { + code: "'\\👍\\8'", + errors: [error("\\8", 5, "'\\👍8'", "'\\👍\\\\8'")] + }, + { + code: "'\\\\8\\9'", + errors: [error("\\9", 5, "'\\\\89'", "'\\\\8\\\\9'")] + }, + { + code: "'\\8\\\\9'", + errors: [error("\\8", 2, "'8\\\\9'", "'\\\\8\\\\9'")] + }, + { + code: "'\\8 \\\\9'", + errors: [error("\\8", 2, "'8 \\\\9'", "'\\\\8 \\\\9'")] + }, + + // multiple errors in the same string + { + code: "'\\8\\8'", + errors: [ + error("\\8", 2, "'8\\8'", "'\\\\8\\8'"), + error("\\8", 4, "'\\88'", "'\\8\\\\8'") + ] + }, + { + code: "'\\9\\8'", + errors: [ + error("\\9", 2, "'9\\8'", "'\\\\9\\8'"), + error("\\8", 4, "'\\98'", "'\\9\\\\8'") + ] + }, + { + code: "'foo\\8bar\\9baz'", + errors: [ + error("\\8", 5, "'foo8bar\\9baz'", "'foo\\\\8bar\\9baz'"), + error("\\9", 10, "'foo\\8bar9baz'", "'foo\\8bar\\\\9baz'") + ] + }, + { + code: "'\\8\\1\\9'", + errors: [ + error("\\8", 2, "'8\\1\\9'", "'\\\\8\\1\\9'"), + error("\\9", 6, "'\\8\\19'", "'\\8\\1\\\\9'") + ] + }, + { + code: "'\\9\\n9\\\\9\\9'", + errors: [ + error("\\9", 2, "'9\\n9\\\\9\\9'", "'\\\\9\\n9\\\\9\\9'"), + error("\\9", 10, "'\\9\\n9\\\\99'", "'\\9\\n9\\\\9\\\\9'") + ] + }, + { + code: "'\\8\\\\\\9'", + errors: [ + error("\\8", 2, "'8\\\\\\9'", "'\\\\8\\\\\\9'"), + error("\\9", 6, "'\\8\\\\9'", "'\\8\\\\\\\\9'") + ] + }, + + // multiple strings + { + code: "var foo = '\\8'; bar('\\9')", + errors: [ + error("\\8", 12, "var foo = '8'; bar('\\9')", "var foo = '\\\\8'; bar('\\9')"), + error("\\9", 22, "var foo = '\\8'; bar('9')", "var foo = '\\8'; bar('\\\\9')") + ] + }, + + // test reported line + { + code: "var foo = '8'\n bar = '\\9'", + errors: [{ + ...error("\\9", 10, "var foo = '8'\n bar = '9'", "var foo = '8'\n bar = '\\\\9'"), + line: 2, + endLine: 2 + }] + }, + + // multiline strings + { + code: "'\\\n\\8'", + errors: [{ + ...error("\\8", 1, "'\\\n8'", "'\\\n\\\\8'"), + line: 2, + endLine: 2 + }] + }, + { + code: "'\\\r\n\\9'", + errors: [{ + ...error("\\9", 1, "'\\\r\n9'", "'\\\r\n\\\\9'"), + line: 2, + endLine: 2 + }] + }, + { + code: "'\\\\\\\n\\8'", + errors: [{ + ...error("\\8", 1, "'\\\\\\\n8'", "'\\\\\\\n\\\\8'"), + line: 2, + endLine: 2 + }] + }, + { + code: "'foo\\\nbar\\9baz'", + errors: [{ + ...error("\\9", 4, "'foo\\\nbar9baz'", "'foo\\\nbar\\\\9baz'"), + line: 2, + endLine: 2 + }] + }, + + // adjacent NULL escape + { + code: "'\\0\\8'", + errors: [{ + ...error("\\8", 4), + suggestions: [ + { + messageId: "refactor", + data: { + original: "\\0\\8", + replacement: "\\u00008" + }, + output: "'\\u00008'" + }, + { + messageId: "refactor", + data: { + original: "\\8", + replacement: "\\u0038" + }, + output: "'\\0\\u0038'" + }, + { + messageId: "escapeBackslash", + data: { + original: "\\8", + replacement: "\\\\8" + }, + output: "'\\0\\\\8'" + } + ] + }] + }, + { + code: "'foo\\0\\9bar'", + errors: [{ + ...error("\\9", 7), + suggestions: [ + { + messageId: "refactor", + data: { + original: "\\0\\9", + replacement: "\\u00009" + }, + output: "'foo\\u00009bar'" + }, + { + messageId: "refactor", + data: { + original: "\\9", + replacement: "\\u0039" + }, + output: "'foo\\0\\u0039bar'" + }, + { + messageId: "escapeBackslash", + data: { + original: "\\9", + replacement: "\\\\9" + }, + output: "'foo\\0\\\\9bar'" + } + ] + }] + }, + { + code: "'\\1\\0\\8'", + errors: [{ + ...error("\\8", 6), + suggestions: [ + { + messageId: "refactor", + data: { + original: "\\0\\8", + replacement: "\\u00008" + }, + output: "'\\1\\u00008'" + }, + { + messageId: "refactor", + data: { + original: "\\8", + replacement: "\\u0038" + }, + output: "'\\1\\0\\u0038'" + }, + { + messageId: "escapeBackslash", + data: { + original: "\\8", + replacement: "\\\\8" + }, + output: "'\\1\\0\\\\8'" + } + ] + }] + }, + { + code: "'\\0\\8\\9'", + errors: [ + { + ...error("\\8", 4), + suggestions: [ + { + messageId: "refactor", + data: { + original: "\\0\\8", + replacement: "\\u00008" + }, + output: "'\\u00008\\9'" + }, + { + messageId: "refactor", + data: { + original: "\\8", + replacement: "\\u0038" + }, + output: "'\\0\\u0038\\9'" + }, + { + messageId: "escapeBackslash", + data: { + original: "\\8", + replacement: "\\\\8" + }, + output: "'\\0\\\\8\\9'" + } + ] + }, + error("\\9", 6, "'\\0\\89'", "'\\0\\8\\\\9'") + ] + }, + { + code: "'\\8\\0\\9'", + errors: [ + error("\\8", 2, "'8\\0\\9'", "'\\\\8\\0\\9'"), + { + ...error("\\9", 6), + suggestions: [ + { + messageId: "refactor", + data: { + original: "\\0\\9", + replacement: "\\u00009" + }, + output: "'\\8\\u00009'" + }, + { + messageId: "refactor", + data: { + original: "\\9", + replacement: "\\u0039" + }, + output: "'\\8\\0\\u0039'" + }, + { + messageId: "escapeBackslash", + data: { + original: "\\9", + replacement: "\\\\9" + }, + output: "'\\8\\0\\\\9'" + } + ] + } + ] + }, + + // not an adjacent NULL escape + { + code: "'0\\8'", + errors: [error("\\8", 3, "'08'", "'0\\\\8'")] + }, + { + code: "'\\\\0\\8'", + errors: [error("\\8", 5, "'\\\\08'", "'\\\\0\\\\8'")] + }, + { + code: "'\\0 \\8'", + errors: [error("\\8", 5, "'\\0 8'", "'\\0 \\\\8'")] + }, + { + code: "'\\01\\8'", + errors: [error("\\8", 5, "'\\018'", "'\\01\\\\8'")] + }, + { + code: "'\\0\\1\\8'", + errors: [error("\\8", 6, "'\\0\\18'", "'\\0\\1\\\\8'")] + }, + { + code: "'\\0\\\n\\8'", + errors: [{ + ...error("\\8", 1, "'\\0\\\n8'", "'\\0\\\n\\\\8'"), + line: 2, + endLine: 2 + }] + } + ] +}); diff --git a/tools/rule-types.json b/tools/rule-types.json index 84700de70d0..9e548287bf9 100644 --- a/tools/rule-types.json +++ b/tools/rule-types.json @@ -156,6 +156,7 @@ "no-new-require": "suggestion", "no-new-symbol": "problem", "no-new-wrappers": "suggestion", + "no-nonoctal-decimal-escape": "suggestion", "no-obj-calls": "problem", "no-octal": "suggestion", "no-octal-escape": "suggestion",