From 2d18144a31a6181de35e3e3ed914acae8adc59bd Mon Sep 17 00:00:00 2001 From: Dunqing <29533304+Dunqing@users.noreply.github.com> Date: Mon, 22 Sep 2025 04:24:14 +0000 Subject: [PATCH] feat(formatter): implement formatting for `TSUnionType` (#13893) --- .../oxc_formatter/src/formatter/comments.rs | 21 ++ crates/oxc_formatter/src/generated/format.rs | 10 + .../oxc_formatter/src/parentheses/ts_type.rs | 22 +- crates/oxc_formatter/src/write/mod.rs | 21 +- crates/oxc_formatter/src/write/union_type.rs | 224 ++++++++++++++++++ .../src/generators/formatter/format.rs | 2 +- .../snapshots/prettier.ts.snap.md | 42 ++-- 7 files changed, 290 insertions(+), 52 deletions(-) create mode 100644 crates/oxc_formatter/src/write/union_type.rs diff --git a/crates/oxc_formatter/src/formatter/comments.rs b/crates/oxc_formatter/src/formatter/comments.rs index c534079581384..d7aa21eb64fcd 100644 --- a/crates/oxc_formatter/src/formatter/comments.rs +++ b/crates/oxc_formatter/src/formatter/comments.rs @@ -173,6 +173,27 @@ impl<'a> Comments<'a> { &self.unprinted_comments()[..index] } + /// Returns end-of-line comments that are after the given position (excluding printed ones). + pub fn end_of_line_comments_after(&self, mut pos: u32) -> &'a [Comment] { + let comments = self.unprinted_comments(); + for (index, comment) in comments.iter().enumerate() { + if self + .source_text + .all_bytes_match(pos, comment.span.start, |b| matches!(b, b'\t' | b' ' | b')')) + { + if !self.source_text.is_own_line_comment(comment) + && (comment.is_line() || self.source_text.is_end_of_line_comment(comment)) + { + return &comments[..=index]; + } + pos = comment.span.end; + } else { + break; + } + } + &[] + } + /// Returns comments that start after the given position (excluding printed ones). pub fn comments_after(&self, pos: u32) -> &'a [Comment] { let comments = self.unprinted_comments(); diff --git a/crates/oxc_formatter/src/generated/format.rs b/crates/oxc_formatter/src/generated/format.rs index 1cd7995a76c5e..f3d2dad942b9d 100644 --- a/crates/oxc_formatter/src/generated/format.rs +++ b/crates/oxc_formatter/src/generated/format.rs @@ -3786,9 +3786,19 @@ impl<'a> Format<'a> for AstNode<'a, TSConditionalType<'a>> { impl<'a> Format<'a> for AstNode<'a, TSUnionType<'a>> { fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { let is_suppressed = f.comments().is_suppressed(self.span().start); + if !is_suppressed && format_type_cast_comment_node(self, false, f)? { + return Ok(()); + } self.format_leading_comments(f)?; + let needs_parentheses = self.needs_parentheses(f); + if needs_parentheses { + "(".fmt(f)?; + } let result = if is_suppressed { FormatSuppressedNode(self.span()).fmt(f) } else { self.write(f) }; + if needs_parentheses { + ")".fmt(f)?; + } self.format_trailing_comments(f)?; result } diff --git a/crates/oxc_formatter/src/parentheses/ts_type.rs b/crates/oxc_formatter/src/parentheses/ts_type.rs index 812b7f1c41d63..4a367ebc835ef 100644 --- a/crates/oxc_formatter/src/parentheses/ts_type.rs +++ b/crates/oxc_formatter/src/parentheses/ts_type.rs @@ -60,12 +60,22 @@ impl<'a> NeedsParentheses<'a> for AstNode<'a, TSConstructorType<'a>> { impl<'a> NeedsParentheses<'a> for AstNode<'a, TSUnionType<'a>> { fn needs_parentheses(&self, f: &Formatter<'_, 'a>) -> bool { - matches!( - self.parent, - AstNodes::TSArrayType(_) - | AstNodes::TSTypeOperator(_) - | AstNodes::TSIndexedAccessType(_) - ) + match self.parent { + AstNodes::TSUnionType(union) => self.types.len() > 1 && union.types.len() > 1, + AstNodes::TSIntersectionType(intersection) => { + self.types.len() > 1 && intersection.types.len() > 1 + } + parent => operator_type_or_higher_needs_parens(self.span(), parent), + } + } +} + +/// Returns `true` if a TS primary type needs parentheses +fn operator_type_or_higher_needs_parens(span: Span, parent: &AstNodes) -> bool { + match parent { + AstNodes::TSArrayType(_) | AstNodes::TSTypeOperator(_) | AstNodes::TSRestType(_) => true, + AstNodes::TSIndexedAccessType(indexed) => indexed.object_type.span() == span, + _ => false, } } diff --git a/crates/oxc_formatter/src/write/mod.rs b/crates/oxc_formatter/src/write/mod.rs index bbeb4d96d0ed7..90e4d59a78976 100644 --- a/crates/oxc_formatter/src/write/mod.rs +++ b/crates/oxc_formatter/src/write/mod.rs @@ -26,6 +26,7 @@ mod switch_statement; mod template; mod try_statement; mod type_parameters; +mod union_type; mod utils; mod variable_declaration; @@ -1209,26 +1210,6 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSConditionalType<'a>> { } } -impl<'a> FormatWrite<'a> for AstNode<'a, TSUnionType<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - let mut types = self.types().iter(); - if self.needs_parentheses(f) { - write!(f, "(")?; - } - if let Some(item) = types.next() { - write!(f, item)?; - - for item in types { - write!(f, [" | ", item])?; - } - } - if self.needs_parentheses(f) { - write!(f, ")")?; - } - Ok(()) - } -} - impl<'a> FormatWrite<'a> for AstNode<'a, TSIntersectionType<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { let mut types = self.types().iter(); diff --git a/crates/oxc_formatter/src/write/union_type.rs b/crates/oxc_formatter/src/write/union_type.rs new file mode 100644 index 0000000000000..a24d8812249d2 --- /dev/null +++ b/crates/oxc_formatter/src/write/union_type.rs @@ -0,0 +1,224 @@ +use oxc_allocator::Vec; +use oxc_ast::ast::*; +use oxc_span::GetSpan; + +use crate::{ + format_args, + formatter::{FormatResult, Formatter, prelude::*, trivia::FormatTrailingComments}, + generated::ast_nodes::{AstNode, AstNodes}, + parentheses::NeedsParentheses, + write, + write::FormatWrite, +}; + +impl<'a> FormatWrite<'a> for AstNode<'a, TSUnionType<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + let types = self.types(); + + if types.len() == 1 { + return write!(f, self.types().first()); + } + + // ```ts + // { + // a: string + // } | null | void + // ``` + // should be inlined and not be printed in the multi-line variant + let should_hug = should_hug_type(self); + if should_hug { + return format_union_types(self.types(), true, f); + } + + // Find the head of the nest union type chain + // ```js + // type Foo = | (| (A | B)) + // ^^^^^ + // ``` + // If the current union type is `A | B` + // - `A | B` is the inner union type of `| (A | B)` + // - `| (A | B)` is the inner union type of `| (| (A | B))` + // + // So the head of the current nested union type chain is `| (| (A | B))` + // if we encounter a leading comment when navigating up the chain, + // we consider the current union type as having leading comments + let mut has_leading_comments = f.comments().has_comment_before(self.span().start); + let mut union_type_at_top = self; + + while let AstNodes::TSUnionType(parent) = union_type_at_top.parent { + if parent.types().len() == 1 { + if f.comments().has_comment_before(parent.span().start) { + has_leading_comments = true; + } + union_type_at_top = parent; + } else { + break; + } + } + + let should_indent = { + let parent = union_type_at_top.parent; + + // These parents have indent for their content, so we don't need to indent here + !match parent { + AstNodes::TSTypeAliasDeclaration(_) => has_leading_comments, + AstNodes::TSTypeAssertion(_) + | AstNodes::TSTupleType(_) + | AstNodes::TSTypeParameterInstantiation(_) => true, + _ => false, + } + }; + + let types = format_with(|f| { + if has_leading_comments { + write!(f, [soft_line_break()])?; + } + + let leading_soft_line_break_or_space = should_indent && !has_leading_comments; + + let separator = format_with(|f| { + if leading_soft_line_break_or_space { + write!(f, [soft_line_break_or_space()])?; + } + write!(f, [text("|"), space()]) + }); + + write!(f, [if_group_breaks(&separator)])?; + + format_union_types(types, false, f) + }); + + let content = format_with(|f| { + // it is necessary to add parentheses for unions in intersections + // ```ts + // type Some = B & (C | A) & D + // ``` + if self.needs_parentheses(f) { + return write!(f, [indent(&types), soft_line_break()]); + } + + let is_inside_complex_tuple_type = match self.parent { + AstNodes::TSTupleType(tuple) => tuple.element_types().len() > 1, + _ => false, + }; + + if is_inside_complex_tuple_type { + write!( + f, + [ + indent(&format_args!( + if_group_breaks(&format_args!(text("("), soft_line_break())), + types + )), + soft_line_break(), + if_group_breaks(&text(")")) + ] + ) + } else if should_indent { + write!(f, [indent(&types)]) + } else { + write!(f, [types]) + } + }); + + write!(f, [group(&content)]) + } +} + +fn should_hug_type(node: &AstNode<'_, TSUnionType<'_>>) -> bool { + // Simple heuristic: hug unions with object types and simple nullable types + let types = node.types(); + + if types.len() <= 3 { + let has_object_type = types.iter().any(|t| matches!(t.as_ref(), TSType::TSTypeLiteral(_))); + + let has_simple_types = types.iter().any(|t| { + matches!( + t.as_ref(), + TSType::TSNullKeyword(_) | TSType::TSUndefinedKeyword(_) | TSType::TSVoidKeyword(_) + ) + }); + + return has_object_type && has_simple_types; + } + + false +} + +pub struct FormatTSType<'a, 'b> { + next_node_span: Option, + element: &'b AstNode<'a, TSType<'a>>, + should_hug: bool, +} + +impl<'a> Format<'a> for FormatTSType<'a, '_> { + fn fmt(&self, f: &mut crate::formatter::Formatter<'_, 'a>) -> FormatResult<()> { + let format_element = format_once(|f| { + self.element.fmt(f)?; + Ok(()) + }); + if self.should_hug { + write!(f, [format_element])?; + } else { + write!(f, [align(2, &format_element)])?; + } + + if let Some(next_node_span) = self.next_node_span { + let comments_before_separator = + f.context().comments().comments_before_character(self.element.span().end, b'|'); + FormatTrailingComments::Comments(comments_before_separator).fmt(f)?; + + // ```ts + // type Some = A | + // // comment + // B + // ``` + // to + // ```ts + // type Some = + // | A + // // comment + // | B + // ``` + // If there is a leading own line comment between `|` and the next node, we need to put print comments + // before `|` instead of after it. + if f.comments().has_leading_own_line_comment(next_node_span.start) { + let comments = f.context().comments().comments_before(next_node_span.start); + FormatTrailingComments::Comments(comments).fmt(f)?; + } + + if self.should_hug { + write!(f, [space()])?; + } else { + write!(f, [soft_line_break_or_space()])?; + } + write!(f, ["|"]) + } else { + // ```ts + // type Foo = ( + // | "thing1" // comment1 + // | "thing2" // comment2 + // ^^^^^^^^^^^ the following logic is to print comment2, + // )[]; // comment 3 + //``` + // TODO: We may need to tweak `AstNode<'a, Vec<'a, T>>` iterator as some of Vec's last elements should have the following span. + let comments = + f.context().comments().end_of_line_comments_after(self.element.span().end); + FormatTrailingComments::Comments(comments).fmt(f) + } + } +} + +fn format_union_types<'a>( + node: &AstNode<'a, Vec<'a, TSType<'a>>>, + should_hug: bool, + f: &mut Formatter<'_, 'a>, +) -> FormatResult<()> { + f.join_with(space()) + .entries(node.iter().enumerate().map(|(index, item)| FormatTSType { + next_node_span: node.get(index + 1).map(GetSpan::span), + element: item, + should_hug, + })) + .finish() +} diff --git a/tasks/ast_tools/src/generators/formatter/format.rs b/tasks/ast_tools/src/generators/formatter/format.rs index 210b9eb745472..4ec6e0f2d7b46 100644 --- a/tasks/ast_tools/src/generators/formatter/format.rs +++ b/tasks/ast_tools/src/generators/formatter/format.rs @@ -38,7 +38,7 @@ const AST_NODE_WITHOUT_PRINTING_COMMENTS_LIST: &[&str] = &[ ]; const AST_NODE_NEEDS_PARENTHESES: &[&str] = - &["TSTypeAssertion", "TSInferType", "TSConditionalType"]; + &["TSTypeAssertion", "TSInferType", "TSConditionalType", "TSUnionType"]; const NEEDS_IMPLEMENTING_FMT_WITH_OPTIONS: phf::Map<&'static str, &'static str> = phf::phf_map! { "ArrowFunctionExpression" => "FormatJsArrowFunctionExpressionOptions", diff --git a/tasks/prettier_conformance/snapshots/prettier.ts.snap.md b/tasks/prettier_conformance/snapshots/prettier.ts.snap.md index a02648ecf98ea..2609214d2327b 100644 --- a/tasks/prettier_conformance/snapshots/prettier.ts.snap.md +++ b/tasks/prettier_conformance/snapshots/prettier.ts.snap.md @@ -1,4 +1,4 @@ -ts compatibility: 406/573 (70.86%) +ts compatibility: 414/573 (72.25%) # Failed @@ -16,13 +16,13 @@ ts compatibility: 406/573 (70.86%) | typescript/argument-expansion/arrow-with-return-type.ts | 💥 | 89.47% | | typescript/arrow/16067.ts | 💥💥 | 95.92% | | typescript/arrow/comments.ts | 💥✨ | 44.44% | -| typescript/as/as.ts | 💥 | 85.04% | +| typescript/as/as.ts | 💥 | 89.06% | | typescript/as/assignment2.ts | 💥 | 94.12% | | typescript/as/expression-statement.ts | 💥 | 75.00% | -| typescript/assignment/issue-10846.ts | 💥 | 63.16% | +| typescript/assignment/issue-10846.ts | 💥 | 60.00% | | typescript/assignment/issue-10848.tsx | 💥 | 52.12% | | typescript/assignment/issue-10850.ts | 💥 | 50.00% | -| typescript/cast/generic-cast.ts | 💥 | 39.60% | +| typescript/cast/generic-cast.ts | 💥 | 39.24% | | typescript/cast/tuple-and-record.ts | 💥 | 0.00% | | typescript/chain-expression/call-expression.ts | 💥 | 68.75% | | typescript/chain-expression/member-expression.ts | 💥 | 65.67% | @@ -36,13 +36,12 @@ ts compatibility: 406/573 (70.86%) | typescript/comments/16207.ts | 💥 | 71.43% | | typescript/comments/16889.ts | 💥 | 62.61% | | typescript/comments/declare_function.ts | 💥 | 66.67% | -| typescript/comments/issues.ts | 💥 | 27.27% | | typescript/comments/location.ts | 💥 | 95.00% | | typescript/comments/mapped_types.ts | 💥 | 58.82% | | typescript/comments/method_types.ts | 💥 | 79.49% | | typescript/comments/type-parameters.ts | 💥 | 36.36% | | typescript/comments/type_literals.ts | 💥 | 68.97% | -| typescript/comments/union.ts | 💥 | 5.26% | +| typescript/comments/union.ts | 💥 | 75.00% | | typescript/compiler/anyIsAssignableToObject.ts | 💥 | 75.00% | | typescript/compiler/castTest.ts | 💥 | 96.67% | | typescript/compiler/contextualSignatureInstantiation2.ts | 💥 | 88.89% | @@ -69,11 +68,6 @@ ts compatibility: 406/573 (70.86%) | typescript/conformance/types/namespaceExportDeclaration/exportAsNamespace.d.ts | 💥 | 75.00% | | typescript/conformance/types/tuple/wideningTuples1.ts | 💥 | 80.00% | | typescript/conformance/types/typeParameters/typeParameterLists/innerTypeParameterShadowingOuterOne2.ts | 💥 | 93.33% | -| typescript/conformance/types/union/unionTypeCallSignatures.ts | 💥 | 64.95% | -| typescript/conformance/types/union/unionTypeCallSignatures3.ts | 💥 | 68.97% | -| typescript/conformance/types/union/unionTypeConstructSignatures.ts | 💥 | 63.44% | -| typescript/conformance/types/union/unionTypeEquivalence.ts | 💥 | 90.00% | -| typescript/conformance/types/union/unionTypeIndexSignature.ts | 💥 | 68.97% | | typescript/custom/abstract/abstractProperties.ts | 💥 | 75.00% | | typescript/custom/computedProperties/string.ts | 💥 | 73.33% | | typescript/custom/declare/declareModifier.d.ts | 💥 | 88.89% | @@ -97,21 +91,20 @@ ts compatibility: 406/573 (70.86%) | typescript/export/export.ts | 💥 | 85.71% | | typescript/function-type/consistent.ts | 💥 | 70.83% | | typescript/function-type/type-annotation.ts | 💥 | 0.00% | -| typescript/generic/arrow-return-type.ts | 💥 | 80.77% | +| typescript/generic/arrow-return-type.ts | 💥 | 80.00% | | typescript/generic/issue-6899.ts | 💥 | 21.05% | | typescript/generic/object-method.ts | 💥 | 72.73% | -| typescript/generic/ungrouped-parameters.ts | 💥 | 81.48% | | typescript/index-signature/static.ts | 💥 | 66.67% | | typescript/infer-extends/basic.ts | 💥 | 71.43% | | typescript/interface/comments-generic.ts | 💥💥 | 30.00% | | typescript/interface/ignore.ts | 💥💥 | 88.26% | -| typescript/interface/long-type-parameters/long-type-parameters.ts | 💥💥 | 52.10% | +| typescript/interface/long-type-parameters/long-type-parameters.ts | 💥💥 | 45.00% | | typescript/interface2/comments-declare.ts | 💥 | 66.67% | | typescript/interface2/comments.ts | 💥 | 78.87% | | typescript/interface2/break/break.ts | 💥💥💥 | 80.23% | | typescript/intersection/intersection-parens.ts | 💥💥 | 59.14% | | typescript/intersection/type-arguments.ts | 💥💥 | 46.67% | -| typescript/intersection/consistent-with-flow/intersection-parens.ts | 💥 | 60.47% | +| typescript/intersection/consistent-with-flow/intersection-parens.ts | 💥 | 69.77% | | typescript/key-remapping-in-mapped-types/key-remapping.ts | 💥 | 23.53% | | typescript/keyword-types/conditional-types.ts | 💥 | 87.50% | | typescript/keywords/keywords-2.ts | 💥 | 79.41% | @@ -133,7 +126,7 @@ ts compatibility: 406/573 (70.86%) | typescript/optional-variance/with-jsx.tsx | 💥 | 81.97% | | typescript/override-modifiers/override-modifier.ts | 💥 | 25.00% | | typescript/prettier-ignore/mapped-types.ts | 💥 | 54.72% | -| typescript/prettier-ignore/prettier-ignore-nested-unions.ts | 💥 | 15.79% | +| typescript/prettier-ignore/prettier-ignore-nested-unions.ts | 💥 | 29.17% | | typescript/prettier-ignore/prettier-ignore-parenthesized-type.ts | 💥 | 0.00% | | typescript/rest-type/complex.ts | 💥 | 0.00% | | typescript/rest-type/infer-type.ts | 💥 | 80.00% | @@ -150,7 +143,7 @@ ts compatibility: 406/573 (70.86%) | typescript/tuple/trailing-comma-trailing-rest.ts | 💥💥💥 | 0.00% | | typescript/tuple/trailing-comma.ts | 💥💥💥 | 61.54% | | typescript/tuple/tuple.ts | 💥💥💥 | 0.00% | -| typescript/type-alias/conditional.ts | 💥 | 23.33% | +| typescript/type-alias/conditional.ts | 💥 | 22.58% | | typescript/type-alias/issue-100857.ts | 💥 | 67.61% | | typescript/type-alias/issue-9874.ts | 💥 | 0.00% | | typescript/type-arguments-bit-shift-left-like/3.ts | 💥 | 0.00% | @@ -158,16 +151,15 @@ ts compatibility: 406/573 (70.86%) | typescript/typeof/typeof.ts | 💥 | 25.00% | | typescript/typeparams/class-method.ts | 💥 | 96.61% | | typescript/typeparams/const.ts | 💥 | 86.15% | -| typescript/typeparams/line-breaking-after-extends-2.ts | 💥 | 21.74% | +| typescript/typeparams/line-breaking-after-extends-2.ts | 💥 | 20.00% | | typescript/typeparams/line-breaking-after-extends.ts | 💥 | 17.14% | | typescript/typeparams/long-function-arg.ts | 💥 | 76.92% | | typescript/typeparams/empty-parameters-with-arrow-function/issue-13817.ts | 💥 | 66.67% | | typescript/typeparams/trailing-comma/type-paramters.ts | 💥💥💥 | 28.57% | -| typescript/union/comments.ts | 💥 | 15.38% | -| typescript/union/inlining.ts | 💥 | 51.97% | -| typescript/union/union-parens.ts | 💥 | 58.00% | -| typescript/union/with-type-params.ts | 💥 | 0.00% | -| typescript/union/consistent-with-flow/prettier-ignore.ts | 💥 | 19.05% | -| typescript/union/consistent-with-flow/single-type.ts | 💥 | 3.39% | -| typescript/union/consistent-with-flow/within-tuple.ts | 💥 | 16.51% | +| typescript/union/comments.ts | 💥 | 84.21% | +| typescript/union/inlining.ts | 💥 | 77.37% | +| typescript/union/union-parens.ts | 💥 | 92.59% | +| typescript/union/with-type-params.ts | 💥 | 37.50% | +| typescript/union/consistent-with-flow/prettier-ignore.ts | 💥 | 38.30% | +| typescript/union/consistent-with-flow/within-tuple.ts | 💥 | 17.50% | | typescript/union/single-type/single-type.ts | 💥 | 66.67% |