diff --git a/changelog_unreleased/javascript/18351.md b/changelog_unreleased/javascript/18351.md new file mode 100644 index 000000000..4d33eefce --- /dev/null +++ b/changelog_unreleased/javascript/18351.md @@ -0,0 +1,13 @@ +#### Fix string print when switching quotes (#18351 by @fisker) + + +```jsx +// Input +console.log("A descriptor\\'s .kind must be \"method\" or \"field\".") + +// Prettier stable +console.log('A descriptor\\'s .kind must be "method" or "field".'); + +// Prettier main +console.log('A descriptor\\\'s .kind must be "method" or "field".'); +``` diff --git a/src/utils/make-string.js b/src/utils/make-string.js index d57c1650f..77dc7b5b8 100644 --- a/src/utils/make-string.js +++ b/src/utils/make-string.js @@ -1,7 +1,8 @@ /** @import {Quote} from "./get-preferred-quote.js" */ // Matches _any_ escape and unescaped quotes (both single and double). -const REGEX = /\\?(?["'])/gu; +// Matching `\` in first group, so we won't treat `\\"` as escaped quote. +const REGEX = /\\(["'\\])|(["'])/gu; /** * @param {string} rawText @@ -9,14 +10,25 @@ const REGEX = /\\?(?["'])/gu; * @returns {string} */ function makeString(rawText, enclosingQuote) { - return ( - enclosingQuote + - rawText.replaceAll( - REGEX, - (_, quote) => (quote === enclosingQuote ? "\\" : "") + quote, - ) + - enclosingQuote - ); + const otherQuote = enclosingQuote === '"' ? "'" : '"'; + + // Escape and unescape single and double quotes as needed to be able to + // enclose `rawText` with `enclosingQuote`. + const raw = rawText.replaceAll(REGEX, (match, escaped, unescaped) => { + // If we matched an escape, and the escaped character is a quote of the + // other type than we intend to enclose the string with, there's no need for + // it to be escaped, so return it _without_ the backslash. + if (escaped) { + return escaped === otherQuote ? otherQuote : match; + } + + // If we matched an unescaped quote and it is of the _same_ type as we + // intend to enclose the string with, it must be escaped, so return it with + // a backslash. + return unescaped === enclosingQuote ? "\\" + unescaped : unescaped; + }); + + return enclosingQuote + raw + enclosingQuote; } export default makeString; diff --git a/tests/format/js/quotes/switch-quote/__snapshots__/format.test.js.snap b/tests/format/js/quotes/switch-quote/__snapshots__/format.test.js.snap new file mode 100644 index 000000000..dac9ff62d --- /dev/null +++ b/tests/format/js/quotes/switch-quote/__snapshots__/format.test.js.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`switch-quote.js format 1`] = ` +====================================options===================================== +parsers: ["babel", "typescript", "flow"] +printWidth: 80 + | printWidth +=====================================input====================================== +console.log([ + // 18351 + "A descriptor\\\\'s .kind must be \\"method\\" or \\"field\\".", + + // Will switch to single quote + "0 backslash [ ' ] \\"\\"", + "0 backslash [ \\' ] \\"\\"", + "1 backslash [ \\\\' ] \\"\\"", + "1 backslash [ \\\\\\' ] \\"\\"", + + // Other charectors + "\\\\'\\"\\" [ \\ ]", + "\\\\'\\"\\" [ \\\\ ]", + "\\\\'\\"\\" [ \\\\\\ ]", + "\\\\'\\"\\" [ \\\\\\\\ ]", + "\\\\'\\"\\" [ \\n ]", + "\\\\'\\"\\" [ \\\\n ]", + "\\\\'\\"\\" [ \\\\\\n ]", + "\\\\'\\"\\" [ \\\\\\\\n ]", +]); + +=====================================output===================================== +console.log([ + // 18351 + 'A descriptor\\\\\\'s .kind must be "method" or "field".', + + // Will switch to single quote + '0 backslash [ \\' ] ""', + '0 backslash [ \\' ] ""', + '1 backslash [ \\\\\\' ] ""', + '1 backslash [ \\\\\\' ] ""', + + // Other charectors + '\\\\\\'"" [ \\ ]', + '\\\\\\'"" [ \\\\ ]', + '\\\\\\'"" [ \\\\\\ ]', + '\\\\\\'"" [ \\\\\\\\ ]', + '\\\\\\'"" [ \\n ]', + '\\\\\\'"" [ \\\\n ]', + '\\\\\\'"" [ \\\\\\n ]', + '\\\\\\'"" [ \\\\\\\\n ]', +]); + +================================================================================ +`; diff --git a/tests/format/js/quotes/switch-quote/format.test.js b/tests/format/js/quotes/switch-quote/format.test.js new file mode 100644 index 000000000..8adc66f60 --- /dev/null +++ b/tests/format/js/quotes/switch-quote/format.test.js @@ -0,0 +1 @@ +runFormatTest(import.meta, ["babel", "typescript", "flow"]); diff --git a/tests/format/js/quotes/switch-quote/switch-quote.js b/tests/format/js/quotes/switch-quote/switch-quote.js new file mode 100644 index 000000000..e359fa2c9 --- /dev/null +++ b/tests/format/js/quotes/switch-quote/switch-quote.js @@ -0,0 +1,20 @@ +console.log([ + // 18351 + "A descriptor\\'s .kind must be \"method\" or \"field\".", + + // Will switch to single quote + "0 backslash [ ' ] \"\"", + "0 backslash [ \' ] \"\"", + "1 backslash [ \\' ] \"\"", + "1 backslash [ \\\' ] \"\"", + + // Other charectors + "\\'\"\" [ \ ]", + "\\'\"\" [ \\ ]", + "\\'\"\" [ \\\ ]", + "\\'\"\" [ \\\\ ]", + "\\'\"\" [ \n ]", + "\\'\"\" [ \\n ]", + "\\'\"\" [ \\\n ]", + "\\'\"\" [ \\\\n ]", +]); diff --git a/tests/integration/__tests__/util-shared.js b/tests/integration/__tests__/util-shared.js index 5cf9f7aaa..3cb6eaf08 100644 --- a/tests/integration/__tests__/util-shared.js +++ b/tests/integration/__tests__/util-shared.js @@ -225,6 +225,39 @@ test("sharedUtil.makeString", () => { expect(makeString(`a${DOUBLE_QUOTE}`, SINGLE_QUOTE)).toBe( `${SINGLE_QUOTE}a${DOUBLE_QUOTE}${SINGLE_QUOTE}`, ); + expect(makeString(`a${DOUBLE_QUOTE}`, DOUBLE_QUOTE)).toBe( + `${DOUBLE_QUOTE}a\\${DOUBLE_QUOTE}${DOUBLE_QUOTE}`, + ); + + expect(makeString(`a\\${DOUBLE_QUOTE}\\${SINGLE_QUOTE}`, DOUBLE_QUOTE)).toBe( + `${DOUBLE_QUOTE}a\\${DOUBLE_QUOTE}${SINGLE_QUOTE}${DOUBLE_QUOTE}`, + ); + expect(makeString(`a\\${DOUBLE_QUOTE}\\${SINGLE_QUOTE}`, SINGLE_QUOTE)).toBe( + `${SINGLE_QUOTE}a${DOUBLE_QUOTE}\\${SINGLE_QUOTE}${SINGLE_QUOTE}`, + ); + + expect( + makeString(`a\\\\${DOUBLE_QUOTE}\\\\${SINGLE_QUOTE}`, DOUBLE_QUOTE), + ).toBe( + `${DOUBLE_QUOTE}a\\\\\\${DOUBLE_QUOTE}\\\\${SINGLE_QUOTE}${DOUBLE_QUOTE}`, + ); + expect( + makeString(`a\\\\${DOUBLE_QUOTE}\\\\${SINGLE_QUOTE}`, SINGLE_QUOTE), + ).toBe( + `${SINGLE_QUOTE}a\\\\${DOUBLE_QUOTE}\\\\\\${SINGLE_QUOTE}${SINGLE_QUOTE}`, + ); + + expect( + makeString(`a\\\\\\${DOUBLE_QUOTE}\\\\\\${SINGLE_QUOTE}`, DOUBLE_QUOTE), + ).toBe( + `${DOUBLE_QUOTE}a\\\\\\${DOUBLE_QUOTE}\\\\${SINGLE_QUOTE}${DOUBLE_QUOTE}`, + ); + expect( + makeString(`a\\\\\\${DOUBLE_QUOTE}\\\\\\${SINGLE_QUOTE}`, SINGLE_QUOTE), + ).toBe( + `${SINGLE_QUOTE}a\\\\${DOUBLE_QUOTE}\\\\\\${SINGLE_QUOTE}${SINGLE_QUOTE}`, + ); + expect( makeString( String.raw`\a`, diff --git a/tests/unit/make-string.js b/tests/unit/make-string.js new file mode 100644 index 000000000..b8f04bf66 --- /dev/null +++ b/tests/unit/make-string.js @@ -0,0 +1,52 @@ +import makeString from "../../src/utils/make-string.js"; + +const DOUBLE_QUOTE = '"'; +const SINGLE_QUOTE = "'"; + +test("makeString", () => { + expect(makeString("a", DOUBLE_QUOTE)).toBe(`${DOUBLE_QUOTE}a${DOUBLE_QUOTE}`); + expect(makeString("a", SINGLE_QUOTE)).toBe(`${SINGLE_QUOTE}a${SINGLE_QUOTE}`); + expect(makeString(`a${DOUBLE_QUOTE}`, DOUBLE_QUOTE)).toBe( + `${DOUBLE_QUOTE}a\\${DOUBLE_QUOTE}${DOUBLE_QUOTE}`, + ); + expect(makeString(`a${DOUBLE_QUOTE}`, SINGLE_QUOTE)).toBe( + `${SINGLE_QUOTE}a${DOUBLE_QUOTE}${SINGLE_QUOTE}`, + ); + expect(makeString(`a${DOUBLE_QUOTE}`, DOUBLE_QUOTE)).toBe( + `${DOUBLE_QUOTE}a\\${DOUBLE_QUOTE}${DOUBLE_QUOTE}`, + ); + + expect(makeString(`a\\${DOUBLE_QUOTE}\\${SINGLE_QUOTE}`, DOUBLE_QUOTE)).toBe( + `${DOUBLE_QUOTE}a\\${DOUBLE_QUOTE}${SINGLE_QUOTE}${DOUBLE_QUOTE}`, + ); + expect(makeString(`a\\${DOUBLE_QUOTE}\\${SINGLE_QUOTE}`, SINGLE_QUOTE)).toBe( + `${SINGLE_QUOTE}a${DOUBLE_QUOTE}\\${SINGLE_QUOTE}${SINGLE_QUOTE}`, + ); + + expect( + makeString(`a\\\\${DOUBLE_QUOTE}\\\\${SINGLE_QUOTE}`, DOUBLE_QUOTE), + ).toBe( + `${DOUBLE_QUOTE}a\\\\\\${DOUBLE_QUOTE}\\\\${SINGLE_QUOTE}${DOUBLE_QUOTE}`, + ); + expect( + makeString(`a\\\\${DOUBLE_QUOTE}\\\\${SINGLE_QUOTE}`, SINGLE_QUOTE), + ).toBe( + `${SINGLE_QUOTE}a\\\\${DOUBLE_QUOTE}\\\\\\${SINGLE_QUOTE}${SINGLE_QUOTE}`, + ); + + expect( + makeString(`a\\\\\\${DOUBLE_QUOTE}\\\\\\${SINGLE_QUOTE}`, DOUBLE_QUOTE), + ).toBe( + `${DOUBLE_QUOTE}a\\\\\\${DOUBLE_QUOTE}\\\\${SINGLE_QUOTE}${DOUBLE_QUOTE}`, + ); + expect( + makeString(`a\\\\\\${DOUBLE_QUOTE}\\\\\\${SINGLE_QUOTE}`, SINGLE_QUOTE), + ).toBe( + `${SINGLE_QUOTE}a\\\\${DOUBLE_QUOTE}\\\\\\${SINGLE_QUOTE}${SINGLE_QUOTE}`, + ); + + // Do not unescape `\a` + expect(makeString(String.raw`\a`, SINGLE_QUOTE)).toBe( + String.raw`${SINGLE_QUOTE}\a${SINGLE_QUOTE}`, + ); +});