Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/tsv_debug/src/cli/commands/gap_audit_known.txt
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ DROPPED :⟨⟩{ annotation,block,jsdoc_cast,multiline
DROPPED :⟨⟩␣ annotation,block,jsdoc_cast,multiline
DROPPED ;#⟨⟩IDENT annotation,block,jsdoc_cast,line,multiline
DROPPED ;}⟨⟩} annotation,block,jsdoc_cast,line,multiline
DROPPED <&⟨⟩␣ line
DROPPED <-⟨⟩NUM annotation,block,jsdoc_cast,line,multiline
DROPPED <⟨⟩IDENT annotation,block,jsdoc_cast,multiline
DROPPED <⟨⟩const annotation,block,jsdoc_cast,multiline
Expand Down
8 changes: 8 additions & 0 deletions crates/tsv_ts/src/parser/expression_type_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ impl<'a, 'arena> Parser<'a, 'arena> {
// whole literal to its follow-token.
b'0'..=b'9' | b'-' => scan_for_closing_angle_bracket(bytes, pos),

// A leading `|`/`&` on the first union/intersection member
// (`f<| A | B>()`, `f<& A & B>()`) — the form prettier itself emits
// whenever such a type argument breaks across lines. Neither byte can
// start an expression, so a `<` followed by one is never a comparison;
// the closing-`>` + follow-token scan still runs, as in every other arm,
// so an unterminated `<` stays unclaimed.
b'|' | b'&' => scan_for_closing_angle_bracket(bytes, pos),

// Not a recognized type argument start
_ => false,
}
Expand Down
31 changes: 19 additions & 12 deletions crates/tsv_ts/src/printer/types/composite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@

use super::super::comments_to_emit_in_range;
use super::helpers::{
should_hug_union_type, type_needs_parens_for_array_element,
type_needs_parens_for_conditional_check, type_needs_parens_for_conditional_extends,
unwrap_parenthesized,
type_needs_parens_for_array_element, type_needs_parens_for_conditional_check,
type_needs_parens_for_conditional_extends, unwrap_parenthesized,
};
use super::{CommentSpacing, Printer};
use crate::ast::internal::{
Expand Down Expand Up @@ -199,7 +198,10 @@ impl<'a> Printer<'a> {
fn build_conditional_check_doc(&self, check: &TSType<'_>) -> DocId {
let d = self.d();
match self.unwrap_redundant_parens(check) {
TSType::Union(u) if !should_hug_union_type(u) => {
// `union_prints_hugged`, not the bare syntactic `union_hug_shape`: a comment
// that makes the printer expand the members must make this gate hang too, or
// `extends` keeps its operand glued while they explode below it.
TSType::Union(u) if !self.union_prints_hugged(u) => {
let union_doc = self.build_union_type_doc(u);
d.group(d.indent(d.concat(&[d.softline(), union_doc])))
}
Expand Down Expand Up @@ -269,7 +271,10 @@ impl<'a> Printer<'a> {
}
};
match self.unwrap_redundant_parens(branch_type) {
TSType::Union(u) if !should_hug_union_type(u) => {
// `union_prints_hugged`, not the bare syntactic `union_hug_shape` — see
// `build_conditional_check_doc`; here a bare ask left the members one indent
// level short of prettier's.
TSType::Union(u) if !self.union_prints_hugged(u) => {
// `build_union_type_doc` already returns `group(members)` (the bare
// `printed`); the branch supplies only one `indent`, so the member
// group breaks its continuations one level past the operator.
Expand Down Expand Up @@ -768,13 +773,15 @@ impl<'a> Printer<'a> {
match self.unwrap_redundant_parens(type_ann) {
TSType::Union(u) => {
let type_doc = self.build_union_type_doc(u);
// A hugging union (`{ ... } | null`) without forcing line comments
// keeps its inline `: ` since the object owns its own expansion;
// everything else hangs after `:` so it breaks to leading `| `
// instead of gluing (line comments between members force multiline).
if should_hug_union_type(u)
&& !self.union_has_line_comments_between_members(u)
{
// A hugging union (`{ ... } | null`) keeps its inline `: ` since the
// object owns its own expansion; everything else hangs after `:` so
// it breaks to leading `| ` instead of gluing. `union_prints_hugged`
// owns that question whole — this site used to pair the bare
// syntactic shape with its own NARROWER comment scan (line comments
// between members only), which let a block comment between members,
// or a line comment in the leading `|`→first-member gap, read as
// "hug" while the printer expanded them.
if self.union_prints_hugged(u) {
body_parts.push(d.text(" "));
body_parts.push(type_doc);
} else {
Expand Down
2 changes: 1 addition & 1 deletion crates/tsv_ts/src/printer/types/function_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ impl<'a> Printer<'a> {
// NOT hugged (the sanctioned `return_type_generic_union` print-width
// family), and a member/gap comment disqualifies the hug — those fall
// through to the break-after-operator layout that matches prettier there.
if self.union_return_hugs(value_type, u, arrow_end, type_start) {
if self.union_return_hugs(value_type, arrow_end, type_start) {
return joined(d.text(arrow_sp), type_doc);
}
let hung = match comments_doc {
Expand Down
57 changes: 38 additions & 19 deletions crates/tsv_ts/src/printer/types/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,25 @@ pub fn is_simple_type_arg(ty: &TSType<'_>) -> bool {
|| matches!(unwrapped, TSType::TypeReference(r) if r.type_arguments.is_none())
}

/// Prettier's `shouldHugUnionType` criterion for a single type argument: a union with
/// exactly one brace-delimited member and only void-like siblings (`{…} | null`,
/// `null | {…}`, `{…} | void`), per [`should_hug_union_type`]. Such a union inlines
/// atomically — the object member carries its own group and breaks block-style inside
/// the hugged `<…>`, so the `<…>` itself never needs a break point. Parenthesized
/// wrappers are unwrapped first. The single source of truth shared by the
/// call/`new`/instantiation type-argument builder and the type-position builder so the
/// two agree by construction. Prettier ref: `shouldHugType` → `shouldHugUnionType`.
pub fn is_hugging_union_type_arg(ty: &TSType<'_>) -> bool {
matches!(unwrap_parenthesized(ty), TSType::Union(u)
if should_hug_union_type(u)
&& u.types.iter().any(|t| matches!(t, TSType::TypeLiteral(_) | TSType::Mapped(_))))
/// Whether any union member is **brace-delimited** (`TypeLiteral`/`Mapped`) — the extra
/// narrowing a *type-argument* hug requires on top of [`union_hug_shape`]: the object
/// member carries its own group and breaks block-style inside the hugged `<…>`, so the
/// `<…>` itself never needs a break point.
///
/// **Deliberately stricter than** prettier's `isObjectLikeType`, which also accepts a bare
/// `TSTypeReference`: excluding it is the sanctioned `return_type_generic_union` print-width
/// family (a `Promise<…> | null` argument keeps its break point). Don't widen it to match
/// prettier.
///
/// Sole caller is
/// [`Printer::type_arg_union_prints_hugged`](super::super::Printer::type_arg_union_prints_hugged)
/// — this is one *clause* of that gate, never the gate. It is safe to ask bare (unlike
/// [`union_hug_shape`], it makes no claim to answer "does this hug?").
pub(super) fn union_has_brace_member(union: &TSUnionType<'_>) -> bool {
union
.types
.iter()
.any(|t| matches!(t, TSType::TypeLiteral(_) | TSType::Mapped(_)))
}

/// Find the `TSParenthesizedType` that directly wraps `ts_type`'s underlying type,
Expand Down Expand Up @@ -142,15 +149,27 @@ pub fn is_huggable_type(ts_type: &TSType<'_>) -> bool {
matches!(ts_type, TSType::TypeLiteral(_) | TSType::Mapped(_))
}

/// Check if a union type should be "hugged" — formatted as `A | B | C` inline
/// even when it breaks, rather than using the multi-line `| A\n| B\n| C` format.
/// The **syntactic shape** a hugging union must have — exactly one object-like
/// member (`TSTypeLiteral` or `TSTypeReference`) with only void siblings (`void`,
/// `null`), e.g. `{ name: string; value: number } | null`.
///
/// Matches Prettier's `shouldHugUnionType`: hugs when there's exactly one
/// object-like type (TSTypeLiteral or TSTypeReference) and all other members
/// are void types (void, null).
/// ⚠️ **Necessary, never sufficient — do not use this as a layout gate.** This is
/// Prettier's `shouldHugUnionType` (`utilities/union-type-print.js`) **minus its
/// first clause**, `types.some((n) => hasComment(n))`. That clause needs the
/// comment table, which a free function has no access to; it lives in
/// [`Printer::union_prints_hugged`](super::super::Printer::union_prints_hugged),
/// which pairs this shape with the comment checks and is the single source of
/// truth for *whether the hug actually happens*.
///
/// Example: `{ name: string; value: number } | null` stays hugged.
pub fn should_hug_union_type(union: &TSUnionType<'_>) -> bool {
/// A layout gate that asks this predicate alone re-derives the hug with a subset
/// of the rule, so it answers "hug" for a union the printer then expands: the
/// keyword keeps its operand glued while the members explode below it
/// (`type A = Foo<| {…} /* c */⏎| null>`). That is a bug class, not a hypothetical
/// — five gates asked it bare. Every caller must pair it with `union_prints_hugged`
/// (see [`Self::union_return_hugs`](super::super::Printer::union_return_hugs) and
/// [`Printer::type_arg_union_prints_hugged`](super::super::Printer::type_arg_union_prints_hugged)
/// for the two shapes that do).
pub(super) fn union_hug_shape(union: &TSUnionType<'_>) -> bool {
// Find exactly one object-like type
let mut object_idx = None;
for (i, t) in union.types.iter().enumerate() {
Expand Down
2 changes: 1 addition & 1 deletion crates/tsv_ts/src/printer/types/type_annotation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ impl<'a> Printer<'a> {
// `return_type_generic_union` print-width family, handled by the
// `union_prints_hugged` branch below), and any comment that makes the printer
// decline the hug disqualifies it here too.
if self.union_return_hugs(value_type, u, colon_end, value_type_start) {
if self.union_return_hugs(value_type, colon_end, value_type_start) {
return match comments_doc {
Some(c) => d.concat(&[d.text(": "), c, type_doc]),
None => d.concat(&[d.text(": "), type_doc]),
Expand Down
6 changes: 3 additions & 3 deletions crates/tsv_ts/src/printer/types/type_arguments.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Type-argument instantiation (`<T, U>`) rendering

use super::Printer;
use super::helpers::{is_hugging_union_type_arg, is_simple_type_arg, unwrap_parenthesized};
use super::helpers::{is_simple_type_arg, unwrap_parenthesized};
use crate::ast::internal::{self, TSType};
use smallvec::smallvec;
use tsv_lang::doc::DocBuf;
Expand Down Expand Up @@ -31,7 +31,7 @@ impl<'a> Printer<'a> {
/// carries its own group and still breaks block-style within the hugged `<…>`.
///
/// Assumes `args.params.len() == 1`; the caller gates on its own single-arg inline
/// predicate (`is_simple_type_arg`, `is_hugging_union_type_arg`, a huggable object,
/// predicate (`is_simple_type_arg`, `union_type_arg_hug_shape`, a huggable object,
/// or always-inline). Own-line and line comments are routed to the multiline path
/// before this runs, so only inline block comments remain to preserve here. Shared by
/// the type-position builder and the call/`new`/instantiation builder.
Expand Down Expand Up @@ -171,7 +171,7 @@ impl<'a> Printer<'a> {
let unwrapped = unwrap_parenthesized(&args.params[0]);
let is_huggable = is_simple_type_arg(&args.params[0])
|| matches!(unwrapped, TSType::TypeLiteral(_) | TSType::Mapped(_))
|| is_hugging_union_type_arg(&args.params[0]);
|| self.type_arg_union_prints_hugged(&args.params[0]);
if is_huggable {
return self.build_single_type_arg_inline(args, has_comments);
}
Expand Down
7 changes: 4 additions & 3 deletions crates/tsv_ts/src/printer/types/type_params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// - Type parameter declarations: `<T, U extends V = W>`
// - Type parameter instantiation (type arguments): `<T, U>`

use super::helpers::{is_hugging_union_type_arg, is_simple_type_arg};
use super::helpers::is_simple_type_arg;
use super::{CommentFilter, CommentSpacing, Printer};
use crate::ast::internal::{self, TSType, TSTypeParameter, TSTypeParameterDeclaration};
use crate::printer::layout::fluid_after_operator;
Expand Down Expand Up @@ -555,7 +555,7 @@ impl<'a> Printer<'a> {
// A single *simple* or *hugged-union* type argument inlines atomically: no
// group, no softlines. Simple = keyword, literal, `this`, or a bare type
// reference (`is_simple_type_arg`); hugged union = `{…} | null` / `null | {…}`
// (`is_hugging_union_type_arg`), whose object member carries its own group and
// (`union_type_arg_hug_shape`), whose object member carries its own group and
// breaks block-style inside the hugged `<…>` rather than breaking the `<…>` onto
// its own lines. Matches Prettier's `shouldInline`/`shouldHugType` and tsv's own
// type-position builder (`build_type_arguments_doc_wrapping`), via the shared
Expand All @@ -567,7 +567,8 @@ impl<'a> Printer<'a> {
// remain — the shared `build_single_type_arg_inline` preserves them. (The single
// brace-delimited object/mapped type is handled by the curly-hug case above.)
if inst.params.len() == 1
&& (is_simple_type_arg(&inst.params[0]) || is_hugging_union_type_arg(&inst.params[0]))
&& (is_simple_type_arg(&inst.params[0])
|| self.type_arg_union_prints_hugged(&inst.params[0]))
{
return self.build_single_type_arg_inline(inst, has_comments);
}
Expand Down
Loading