diff --git a/changelog_unreleased/api/13532-2.md b/changelog_unreleased/api/13532-2.md new file mode 100644 index 000000000..e76572f6e --- /dev/null +++ b/changelog_unreleased/api/13532-2.md @@ -0,0 +1,3 @@ +#### [BREAKING] Allow using arbitrary truthy values for `label` docs (#13532 by @thorn0) + +The `label` doc builder has been changed. See the [documentation](https://github.com/prettier/prettier/blob/main/commands.md#label). diff --git a/changelog_unreleased/javascript/13532.md b/changelog_unreleased/javascript/13532.md new file mode 100644 index 000000000..394f32e59 --- /dev/null +++ b/changelog_unreleased/javascript/13532.md @@ -0,0 +1,32 @@ +#### Fix unstable template literals with embedded languages (#13532 by @thorn0) + +If a template literal with embedded syntax is the only argument of a call or the body of an arrow function and has leading and trailing whitespace, it won't be printed on a new line. + + +```js +// Input +foo(/* HTML */ ` bar `); + +// Prettier stable (first output) +foo( + /* HTML */ ` + + bar + + ` +); + +// Prettier stable (second output) +foo(/* HTML */ ` + + bar + +`); + +// Prettier main (first output) +foo(/* HTML */ ` + + bar + +`); +``` diff --git a/commands.md b/commands.md index e10722561..fef9b7ca9 100644 --- a/commands.md +++ b/commands.md @@ -276,13 +276,15 @@ It doesn't make sense to apply `indentIfBreak` to the current group because "ind _Added in v2.3.0_ ```ts -declare function label(label: string, doc: Doc): Doc; +declare function label(label: any, doc: Doc): Doc; ``` -Mark a doc with a string label. This doesn't affect how the doc is printed, but can be useful for heuristics based on doc introspection. +Mark a doc with an arbitrary truthy value. This doesn't affect how the doc is printed, but can be useful for heuristics based on doc introspection. E.g., to decide how to print an assignment expression, we might want to know whether its right-hand side has been printed as a method call chain, not as a plain function call. If the method chain printing code uses `label` to mark its result, checking that condition can be as easy as `rightHandSideDoc.label === 'method-chain'`. +If the `label` argument is falsy, `doc` is returned as is, without wrapping. + ### `hardlineWithoutBreakParent` and `literallineWithoutBreakParent` _Added in v2.3.0_ diff --git a/scripts/lint-changelog.mjs b/scripts/lint-changelog.mjs index b715e561d..e94cf9035 100644 --- a/scripts/lint-changelog.mjs +++ b/scripts/lint-changelog.mjs @@ -62,7 +62,7 @@ for (const category of CHANGELOG_CATEGORIES) { continue; } - const match = prFile.match(/^(\d{4,})\.md$/); + const match = prFile.match(/^(\d{4,})(-\d+)?\.md$/); const displayPath = `${CHANGELOG_DIR}/${category}/${prFile}`; if (!match) { @@ -73,16 +73,16 @@ for (const category of CHANGELOG_CATEGORIES) { } const [, prNumber] = match; const prLink = `#${prNumber}`; - if (checkedFiles.has(prNumber)) { + if (checkedFiles.has(prFile)) { showErrorMessage( outdent` Duplicate files for ${prLink} found. - - ${checkedFiles.get(prNumber)} + - ${checkedFiles.get(prFile)} - ${displayPath} ` ); } - checkedFiles.set(prNumber, displayPath); + checkedFiles.set(prFile, displayPath); const content = fs.readFileSync( path.join(CHANGELOG_DIR, category, prFile), "utf8" diff --git a/src/document/builders.js b/src/document/builders.js index 33132d644..12a3624d1 100644 --- a/src/document/builders.js +++ b/src/document/builders.js @@ -250,8 +250,13 @@ function addAlignmentToDoc(doc, size, tabWidth) { return aligned; } +/** + * Mark a doc with an arbitrary truthy value. This doesn't affect how the doc is printed, but can be useful for heuristics based on doc introspection. + * @param {any} label If falsy, the `contents` doc is returned as is. + * @param {Doc} contents + */ function label(label, contents) { - return { type: DOC_TYPE_LABEL, label, contents }; + return label ? { type: DOC_TYPE_LABEL, label, contents } : contents; } // TODO: Remove this in v4 diff --git a/src/language-js/embed.js b/src/language-js/embed.js index 5cda83507..be8892041 100644 --- a/src/language-js/embed.js +++ b/src/language-js/embed.js @@ -1,3 +1,4 @@ +import { label } from "../document/builders.js"; import { hasComment, CommentCheckFlags, @@ -28,7 +29,10 @@ function embed(path) { return "``"; } - return embedder; + return async (...args) => { + const doc = await embedder(...args); + return doc && label({ embed: true, ...doc.label }, doc); + }; } } diff --git a/src/language-js/embed/html.js b/src/language-js/embed/html.js index df10e7972..d65953770 100644 --- a/src/language-js/embed/html.js +++ b/src/language-js/embed/html.js @@ -1,4 +1,10 @@ -import { indent, line, hardline, group } from "../../document/builders.js"; +import { + indent, + line, + hardline, + group, + label, +} from "../../document/builders.js"; import { mapDoc } from "../../document/utils.js"; import { printTemplateExpressions, @@ -77,13 +83,16 @@ async function embedHtmlLike(parser, textToDoc, print, path, options) { return group(["`", indent([linebreak, group(contentDoc)]), linebreak, "`"]); } - return group([ - "`", - leadingWhitespace, - topLevelCount > 1 ? indent(group(contentDoc)) : group(contentDoc), - trailingWhitespace, - "`", - ]); + return label( + { hug: false }, + group([ + "`", + leadingWhitespace, + topLevelCount > 1 ? indent(group(contentDoc)) : group(contentDoc), + trailingWhitespace, + "`", + ]) + ); } export const embedHtml = embedHtmlLike.bind(undefined, "html"); diff --git a/src/language-js/print/assignment.js b/src/language-js/print/assignment.js index fede36338..47cd80733 100644 --- a/src/language-js/print/assignment.js +++ b/src/language-js/print/assignment.js @@ -347,7 +347,7 @@ function isPoorlyBreakableMemberOrCallChain( if (isCallExpression(node)) { /** @type {any} TODO */ const doc = printCallExpression(path, options, print); - if (doc.label === "member-chain") { + if (doc.label?.memberChain) { return false; } diff --git a/src/language-js/print/call-arguments.js b/src/language-js/print/call-arguments.js index b68c7dd61..98cc718cf 100644 --- a/src/language-js/print/call-arguments.js +++ b/src/language-js/print/call-arguments.js @@ -56,20 +56,19 @@ function printCallArguments(path, options, print) { let anyArgEmptyLine = false; const lastArgIndex = args.length - 1; const printedArguments = []; - iterateCallArgumentsPath(path, (argPath, index) => { - const arg = argPath.node; - const parts = [print()]; + iterateCallArgumentsPath(path, ({ node: arg }, index) => { + let argDoc = print(); if (index === lastArgIndex) { // do nothing } else if (isNextLineEmpty(arg, options)) { anyArgEmptyLine = true; - parts.push(",", hardline, hardline); + argDoc = [argDoc, ",", hardline, hardline]; } else { - parts.push(",", line); + argDoc = [argDoc, ",", line]; } - printedArguments.push(parts); + printedArguments.push(argDoc); }); // Dynamic imports cannot have trailing commas @@ -128,7 +127,7 @@ function printCallArguments(path, options, print) { ]); } - if (shouldExpandLastArg(args, options)) { + if (shouldExpandLastArg(args, printedArguments, options)) { const headArgs = printedArguments.slice(0, -1); if (headArgs.some(willBreak)) { return allArgsBrokenOut(); @@ -221,8 +220,16 @@ function couldExpandArg(arg, arrowChainRecursion = false) { ); } -function shouldExpandLastArg(args, options) { +function shouldExpandLastArg(args, argDocs, options) { const lastArg = args.at(-1); + + if (args.length === 1) { + const lastArgDoc = argDocs.at(-1); + if (lastArgDoc.label?.embed && lastArgDoc.label?.hug !== false) { + return true; + } + } + const penultimateArg = args.at(-2); return ( !hasComment(lastArg, CommentCheckFlags.Leading) && diff --git a/src/language-js/print/call-expression.js b/src/language-js/print/call-expression.js index c798dce40..9d50e3bf7 100644 --- a/src/language-js/print/call-expression.js +++ b/src/language-js/print/call-expression.js @@ -15,40 +15,45 @@ import printCallArguments from "./call-arguments.js"; import { printOptionalToken, printFunctionTypeParameters } from "./misc.js"; function printCallExpression(path, options, print) { - const { node } = path; - const parentNode = path.getParentNode(); + const { node, parent } = path; const isNew = node.type === "NewExpression"; const isDynamicImport = node.type === "ImportExpression"; const optional = printOptionalToken(path); const args = getCallArguments(node); + + const isTemplateLiteralSingleArg = + args.length === 1 && isTemplateOnItsOwnLine(args[0], options.originalText); + if ( + isTemplateLiteralSingleArg || // Dangling comments are not handled, all these special cases should have arguments #9668 - args.length > 0 && - // We want to keep CommonJS- and AMD-style require calls, and AMD-style - // define calls, as a unit. - // e.g. `define(["some/lib"], (lib) => {` - ((!isDynamicImport && !isNew && isCommonsJsOrAmdCall(node, parentNode)) || - // Template literals as single arguments - (args.length === 1 && - isTemplateOnItsOwnLine(args[0], options.originalText)) || - // Keep test declarations on a single line - // e.g. `it('long name', () => {` - (!isNew && isTestCall(node, parentNode))) + (args.length > 0 && + !isNew && + !isDynamicImport && + // We want to keep CommonJS- and AMD-style require calls, and AMD-style + // define calls, as a unit. + // e.g. `define(["some/lib"], (lib) => {` + (isCommonsJsOrAmdCall(node, parent) || + // Keep test declarations on a single line + // e.g. `it('long name', () => {` + isTestCall(node, parent))) ) { const printed = []; iterateCallArgumentsPath(path, () => { printed.push(print()); }); - return [ - isNew ? "new " : "", - print("callee"), - optional, - printFunctionTypeParameters(path, options, print), - "(", - join(", ", printed), - ")", - ]; + if (!(isTemplateLiteralSingleArg && printed[0].label?.embed)) { + return [ + isNew ? "new " : "", + print("callee"), + optional, + printFunctionTypeParameters(path, options, print), + "(", + join(", ", printed), + ")", + ]; + } } // Inline Flow annotation comments following Identifiers in Call nodes need to diff --git a/src/language-js/print/function.js b/src/language-js/print/function.js index 9a6118a57..ac4093b83 100644 --- a/src/language-js/print/function.js +++ b/src/language-js/print/function.js @@ -340,7 +340,9 @@ function printArrowFunction(path, options, print, args) { node.body.type === "ObjectExpression" || node.body.type === "BlockStatement" || isJsxNode(node.body) || - isTemplateOnItsOwnLine(node.body, options.originalText) || + (body[0].label?.hug !== false && + (body[0].label?.embed || + isTemplateOnItsOwnLine(node.body, options.originalText))) || node.body.type === "ArrowFunctionExpression" || node.body.type === "DoExpression") ) { diff --git a/src/language-js/print/member-chain.js b/src/language-js/print/member-chain.js index eb088d75e..980aaad9a 100644 --- a/src/language-js/print/member-chain.js +++ b/src/language-js/print/member-chain.js @@ -399,7 +399,7 @@ function printMemberChain(path, options, print) { ]; } - return label("member-chain", result); + return label({ memberChain: true }, result); } export default printMemberChain; diff --git a/src/language-js/print/member.js b/src/language-js/print/member.js index a79482666..c0bb4b6d3 100644 --- a/src/language-js/print/member.js +++ b/src/language-js/print/member.js @@ -40,9 +40,9 @@ function printMemberExpression(path, options, print) { (node.object.type === "TSNonNullExpression" && isCallExpression(node.object.expression) && node.object.expression.arguments.length > 0) || - objectDoc.label === "member-chain")); + objectDoc.label?.memberChain)); - return label(objectDoc.label === "member-chain" ? "member-chain" : "member", [ + return label(objectDoc.label, [ objectDoc, shouldInline ? lookupDoc : group(indent([softline, lookupDoc])), ]); diff --git a/src/language-js/print/template-literal.js b/src/language-js/print/template-literal.js index 7a578ceef..541766686 100644 --- a/src/language-js/print/template-literal.js +++ b/src/language-js/print/template-literal.js @@ -8,6 +8,7 @@ import { align, lineSuffixBoundary, addAlignmentToDoc, + label, } from "../../document/builders.js"; import { printDocToString } from "../../document/printer.js"; import { mapDoc } from "../../document/utils.js"; @@ -106,6 +107,16 @@ function printTemplateLiteral(path, print, options) { return parts; } +function printTaggedTemplateLiteral(print) { + const quasiDoc = print("quasi"); + return label(quasiDoc.label && { tagged: true, ...quasiDoc.label }, [ + print("tag"), + print("typeParameters"), + lineSuffixBoundary, + quasiDoc, + ]); +} + function printJestEachTemplateLiteral(path, options, print) { /** * a | b | expected @@ -229,6 +240,7 @@ function uncookTemplateElementValue(cookedValue) { export { printTemplateLiteral, + printTaggedTemplateLiteral, printTemplateExpressions, escapeTemplateCharacters, uncookTemplateElementValue, diff --git a/src/language-js/printer-estree.js b/src/language-js/printer-estree.js index 5b4e9d4d9..05f069767 100644 --- a/src/language-js/printer-estree.js +++ b/src/language-js/printer-estree.js @@ -58,7 +58,10 @@ import { printModuleSpecifier, } from "./print/module.js"; import { printTernary } from "./print/ternary.js"; -import { printTemplateLiteral } from "./print/template-literal.js"; +import { + printTaggedTemplateLiteral, + printTemplateLiteral, +} from "./print/template-literal.js"; import { printArray } from "./print/array.js"; import { printObject } from "./print/object.js"; import { @@ -755,7 +758,7 @@ function printPathNoParens(path, options, print, args) { case "TemplateLiteral": return printTemplateLiteral(path, print, options); case "TaggedTemplateExpression": - return [print("tag"), print("typeParameters"), print("quasi")]; + return printTaggedTemplateLiteral(print); case "PrivateIdentifier": return ["#", node.name]; case "PrivateName": diff --git a/src/main/comments.js b/src/main/comments.js index 5f6687a65..40d8df963 100644 --- a/src/main/comments.js +++ b/src/main/comments.js @@ -7,6 +7,7 @@ import { lineSuffix, join, cursor, + label, } from "../document/builders.js"; import { hasNewline, @@ -585,7 +586,12 @@ function printComments(path, doc, options, ignored) { if (!leading && !trailing) { return doc; } - return [leading, doc, trailing]; + return label( + // Propagate object labels so that the printing logic for ancestor nodes + // could easily check them. + typeof doc.label === "object" && { commented: true, ...doc.label }, + [leading, doc, trailing] + ); } function ensureAllCommentsPrinted(astComments) { diff --git a/tests/config/format-test.js b/tests/config/format-test.js index 9802c444e..7fa3873e9 100644 --- a/tests/config/format-test.js +++ b/tests/config/format-test.js @@ -28,7 +28,6 @@ const unstableTests = new Map( "js/comments/tagged-template-literal.js", "markdown/spec/example-234.md", "markdown/spec/example-235.md", - "html/multiparser/js/script-tag-escaping.html", [ "js/multiparser-markdown/codeblock.js", (options) => options.proseWrap === "always", diff --git a/tests/format/js/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap b/tests/format/js/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap index 812524a5e..2563c8463 100644 --- a/tests/format/js/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap +++ b/tests/format/js/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap @@ -291,6 +291,67 @@ someReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReal ================================================================================ `; +exports[`embed.js format 1`] = ` +====================================options===================================== +parsers: ["babel", "flow"] +printWidth: 80 + | printWidth +=====================================input====================================== +foo(/* HTML */ \` bar \`); +foo(/* HTML */ \` bar \`); +foo(/* HTML */ \`

bar

foo
\`); +foo(/* HTML */ \`

bar

foo
\`); +foo(/* GraphQL */ \`query { foo { bar } }\`); +foo(/* ... */ css\`color:magenta\`); +const a = b => /* HTML */ \` bar \` +const c = b => /* HTML */ \` bar \` + +=====================================output===================================== +foo( + /* HTML */ \` + bar + \`, +); +foo(/* HTML */ \` + + bar + +\`); +foo( + /* HTML */ \`
+

bar

+ foo +
\`, +); +foo(/* HTML */ \` +
+

bar

+ foo +
+\`); +foo(/* GraphQL */ \` + query { + foo { + bar + } + } +\`); +foo(/* ... */ css\` + color: magenta; +\`); +const a = (b) => + /* HTML */ \` + bar + \`; +const c = (b) => /* HTML */ \` + + bar + +\`; + +================================================================================ +`; + exports[`empty-lines.js format 1`] = ` ====================================options===================================== parsers: ["babel", "flow"] diff --git a/tests/format/js/last-argument-expansion/embed.js b/tests/format/js/last-argument-expansion/embed.js new file mode 100644 index 000000000..a8ba2c149 --- /dev/null +++ b/tests/format/js/last-argument-expansion/embed.js @@ -0,0 +1,8 @@ +foo(/* HTML */ ` bar `); +foo(/* HTML */ ` bar `); +foo(/* HTML */ `

bar

foo
`); +foo(/* HTML */ `

bar

foo
`); +foo(/* GraphQL */ `query { foo { bar } }`); +foo(/* ... */ css`color:magenta`); +const a = b => /* HTML */ ` bar ` +const c = b => /* HTML */ ` bar ` diff --git a/tests/format/js/multiparser-comments/__snapshots__/jsfmt.spec.js.snap b/tests/format/js/multiparser-comments/__snapshots__/jsfmt.spec.js.snap index 4eb295822..6570a6f1d 100644 --- a/tests/format/js/multiparser-comments/__snapshots__/jsfmt.spec.js.snap +++ b/tests/format/js/multiparser-comments/__snapshots__/jsfmt.spec.js.snap @@ -165,3 +165,26 @@ graphql.experimental\` ================================================================================ `; + +exports[`tagged.js format 1`] = ` +====================================options===================================== +parsers: ["babel", "flow"] +printWidth: 80 + | printWidth +=====================================input====================================== +foo(html // oops +\`

bar

foo
\`); + +=====================================output===================================== +foo( + html // oops + \` +
+

bar

+ foo +
+ \`, +); + +================================================================================ +`; diff --git a/tests/format/js/multiparser-comments/tagged.js b/tests/format/js/multiparser-comments/tagged.js new file mode 100644 index 000000000..49673589b --- /dev/null +++ b/tests/format/js/multiparser-comments/tagged.js @@ -0,0 +1,2 @@ +foo(html // oops +`

bar

foo
`); diff --git a/tests/format/misc/embedded_language_formatting/in-javascript/__snapshots__/jsfmt.spec.js.snap b/tests/format/misc/embedded_language_formatting/in-javascript/__snapshots__/jsfmt.spec.js.snap index 30d06f125..b8d0a9eaf 100644 --- a/tests/format/misc/embedded_language_formatting/in-javascript/__snapshots__/jsfmt.spec.js.snap +++ b/tests/format/misc/embedded_language_formatting/in-javascript/__snapshots__/jsfmt.spec.js.snap @@ -67,13 +67,11 @@ css\` md\`\`; -graphql( - \` - { - id - } - \`, -); +graphql(\` + { + id + } +\`); html\`\`;