From 187680371e6e681575603f1a9c548e695c407c6c Mon Sep 17 00:00:00 2001 From: fisker Cheung Date: Tue, 27 Jan 2026 22:41:29 +0800 Subject: [PATCH] Print leading semicolon before type cast comments (#18751) --- changelog_unreleased/javascript/18751.md | 16 ++ changelog_unreleased/typescript/18738.md | 6 +- .../comments/will-print-own-comments.js | 8 +- src/language-js/print/expression-statement.js | 42 ++++- src/language-js/print/ignored.js | 3 + src/language-js/print/index.js | 5 +- src/language-js/semicolon/semicolon.js | 21 ++- ...expression-statement-print-own-comments.js | 22 +++ .../config/format-test/failed-format-tests.js | 5 +- .../no-semi/__snapshots__/format.test.js.snap | 169 ++++++++++++++++++ .../no-semi/comments.js | 18 ++ .../no-semi/format.test.js | 1 + .../no-semi/multiline.js | 5 + .../no-semi/not-on-same-line.js | 33 ++++ .../no-semi/with-other-comments.js | 2 + .../__snapshots__/format.test.js.snap | 4 +- 16 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 changelog_unreleased/javascript/18751.md create mode 100644 src/language-js/utilities/should-expression-statement-print-own-comments.js create mode 100644 tests/format/js/comments-closure-typecast/no-semi/__snapshots__/format.test.js.snap create mode 100644 tests/format/js/comments-closure-typecast/no-semi/comments.js create mode 100644 tests/format/js/comments-closure-typecast/no-semi/format.test.js create mode 100644 tests/format/js/comments-closure-typecast/no-semi/multiline.js create mode 100644 tests/format/js/comments-closure-typecast/no-semi/not-on-same-line.js create mode 100644 tests/format/js/comments-closure-typecast/no-semi/with-other-comments.js diff --git a/changelog_unreleased/javascript/18751.md b/changelog_unreleased/javascript/18751.md new file mode 100644 index 000000000..45a5fb2da --- /dev/null +++ b/changelog_unreleased/javascript/18751.md @@ -0,0 +1,16 @@ +#### Print leading semicolon before type cast comments (#18751 by @fisker) + + +```jsx +// Input +;/** @type {string[]} */ (['foo', 'bar']).forEach(doStuff) + +// Prettier stable (--no-semi, first format) +/** @type {string[]} */ ;(["foo", "bar"]).forEach(doStuff) + +// Prettier stable (--no-semi, second format) +/** @type {string[]} */ ;["foo", "bar"].forEach(doStuff) + +// Prettier main (--no-semi) +;/** @type {string[]} */ (["foo", "bar"]).forEach(doStuff) +``` diff --git a/changelog_unreleased/typescript/18738.md b/changelog_unreleased/typescript/18738.md index bd96e5acc..7d46844b5 100644 --- a/changelog_unreleased/typescript/18738.md +++ b/changelog_unreleased/typescript/18738.md @@ -6,14 +6,14 @@ a; c; -// Prettier stable (First format, --no-semi) +// Prettier stable (--no-semi, first format) a c -// Prettier stable (Second format, --no-semi) +// Prettier stable (--no-semi, second format) a < b > c -// Prettier main +// Prettier main (--no-semi) a ;c ``` diff --git a/src/language-js/comments/will-print-own-comments.js b/src/language-js/comments/will-print-own-comments.js index e36fc8ead..35a3c479a 100644 --- a/src/language-js/comments/will-print-own-comments.js +++ b/src/language-js/comments/will-print-own-comments.js @@ -5,6 +5,7 @@ import { shouldUnionTypePrintOwnComments, } from "../utilities/index.js"; import { isJsxElement, isUnionType } from "../utilities/node-types.js"; +import { shouldExpressionStatementPrintOwnComments } from "../utilities/should-expression-statement-print-own-comments.js"; /** @import {Node} from "../types/estree.js"; @@ -26,7 +27,7 @@ const isClassOrInterface = createTypeCheckFunction([ * @param {AstPath} path * @returns {boolean} */ -function willPrintOwnComments(path) { +function willPrintOwnComments(path, options) { const { key, parent } = path; if ( (key === "types" && isUnionType(parent)) || @@ -45,10 +46,15 @@ function willPrintOwnComments(path) { } const { node } = path; + if (hasNodeIgnoreComment(node)) { return false; } + if (node.type === "ExpressionStatement") { + return shouldExpressionStatementPrintOwnComments(path, options); + } + if (isUnionType(node)) { return shouldUnionTypePrintOwnComments(path); } diff --git a/src/language-js/print/expression-statement.js b/src/language-js/print/expression-statement.js index 835f3e78b..156ec614a 100644 --- a/src/language-js/print/expression-statement.js +++ b/src/language-js/print/expression-statement.js @@ -1,14 +1,25 @@ +import { + printComments, + printCommentsSeparately, +} from "../../main/comments/print.js"; import { isSingleHtmlEventHandlerExpressionStatement, isSingleJsxExpressionStatementInMarkdown, isSingleVueEventBindingExpressionStatement, + shouldExpressionStatementPrintLeadingSemicolon, } from "../semicolon/semicolon.js"; +import { CommentCheckFlags, getComments } from "../utilities/index.js"; +import { shouldExpressionStatementPrintOwnComments } from "../utilities/should-expression-statement-print-own-comments.js"; import { isVueEventBindingFunctionExpression, isVueEventBindingMemberExpression, unwrapVueEventBindingTsNode, } from "../utilities/vue-event-binding.js"; +/** +@import {Doc} from "../../document/index.js" +*/ + function shouldPrintSemicolon(path, options) { if (isSingleVueEventBindingExpressionStatement(path, options)) { const expression = unwrapVueEventBindingTsNode(path.node.expression); @@ -35,7 +46,36 @@ function shouldPrintSemicolon(path, options) { } function printExpressionStatement(path, options, print) { - return [print("expression"), shouldPrintSemicolon(path, options) ? ";" : ""]; + /** @type {Doc[]} */ + const parts = [print("expression")]; + + if (shouldExpressionStatementPrintLeadingSemicolon(path, options)) { + if (shouldExpressionStatementPrintOwnComments(path, options)) { + const { node } = path; + const typeCastComment = getComments(node, CommentCheckFlags.Leading).at( + -1, + ); + + // Print the type cast comment separately and print `;` before it + // eslint-disable-next-line prettier-internal-rules/no-node-comments + const { comments } = node; + // eslint-disable-next-line prettier-internal-rules/no-node-comments + node.comments = [typeCastComment]; + const typeCastCommentDoc = printCommentsSeparately(path, options).leading; + // eslint-disable-next-line prettier-internal-rules/no-node-comments + node.comments = comments; + // Make it printed, so `printComments` will ignore it + options[Symbol.for("printedComments")].add(typeCastComment); + + return printComments(path, [";", typeCastCommentDoc, ...parts], options); + } + + parts.unshift(";"); + } else if (shouldPrintSemicolon(path, options)) { + parts.push(";"); + } + + return parts; } export { printExpressionStatement }; diff --git a/src/language-js/print/ignored.js b/src/language-js/print/ignored.js index 0a847f69d..329d49e88 100644 --- a/src/language-js/print/ignored.js +++ b/src/language-js/print/ignored.js @@ -1,4 +1,5 @@ import { locEnd, locStart } from "../loc.js"; +import { shouldExpressionStatementPrintLeadingSemicolon } from "../semicolon/semicolon.js"; function printIgnored(path, options /* , print*/) { const { node } = path; @@ -18,6 +19,8 @@ function printIgnored(path, options /* , print*/) { node.__end)) ) { text += ";"; + } else if (shouldExpressionStatementPrintLeadingSemicolon(path, options)) { + text = `;${text}`; } return text; diff --git a/src/language-js/print/index.js b/src/language-js/print/index.js index 83d05e41f..6b4f79c9c 100644 --- a/src/language-js/print/index.js +++ b/src/language-js/print/index.js @@ -8,7 +8,6 @@ import { import { printComments } from "../../main/comments/print.js"; import isNonEmptyArray from "../../utilities/is-non-empty-array.js"; import needsParentheses from "../parentheses/needs-parentheses.js"; -import { shouldPrintLeadingSemicolon } from "../semicolon/semicolon.js"; import { CommentCheckFlags, createTypeCheckFunction, @@ -96,14 +95,12 @@ function print(path, options, print, args) { } const needsParens = needsParentheses(path, options); - const needsSemi = shouldPrintLeadingSemicolon(path, options); - if (!decoratorsDoc && !needsParens && !needsSemi) { + if (!decoratorsDoc && !needsParens) { return doc; } return inheritLabel(doc, (doc) => [ - needsSemi ? ";" : "", needsParens ? "(" : "", needsParens && isClassExpression && hasDecorators ? [indent([line, decoratorsDoc, doc]), line] diff --git a/src/language-js/semicolon/semicolon.js b/src/language-js/semicolon/semicolon.js index a06ad4f22..d390a45f2 100644 --- a/src/language-js/semicolon/semicolon.js +++ b/src/language-js/semicolon/semicolon.js @@ -3,9 +3,15 @@ import { shouldPrintParamsWithoutParens } from "../print/function.js"; import { getLeftSidePathName, hasNakedLeftSide } from "../utilities/index.js"; import { isJsxElement } from "../utilities/node-types.js"; -function shouldPrintLeadingSemicolon(path, options) { +function shouldExpressionStatementPrintLeadingSemicolon(path, options) { + if (options.semi) { + return false; + } + + const { node } = path; + if ( - options.semi || + node.type !== "ExpressionStatement" || isSingleJsxExpressionStatementInMarkdown(path, options) || isSingleVueEventBindingExpressionStatement(path, options) || isSingleHtmlEventHandlerExpressionStatement(path, options) @@ -13,9 +19,8 @@ function shouldPrintLeadingSemicolon(path, options) { return false; } - const { node, key, parent } = path; + const { key, parent } = path; if ( - node.type === "ExpressionStatement" && // `Program.directives` don't need leading semicolon ((key === "body" && (parent.type === "Program" || @@ -23,7 +28,7 @@ function shouldPrintLeadingSemicolon(path, options) { parent.type === "StaticBlock" || parent.type === "TSModuleBlock")) || (key === "consequent" && parent.type === "SwitchCase")) && - path.call(() => expressionNeedsASIProtection(path, options), "expression") + path.call(() => expressionNeedsAsiProtection(path, options), "expression") ) { return true; } @@ -31,7 +36,7 @@ function shouldPrintLeadingSemicolon(path, options) { return false; } -function expressionNeedsASIProtection(path, options) { +function expressionNeedsAsiProtection(path, options) { const { node } = path; switch (node.type) { case "ParenthesizedExpression": @@ -84,7 +89,7 @@ function expressionNeedsASIProtection(path, options) { } return path.call( - () => expressionNeedsASIProtection(path, options), + () => expressionNeedsAsiProtection(path, options), ...getLeftSidePathName(node), ); } @@ -123,5 +128,5 @@ export { isSingleHtmlEventHandlerExpressionStatement, isSingleJsxExpressionStatementInMarkdown, isSingleVueEventBindingExpressionStatement, - shouldPrintLeadingSemicolon, + shouldExpressionStatementPrintLeadingSemicolon, }; diff --git a/src/language-js/utilities/should-expression-statement-print-own-comments.js b/src/language-js/utilities/should-expression-statement-print-own-comments.js new file mode 100644 index 000000000..493f6f8a2 --- /dev/null +++ b/src/language-js/utilities/should-expression-statement-print-own-comments.js @@ -0,0 +1,22 @@ +import { shouldExpressionStatementPrintLeadingSemicolon } from "../semicolon/semicolon.js"; +import { CommentCheckFlags, getComments } from "./index.js"; +import isTypeCastComment from "./is-type-cast-comment.js"; + +function shouldExpressionStatementPrintOwnComments(path, options) { + if (!shouldExpressionStatementPrintLeadingSemicolon(path, options)) { + return false; + } + + // Note: this causes the following print differently + // `;/** @type {string[]} */ ([]).forEach(foo)` + // `;/* normal comment */ ([]).forEach(foo)` + // We may want consider remove the `isTypeCastComment` check + const lastComment = getComments(path.node, CommentCheckFlags.Leading).at(-1); + if (lastComment && isTypeCastComment(lastComment)) { + return true; + } + + return false; +} + +export { shouldExpressionStatementPrintOwnComments }; diff --git a/tests/config/format-test/failed-format-tests.js b/tests/config/format-test/failed-format-tests.js index e34ce86b6..1595ac26b 100644 --- a/tests/config/format-test/failed-format-tests.js +++ b/tests/config/format-test/failed-format-tests.js @@ -43,7 +43,10 @@ const unstableTests = new Map( const unstableAstTests = new Map(); // These tests works on `babel`, `acorn`, `espree`, `oxc`, and `meriyah` -const commentClosureTypecaseTests = ["js/comments-closure-typecast/"]; +const commentClosureTypecaseTests = [ + "js/comments-closure-typecast/", + "js/comments-closure-typecast/no-semi/", +]; const disabledTests = new Map( Object.entries({ diff --git a/tests/format/js/comments-closure-typecast/no-semi/__snapshots__/format.test.js.snap b/tests/format/js/comments-closure-typecast/no-semi/__snapshots__/format.test.js.snap new file mode 100644 index 000000000..e8d8238b2 --- /dev/null +++ b/tests/format/js/comments-closure-typecast/no-semi/__snapshots__/format.test.js.snap @@ -0,0 +1,169 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`comments.js - {"semi":false} format 1`] = ` +====================================options===================================== +parsers: ["babel"] +printWidth: 80 +semi: false + | printWidth +=====================================input====================================== +// https://github.com/prettier/prettier/issues/18307 +class C { + typeCastS() { + /** @type {string} */ (this.s).length // 7 + } +} + +// https://github.com/prettier/prettier/issues/12794 +;(/** @type {(token: Token)=>void} */ (onToken))(token) // 1 +;/** @type {(token: Token)=>void} */ (onToken)(token) // 2 + +// unparenthesized +;/** @type {(token: Token)=>void} */ [onToken](token) // 3 +;/** @type {(token: Token)=>void} */ onToken(token) // 4 + +;/* not a type cast comment */ ([])(token) // 5 + +;/* don't need leading semicolon */ (foo)(token) // 5 + +=====================================output===================================== +// https://github.com/prettier/prettier/issues/18307 +class C { + typeCastS() { + ;/** @type {string} */ (this.s).length // 7 + } +} + +// https://github.com/prettier/prettier/issues/12794 +;/** @type {(token: Token)=>void} */ (onToken)(token) // 1 +;/** @type {(token: Token)=>void} */ (onToken)(token) // 2 + +// unparenthesized +;/** @type {(token: Token)=>void} */ [onToken](token) // 3 +/** @type {(token: Token)=>void} */ onToken(token) // 4 + +/* not a type cast comment */ ;[](token) // 5 + +/* don't need leading semicolon */ foo(token) // 5 + +================================================================================ +`; + +exports[`multiline.js - {"semi":false} format 1`] = ` +====================================options===================================== +parsers: ["babel"] +printWidth: 80 +semi: false + | printWidth +=====================================input====================================== +/** +@type {{ + bar: string[] +}} +*/ (foo).bar.forEach(doStuff) + +=====================================output===================================== +;/** +@type {{ + bar: string[] +}} +*/ (foo).bar.forEach(doStuff) + +================================================================================ +`; + +exports[`not-on-same-line.js - {"semi":false} format 1`] = ` +====================================options===================================== +parsers: ["babel"] +printWidth: 80 +semi: false + | printWidth +=====================================input====================================== +/** +@type {{ + bar: string[] +}} +*/ +({}).bar.forEach(doStuff); + +// 1 + +/** +@type {{ + bar: string[] +}} +*/ + +({}).bar.forEach(doStuff); + +/** +@type {{ + bar: string[] +}} +*/ + +(foo.bar.forEach(doStuff)); + +/** +@type {{ + bar: string[] +}} +*/ + +// 2 +({}).bar.forEach(doStuff); + +=====================================output===================================== +;/** +@type {{ + bar: string[] +}} +*/ +({}).bar.forEach(doStuff) + +// 1 + +;/** +@type {{ + bar: string[] +}} +*/ + +({}).bar.forEach(doStuff) + +;/** +@type {{ + bar: string[] +}} +*/ + +(foo.bar.forEach(doStuff)) + +/** +@type {{ + bar: string[] +}} +*/ + +// 2 +;({}).bar.forEach(doStuff) + +================================================================================ +`; + +exports[`with-other-comments.js - {"semi":false} format 1`] = ` +====================================options===================================== +parsers: ["babel"] +printWidth: 80 +semi: false + | printWidth +=====================================input====================================== +;/* 2 */ /** @type {{bar: string[]}} */ ({}).bar.forEach(doStuff) +;/** @type {{bar: string[]}} */ /* 2 */ ({}).bar.forEach(doStuff) + +=====================================output===================================== +/* 2 */ ;/** @type {{bar: string[]}} */ ({}).bar.forEach(doStuff) +/** @type {{bar: string[]}} */ /* 2 */ ;({}).bar.forEach(doStuff) + +================================================================================ +`; diff --git a/tests/format/js/comments-closure-typecast/no-semi/comments.js b/tests/format/js/comments-closure-typecast/no-semi/comments.js new file mode 100644 index 000000000..1fe84bef9 --- /dev/null +++ b/tests/format/js/comments-closure-typecast/no-semi/comments.js @@ -0,0 +1,18 @@ +// https://github.com/prettier/prettier/issues/18307 +class C { + typeCastS() { + /** @type {string} */ (this.s).length // 7 + } +} + +// https://github.com/prettier/prettier/issues/12794 +;(/** @type {(token: Token)=>void} */ (onToken))(token) // 1 +;/** @type {(token: Token)=>void} */ (onToken)(token) // 2 + +// unparenthesized +;/** @type {(token: Token)=>void} */ [onToken](token) // 3 +;/** @type {(token: Token)=>void} */ onToken(token) // 4 + +;/* not a type cast comment */ ([])(token) // 5 + +;/* don't need leading semicolon */ (foo)(token) // 5 diff --git a/tests/format/js/comments-closure-typecast/no-semi/format.test.js b/tests/format/js/comments-closure-typecast/no-semi/format.test.js new file mode 100644 index 000000000..e074996bb --- /dev/null +++ b/tests/format/js/comments-closure-typecast/no-semi/format.test.js @@ -0,0 +1 @@ +runFormatTest(import.meta, ["babel"], { semi: false }); diff --git a/tests/format/js/comments-closure-typecast/no-semi/multiline.js b/tests/format/js/comments-closure-typecast/no-semi/multiline.js new file mode 100644 index 000000000..6e3d3b658 --- /dev/null +++ b/tests/format/js/comments-closure-typecast/no-semi/multiline.js @@ -0,0 +1,5 @@ +/** +@type {{ + bar: string[] +}} +*/ (foo).bar.forEach(doStuff) diff --git a/tests/format/js/comments-closure-typecast/no-semi/not-on-same-line.js b/tests/format/js/comments-closure-typecast/no-semi/not-on-same-line.js new file mode 100644 index 000000000..8c967b790 --- /dev/null +++ b/tests/format/js/comments-closure-typecast/no-semi/not-on-same-line.js @@ -0,0 +1,33 @@ +/** +@type {{ + bar: string[] +}} +*/ +({}).bar.forEach(doStuff); + +// 1 + +/** +@type {{ + bar: string[] +}} +*/ + +({}).bar.forEach(doStuff); + +/** +@type {{ + bar: string[] +}} +*/ + +(foo.bar.forEach(doStuff)); + +/** +@type {{ + bar: string[] +}} +*/ + +// 2 +({}).bar.forEach(doStuff); diff --git a/tests/format/js/comments-closure-typecast/no-semi/with-other-comments.js b/tests/format/js/comments-closure-typecast/no-semi/with-other-comments.js new file mode 100644 index 000000000..c4bc11840 --- /dev/null +++ b/tests/format/js/comments-closure-typecast/no-semi/with-other-comments.js @@ -0,0 +1,2 @@ +;/* 2 */ /** @type {{bar: string[]}} */ ({}).bar.forEach(doStuff) +;/** @type {{bar: string[]}} */ /* 2 */ ({}).bar.forEach(doStuff) diff --git a/tests/format/js/comments/__snapshots__/format.test.js.snap b/tests/format/js/comments/__snapshots__/format.test.js.snap index 8c0b78952..643b6deab 100644 --- a/tests/format/js/comments/__snapshots__/format.test.js.snap +++ b/tests/format/js/comments/__snapshots__/format.test.js.snap @@ -3766,10 +3766,10 @@ const x = ( ) -/** +;/** * @type {object} */ -;() => ( +() => (
sajdfpoiasdjfpoiasdjfpoiasdjfpoiadsjfpaoisdjfapsdiofjapioisadfaskfaspiofjp