fix(formatter): keep JSDoc cast parens with a comment inside them (#26374)

Fixes #26274, #26275.
This commit is contained in:
leaysgur committed 2026-09-07 04:56:37 +00:00
1 parent b3613b176a
commit 104061b98a
10 files changed
+454 -52

No files matched your search

+40 -19
View File
@@ -135,7 +135,7 @@ pub struct Comments<'a> {
///
/// When set, [`Self::unprinted_comments()`] will only return comments up to this index,
/// effectively hiding comments beyond this point from the formatter.
pub view_limit: Option<usize>,
view_limit: Option<usize>,
}
impl<'a> Comments<'a> {
@@ -275,6 +275,42 @@ impl<'a> Comments<'a> {
unreachable!("the caller guarantees the character occurs outside a comment")
}
/// The run of comments after `pos` separated only by whitespace (a prefix of `comments`).
fn comment_run_after(&self, comments: &'a [Comment], pos: u32) -> &'a [Comment] {
let mut cursor = pos;
let mut count = 0;
for comment in comments {
if comment.span.start < cursor
|| !self
.source_text
.all_bytes_match(cursor, comment.span.start, |b| b.is_ascii_whitespace())
{
break;
}
count += 1;
cursor = comment.span.end;
}
&comments[..count]
}
/// Whether the first non-whitespace byte after `pos` outside comments is `)`.
///
/// Skips every comment by span, printed and hidden ones included, so the answer does not depend on the comment view:
/// a cast target hidden behind an ancestor's trailing-comment limit must still be recognized, or its parens drop.
pub(crate) fn is_followed_by_closing_paren(&self, pos: u32) -> bool {
// Only a comment can hide the `)`, so anything but `/` answers without touching the comments;
// a `/` that is an operator instead is settled by the comment spans below (no adjacent comment: `false`)
match self.source_text.next_non_whitespace_byte(pos) {
Some(b')') => return true,
Some(b'/') => {}
_ => return false,
}
let first = self.inner.partition_point(|comment| comment.span.end <= pos);
let run = self.comment_run_after(&self.inner[first..], pos);
let end = run.last().map_or(pos, |comment| comment.span.end);
self.source_text.next_non_whitespace_byte_is(end, b')')
}
/// Comments sitting between `pos` and a closing source paren:
/// the run of comments after `pos` separated only by whitespace,
/// whose next non-whitespace character is `)`.
@@ -284,24 +320,9 @@ impl<'a> Comments<'a> {
/// (e.g. an expression end up to the statement end)
/// A range containing a string literal would false-match a `)` inside it.
pub(crate) fn comments_before_closing_paren(&self, pos: u32) -> Option<&'a [Comment]> {
let comments = self.comments_after(pos);
let mut pos = pos;
let mut count = 0;
for comment in comments {
if comment.span.start < pos
|| !self
.source_text
.all_bytes_match(pos, comment.span.start, |b| b.is_ascii_whitespace())
{
break;
}
count += 1;
pos = comment.span.end;
}
(count > 0 && self.source_text.next_non_whitespace_byte_is(pos, b')'))
.then(|| &comments[..count])
let run = self.comment_run_after(self.comments_after(pos), pos);
let end = run.last()?.span.end;
self.source_text.next_non_whitespace_byte_is(end, b')').then_some(run)
}
/// Whether the range holds a `;` or a `)` outside comments (`foo /* ; */` doesn't count).
+1 -1
View File
@@ -637,7 +637,7 @@ impl<'a> Format<'a, JsFormatContext<'a>> for FormatClassElementWithSemicolon<'a,
// the comment still ends up behind the semicolon like Prettier.
let content_end = value
.as_ref()
.map(assignment_chain_leaf_end)
.map(|value| assignment_chain_leaf_end(value, f))
.or_else(|| type_annotation.as_ref().map(|ta| ta.span.end))
.unwrap_or_else(|| key.span().end);
// `node_end` bound: the element may have no source `;` at all
+1
View File
@@ -39,6 +39,7 @@ pub use arrow_function_expression::{
};
pub use binary_like_expression::{BinaryLikeExpression, should_flatten};
pub use fragment::{FormatFunctionParams, FormatTypeParameters};
pub use semicolon::write_comments_before_closing_paren;
pub use union_type::{
alias_union_breaks_after_operator, is_line_ending_trailing_jsdoc_comment, type_alias_left_end,
};
@@ -85,7 +85,7 @@ impl<'a> Format<'a, JsFormatContext<'a>> for ReturnAndThrowStatement<'a, '_> {
if let Some(argument) = self.argument() {
write!(f, space());
let argument_leaf_end = assignment_chain_leaf_end(argument.as_ref());
let argument_leaf_end = assignment_chain_leaf_end(argument.as_ref(), f);
if f.comments().has_comment_in_range(argument_leaf_end, self.span().end) {
// Comments inside the argument's source parentheses belong to the argument
// and stay in place (`return (\n a && b // c\n);` keeps the comment inside, like Prettier);
+20 -7
View File
@@ -6,7 +6,10 @@ use crate::{
ast_nodes::AstNodes,
formatter::{JsFormatContext, JsFormatter, trivia::FormatTrailingComments},
options::Semicolons,
utils::format_node_without_trailing_comments::format_content_without_comments_after,
utils::{
format_node_without_trailing_comments::format_content_without_comments_after,
typecast::cast_target_end,
},
write,
};
@@ -101,7 +104,11 @@ pub fn keeps_trailing_comment_inside_parens(expr: &Expression<'_>, gated: bool)
///
/// The chain also passes through arrow expression bodies (prettier#19930 family);
/// bodies matching [`arrow_body_keeps_trailing_comment_inside_parens`] stop the walk.
pub fn assignment_chain_leaf_end(expr: &Expression<'_>) -> u32 {
///
/// A JSDoc cast target leaf ends past its cast parens (`cast_target_end`), like the keeps above;
/// not via `end_including_source_parens`: the walk has no `node_end` bound,
/// and the cast's matching `)` is counted, not the last one in a window.
pub fn assignment_chain_leaf_end(expr: &Expression<'_>, f: &JsFormatter<'_, '_>) -> u32 {
let mut leaf = expr;
loop {
match leaf {
@@ -115,7 +122,7 @@ pub fn assignment_chain_leaf_end(expr: &Expression<'_>) -> u32 {
_ => break,
}
}
leaf.span().end
cast_target_end(leaf.span(), f).unwrap_or(leaf.span().end)
}
/// Content end for a semicolon-terminated expression site, pairing the two functions above:
@@ -133,7 +140,7 @@ pub fn semicolon_terminated_expression_content_end(
if keeps_trailing_comment_inside_parens(expr, gated) {
f.context().comments().end_including_source_parens(paren_scan_start, node_end)
} else {
assignment_chain_leaf_end(expr)
assignment_chain_leaf_end(expr, f)
}
}
@@ -178,9 +185,15 @@ pub fn write_trailing_comments_inside_parens<'a>(
AstNodes::AssignmentExpression(_) => is_sequence,
_ => false,
};
if parens_survive
&& let Some(comments) = f.context().comments().comments_before_closing_paren(node_end)
{
if parens_survive {
write_comments_before_closing_paren(f, node_end);
}
}
/// Prints the comments sitting right before the closing source paren after `end`,
/// inside the parentheses, for a node that re-prints them.
pub fn write_comments_before_closing_paren(f: &mut JsFormatter<'_, '_>, end: u32) {
if let Some(comments) = f.context().comments().comments_before_closing_paren(end) {
write!(f, FormatTrailingComments::Comments(comments));
}
}
@@ -5,7 +5,7 @@ use crate::{
formatter::{
JsFormatContext, JsFormatter, JsFormatterExt as _, trivia::format_leading_comments,
},
utils::suppressed::FormatSuppressedNode,
utils::{suppressed::FormatSuppressedNode, typecast::cast_target_end},
};
/// Generic wrapper for formatting a node without its trailing comments.
@@ -20,7 +20,11 @@ where
T: Format<'a, JsFormatContext<'a>> + GetSpan,
{
fn fmt(&self, f: &mut JsFormatter<'_, 'a>) {
let node_end = self.0.span().end;
// A JSDoc cast target's hidden range starts past its cast parens (see `cast_target_end`);
// a suppression comment there keeps the plain span for the suppressed path below.
let node_end = cast_target_end(self.0.span(), f)
.filter(|end| !f.comments().has_trailing_suppression_comment(*end))
.unwrap_or(self.0.span().end);
if f.comments().has_trailing_suppression_comment(node_end) {
format_leading_comments(self.0.span()).fmt(f);
+56 -19
View File
@@ -8,6 +8,7 @@ use crate::{
prelude::*,
trivia::{FormatLeadingComments, format_leading_comments},
},
print::write_comments_before_closing_paren,
utils::suppressed::FormatSuppressedNode,
write,
};
@@ -25,10 +26,11 @@ pub enum TypeCast<'a> {
/// /** @type {Document} */ (root.head ?? fallback)
/// ^^^^^^^^^^^^^^^^^^^^^ Target
/// ```
/// The slice holds the comments still to be printed;
/// `pending` holds the comments still to be printed;
/// it is empty when the cast comment was already printed
/// (re-entry from [`format_type_cast_comment_node`], or printed by an ancestor).
Target(&'a [Comment]),
/// `cast_comment` is the cast comment either way.
Target { pending: &'a [Comment], cast_comment: &'a Comment },
/// The node's unprinted leading comments end with a cast comment that binds
/// to an inner expression (its parenthesis closes before the node ends):
/// ```js
@@ -51,7 +53,7 @@ pub enum TypeCast<'a> {
impl TypeCast<'_> {
pub fn is_target(&self) -> bool {
matches!(self, TypeCast::Target(_))
matches!(self, TypeCast::Target { .. })
}
}
@@ -70,8 +72,8 @@ pub fn classify_type_cast<'a>(span: Span, f: &JsFormatter<'_, 'a>) -> TypeCast<'
&& comments.is_type_cast_comment_followed_by_paren(last_printed_comment)
{
match classify_cast_comment_gap(last_printed_comment.span.end, span.start, f) {
CastCommentGap::ParensAndTrivia if is_followed_by_closing_paren(span, f) => {
return TypeCast::Target(&[]);
CastCommentGap::ParensAndTrivia if comments.is_followed_by_closing_paren(span.end) => {
return TypeCast::Target { pending: &[], cast_comment: last_printed_comment };
}
// The printed comment is unrelated to this node;
// fall through to look for the node's own unprinted cast comment.
@@ -90,8 +92,11 @@ pub fn classify_type_cast<'a>(span: Span, f: &JsFormatter<'_, 'a>) -> TypeCast<'
CastCommentGap::Trivia => {
TypeCast::BindsInner(&unprinted_comments[..=type_cast_comment_index])
}
CastCommentGap::ParensAndTrivia if is_followed_by_closing_paren(span, f) => {
TypeCast::Target(&unprinted_comments[..=type_cast_comment_index])
CastCommentGap::ParensAndTrivia if comments.is_followed_by_closing_paren(span.end) => {
TypeCast::Target {
pending: &unprinted_comments[..=type_cast_comment_index],
cast_comment: type_cast_comment,
}
}
_ => TypeCast::None,
};
@@ -100,11 +105,31 @@ pub fn classify_type_cast<'a>(span: Span, f: &JsFormatter<'_, 'a>) -> TypeCast<'
TypeCast::None
}
/// Whether the next non-whitespace byte after the node (skipping comments adjacent to it) is `)`.
/// i.e. source parentheses close right after the node, as a cast target requires.
fn is_followed_by_closing_paren(span: Span, f: &JsFormatter<'_, '_>) -> bool {
f.source_text().next_non_whitespace_byte_is(span.end, b')')
|| f.context().comments().comments_before_closing_paren(span.end).is_some()
/// A cast target's pending comments and the span of its cast parentheses
/// (from the first `(` after the cast comment to after the matching `)`), `None` for any other node.
fn cast_target_parens<'a>(span: Span, f: &JsFormatter<'_, 'a>) -> Option<(&'a [Comment], Span)> {
// Every cast target closes with `)`: the cheapest rejection, before any comment lookup
if !f.context().comments().is_followed_by_closing_paren(span.end) {
return None;
}
let TypeCast::Target { pending, cast_comment } = classify_type_cast(span, f) else {
return None;
};
cast_parens_span(cast_comment.span.end, span, f).map(|parens| (pending, parens))
}
/// The position after the cast parentheses of a cast target, `None` for any other node.
///
/// A site that hides a child's trailing comments must hide from here, not from the child's span end:
/// the target prints its parens and the comments inside them,
/// and hiding those blinds its trailing-comments pass, so the comment escapes the parens.
/// ```js
/// const x = /** @type {T} */ (
/// value // must stay inside
/// );
/// ```
pub fn cast_target_end(span: Span, f: &JsFormatter<'_, '_>) -> Option<u32> {
cast_target_parens(span, f).map(|(_, parens)| parens.end)
}
/// Formats a node that is the target of a JSDoc type cast (see [`TypeCast::Target`]):
@@ -120,7 +145,8 @@ pub fn format_type_cast_comment_node<'a>(
f: &mut JsFormatter<'_, 'a>,
) -> bool {
// Check if this node is a cast target and get the comments to print
let TypeCast::Target(type_cast_comments) = classify_type_cast(node.span(), f) else {
let TypeCast::Target { pending: type_cast_comments, .. } = classify_type_cast(node.span(), f)
else {
return false;
};
@@ -132,11 +158,23 @@ pub fn format_type_cast_comment_node<'a>(
let span = node.span();
f.context_mut().comments_mut().mark_as_type_cast_node(node);
let format_node = format_with(|f| {
node.fmt(f);
// Own-line comments left before the cast `)` have no following node inside the parens to lead;
// deferred, they would lead the next sibling (a call's arguments) outside the parens.
// Same-line ones are already printed by the node's trailing pass.
write_comments_before_closing_paren(f, span.end);
});
// https://github.com/prettier/prettier/blob/7584432401a47a26943dd7a9ca9a8e032ead7285/src/language-js/print/estree.js#L117-L120
if is_object_or_array_expression && !f.comments().has_comment_before(span.start) {
write!(f, group(&format_args!("(", &format_with(|f| node.fmt(f)), ")")));
// A comment before the cast `)` (the only thing that can precede it) breaks the hug too
let hugs = is_object_or_array_expression
&& !f.comments().has_comment_before(span.start)
&& f.source_text().next_non_whitespace_byte_is(span.end, b')');
if hugs {
write!(f, group(&format_args!("(", &format_node, ")")));
} else {
write!(f, group(&format_args!("(", soft_block_indent(&format_with(|f| node.fmt(f))), ")")));
write!(f, group(&format_args!("(", soft_block_indent(&format_node), ")")));
}
true
@@ -154,9 +192,8 @@ pub fn format_type_cast_comment_node<'a>(
/// An empty `pending` (the cast comment printed by an ancestor) cannot reach here suppressed,
/// and falls through regardless.
pub fn write_suppressed_cast_target(span: Span, f: &mut JsFormatter<'_, '_>) -> bool {
if let TypeCast::Target(pending) = classify_type_cast(span, f)
&& let Some(cast_comment) = pending.last()
&& let Some(verbatim_span) = cast_parens_span(cast_comment.span.end, span, f)
if let Some((pending, verbatim_span)) = cast_target_parens(span, f)
&& !pending.is_empty()
{
write!(f, [FormatLeadingComments::Comments(pending)]);
FormatSuppressedNode(verbatim_span).fmt(f);
@@ -0,0 +1,74 @@
// A comment inside the parens of a JSDoc type cast never drops the parens
// (without them tsc ignores the cast) and stays inside them, like Prettier.
// The traps are the sites that hide a child's trailing comments while printing it:
// statement terminators, callees, heritage (issues #26274, #26275).
// Statement terminators: same-line and own-line comments stay inside.
const declared = /** @type {string} */ (
value // trailing
);
const ownLine = /** @type {string} */ (
value
// own-line
);
/** @type {string} */ (
statement // trailing
);
export default /** @type {string} */ (
value // trailing
);
chained = other = /** @type {string} */ (
value // trailing
);
const arrow = () => /** @type {string} */ (
value // trailing
);
class Property {
field = /** @type {string} */ (
value // trailing
);
}
function returned() {
return assigned = /** @type {string} */ (
value // trailing
);
}
// No source `;` (ASI): the cast `)` is not a dropped paren.
const asi = /** @type {string} */ (
value // trailing
)
// Object and array targets lose the hug form, the comment breaks the parens.
const object = /** @type {{ value: string }} */ ({ value } // trailing
);
const array = /** @type {string[]} */ ([value]
// own-line
);
const hugged = /** @type {{ value: string }} */ ({ value });
// Callee: the comment stays with the callee, not the arguments.
const called = /** @type {(a: number) => number} */ (
fn
// own-line
)(1);
const calledTrailing = /** @type {(a: number) => number} */ (
fn // trailing
)(1);
// A comment between the cast `)` and the arguments stays outside.
const between = /** @type {(a: number) => number} */ (
fn // trailing
) /* between */ (1);
// Other hiding sites (`FormatNodeWithoutTrailingComments`): class heritage.
class Heritage extends /** @type {typeof Base} */ (
Base // trailing
) {}
// Member object.
const member = foo(/** @type {X} */ (
fn
// own-line
).prop);
// Same-line comments after the cast `)` still move behind the `;`.
const after = /** @type {string} */ (value) /* after */;
@@ -0,0 +1,249 @@
---
source: crates/oxc_formatter/tests/fixtures/mod.rs
---
==================== Input ====================
// A comment inside the parens of a JSDoc type cast never drops the parens
// (without them tsc ignores the cast) and stays inside them, like Prettier.
// The traps are the sites that hide a child's trailing comments while printing it:
// statement terminators, callees, heritage (issues #26274, #26275).
// Statement terminators: same-line and own-line comments stay inside.
const declared = /** @type {string} */ (
value // trailing
);
const ownLine = /** @type {string} */ (
value
// own-line
);
/** @type {string} */ (
statement // trailing
);
export default /** @type {string} */ (
value // trailing
);
chained = other = /** @type {string} */ (
value // trailing
);
const arrow = () => /** @type {string} */ (
value // trailing
);
class Property {
field = /** @type {string} */ (
value // trailing
);
}
function returned() {
return assigned = /** @type {string} */ (
value // trailing
);
}
// No source `;` (ASI): the cast `)` is not a dropped paren.
const asi = /** @type {string} */ (
value // trailing
)
// Object and array targets lose the hug form, the comment breaks the parens.
const object = /** @type {{ value: string }} */ ({ value } // trailing
);
const array = /** @type {string[]} */ ([value]
// own-line
);
const hugged = /** @type {{ value: string }} */ ({ value });
// Callee: the comment stays with the callee, not the arguments.
const called = /** @type {(a: number) => number} */ (
fn
// own-line
)(1);
const calledTrailing = /** @type {(a: number) => number} */ (
fn // trailing
)(1);
// A comment between the cast `)` and the arguments stays outside.
const between = /** @type {(a: number) => number} */ (
fn // trailing
) /* between */ (1);
// Other hiding sites (`FormatNodeWithoutTrailingComments`): class heritage.
class Heritage extends /** @type {typeof Base} */ (
Base // trailing
) {}
// Member object.
const member = foo(/** @type {X} */ (
fn
// own-line
).prop);
// Same-line comments after the cast `)` still move behind the `;`.
const after = /** @type {string} */ (value) /* after */;
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
// A comment inside the parens of a JSDoc type cast never drops the parens
// (without them tsc ignores the cast) and stays inside them, like Prettier.
// The traps are the sites that hide a child's trailing comments while printing it:
// statement terminators, callees, heritage (issues #26274, #26275).
// Statement terminators: same-line and own-line comments stay inside.
const declared = /** @type {string} */ (
value // trailing
);
const ownLine = /** @type {string} */ (
value
// own-line
);
/** @type {string} */ (
statement // trailing
);
export default /** @type {string} */ (
value // trailing
);
chained = other = /** @type {string} */ (
value // trailing
);
const arrow = () =>
/** @type {string} */ (
value // trailing
);
class Property {
field = /** @type {string} */ (
value // trailing
);
}
function returned() {
return (assigned = /** @type {string} */ (
value // trailing
));
}
// No source `;` (ASI): the cast `)` is not a dropped paren.
const asi = /** @type {string} */ (
value // trailing
);
// Object and array targets lose the hug form, the comment breaks the parens.
const object = /** @type {{ value: string }} */ (
{ value } // trailing
);
const array = /** @type {string[]} */ (
[value]
// own-line
);
const hugged = /** @type {{ value: string }} */ ({ value });
// Callee: the comment stays with the callee, not the arguments.
const called = /** @type {(a: number) => number} */ (
fn
// own-line
)(1);
const calledTrailing = /** @type {(a: number) => number} */ (
fn // trailing
)(1);
// A comment between the cast `)` and the arguments stays outside.
const between = /** @type {(a: number) => number} */ (
fn // trailing
)(/* between */ 1);
// Other hiding sites (`FormatNodeWithoutTrailingComments`): class heritage.
class Heritage
extends /** @type {typeof Base} */ (
Base // trailing
) {}
// Member object.
const member = foo(
/** @type {X} */ (
fn
// own-line
).prop,
);
// Same-line comments after the cast `)` still move behind the `;`.
const after = /** @type {string} */ (value); /* after */
-------------------
{ printWidth: 100 }
-------------------
// A comment inside the parens of a JSDoc type cast never drops the parens
// (without them tsc ignores the cast) and stays inside them, like Prettier.
// The traps are the sites that hide a child's trailing comments while printing it:
// statement terminators, callees, heritage (issues #26274, #26275).
// Statement terminators: same-line and own-line comments stay inside.
const declared = /** @type {string} */ (
value // trailing
);
const ownLine = /** @type {string} */ (
value
// own-line
);
/** @type {string} */ (
statement // trailing
);
export default /** @type {string} */ (
value // trailing
);
chained = other = /** @type {string} */ (
value // trailing
);
const arrow = () =>
/** @type {string} */ (
value // trailing
);
class Property {
field = /** @type {string} */ (
value // trailing
);
}
function returned() {
return (assigned = /** @type {string} */ (
value // trailing
));
}
// No source `;` (ASI): the cast `)` is not a dropped paren.
const asi = /** @type {string} */ (
value // trailing
);
// Object and array targets lose the hug form, the comment breaks the parens.
const object = /** @type {{ value: string }} */ (
{ value } // trailing
);
const array = /** @type {string[]} */ (
[value]
// own-line
);
const hugged = /** @type {{ value: string }} */ ({ value });
// Callee: the comment stays with the callee, not the arguments.
const called = /** @type {(a: number) => number} */ (
fn
// own-line
)(1);
const calledTrailing = /** @type {(a: number) => number} */ (
fn // trailing
)(1);
// A comment between the cast `)` and the arguments stays outside.
const between = /** @type {(a: number) => number} */ (
fn // trailing
)(/* between */ 1);
// Other hiding sites (`FormatNodeWithoutTrailingComments`): class heritage.
class Heritage
extends /** @type {typeof Base} */ (
Base // trailing
) {}
// Member object.
const member = foo(
/** @type {X} */ (
fn
// own-line
).prop,
);
// Same-line comments after the cast `)` still move behind the `;`.
const after = /** @type {string} */ (value); /* after */
===================== End =====================
+6 -3
View File
@@ -66,11 +66,14 @@ impl<'a> SourceText<'a> {
}
// Byte checking
/// First non-whitespace byte at or after position
pub fn next_non_whitespace_byte(&self, position: u32) -> Option<u8> {
self.bytes_from(position).find(|byte| !byte.is_ascii_whitespace())
}
/// Check if first non-whitespace byte at position matches expected
pub fn next_non_whitespace_byte_is(&self, position: u32, expected_byte: u8) -> bool {
self.bytes_from(position)
.find(|byte| !byte.is_ascii_whitespace())
.is_some_and(|b| b == expected_byte)
self.next_non_whitespace_byte(position) == Some(expected_byte)
}
// Byte range operations