tsv_ts 0.8.0

TypeScript/JS formatter, type stripper and acorn-compatible parser — part of tsv
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
// Template literal printing for TypeScript
//
// Builds docs for template literals (quasi/interpolation layout, alignment,
// interpolation comments) and tagged template expressions.

use crate::ast::{Expression, ExpressionKind};
use crate::printer::chain::tag_paren_leading_start;
use crate::printer::comments::{CommentSpacing, LeftSpineStep, left_spine_shell_is_comment_free};
use crate::printer::ignore::FrozenOperandPair;
use crate::printer::needs_parens::strip_non_null_wrappers;
use crate::printer::{CommentVec, ParenContext, Printer, SecondTypeArgs};
use smallvec::smallvec;
use tsv_lang::Span;
use tsv_lang::TAB_WIDTH;
use tsv_lang::doc::DocBuf;
use tsv_lang::doc::arena::DocId;
use tsv_lang::printing::visual_width;

impl<'a> Printer<'a> {
    /// Build a Doc for a template literal.
    ///
    /// Each `${…}` interpolation is kept on one line (atomized — see the
    /// `interpolation_has_newline` gate below) unless its source spans multiple
    /// lines or the expression forces a break. When it does break, two
    /// strategies apply based on expression type:
    /// - **Qualifying types** (Identifier, MemberExpression, Conditional, etc.):
    ///   softline wrapping in a regular group — `${/}` breaks when line exceeds width.
    /// - **Non-qualifying types** (CallExpression, chains, arrows, etc.):
    ///   no softlines at `${/}` — expression breaks internally, boundaries hug.
    ///   Matches Prettier's approach where these types keep their doc structure.
    pub(super) fn build_template_literal_doc(
        &self,
        template: &crate::ast::TemplateLiteral<'_>,
    ) -> DocId {
        let d = self.d();
        let mut parts = d.pooled_docbuf();
        parts.push(d.line_suffix_boundary());
        parts.push(d.text("`"));

        // Zero-comment fast gate: one binary search over the whole template window
        // short-circuits the two per-`${}` interpolation comment collects below.
        // Sound because every interpolation's leading/trailing comment sub-range
        // lies within the template span, so no comment anywhere in the window means
        // every per-interpolation collect is empty. Templates are comment-sparse.
        //
        // ⚠️ **ON-PAGE**, not to-emit, because it guards a LAYOUT question as well as the
        // collects (`interpolation_has_comment_on_page` below). An emit-keyed gate blinds
        // every layout gate under it (`docs/comments.md` §The three axes): a glued block
        // comment is OWNED — printed by the value's own doc — so the emit axis skips it
        // while it still occupies the interpolation. On-page ⊇ to-emit, so widening the gate
        // can only run the collects where they would have been empty, never skip a comment.
        let template_has_comments =
            self.has_comments_on_page_between(template.span.start, template.span.end);

        let mut previous_quasi_indent_size: usize = 0;

        for (i, quasi) in template.quasis.iter().enumerate() {
            // Template content — split at newlines and join with literalline
            // (matches Prettier's replaceEndOfLine(node.value.raw) for TemplateElement).
            // Using literalline makes will_break() propagate correctly through
            // containing groups, so chains/calls break when they contain multiline templates.
            parts.push(self.replace_end_of_line(quasi.raw_span, quasi.has_newline));

            // Interpolation
            if i < template.expressions.len() {
                let expr = &template.expressions[i];
                let next_quasi = &template.quasis[i + 1];

                // Calculate indent size from quasi text (Prettier's getIndentSize)
                let text = quasi.raw(self.source);
                let indent_size = if quasi.has_newline {
                    self.get_template_indent_size(text)
                } else {
                    previous_quasi_indent_size
                };
                previous_quasi_indent_size = indent_size;

                // The `${`→expression value head ([`Printer::value_head_frozen_span`]): an
                // own-line directive in the gap freezes the whole expression. The `${` and
                // `}` are the literal's own and stay outside the slice, and so does any pair
                // the expression REQUIRES — the printer's, not the author's, exactly as at
                // every other value head.
                let frozen = self.value_head_frozen_span(quasi.span.end, expr.span());

                // Build expression doc. The clarity parens are asked ONCE, outside the
                // freeze — they are the position's, so the frozen and unfrozen forms cannot
                // disagree about the shell (the rule `Printer::build_frozen_value_doc`
                // states for the heads that reach it through that helper).
                // `build_flat_chain_expression_doc`: a template-literal interpolation is
                // a `shouldNotIndent` position (`parent.type === "TemplateLiteral"`,
                // `shouldNotIndent` in `print/binaryish.js`), so a binary here keeps its continuation lines at the
                // interpolation's own column.
                let expr_inner = match frozen {
                    Some(frozen) => self.build_frozen_expression_doc(expr, frozen),
                    None => self.build_value_with_outermost_owned_comment(expr, || {
                        self.build_flat_chain_expression_doc(expr)
                    }),
                };
                let expr_doc = if self.needs_parens(expr, ParenContext::TemplateLiteralExpression) {
                    d.parens(expr_inner)
                } else {
                    expr_inner
                };

                // Collect comments in the interpolation region (gated: when the
                // template carries no comments at all, both ranges are provably
                // empty, so skip the two collects).
                let (leading_comments, trailing_comments): (CommentVec<'_>, CommentVec<'_>) =
                    if template_has_comments {
                        (
                            self.comments_to_emit_between(quasi.span.end, expr.span().start)
                                .collect(),
                            self.comments_to_emit_between(expr.span().end, next_quasi.span.start)
                                .collect(),
                        )
                    } else {
                        (CommentVec::new(), CommentVec::new())
                    };

                // The LAYOUT question, asked on the **on-page** axis rather than read off the
                // collects above — those are the emit axis, and they print. A glued block
                // comment is OWNED, so the emit axis skips it while it still occupies the
                // interpolation; keying the layout to the collects let
                // `is_template_softline_expression` fall through to prettier's
                // qualifying-type list — which decides this layout only for an interpolation
                // holding NO comment — and collapse a `${…}` the comment had already broken,
                // at every kind outside that list (`${/* c⏎d */ a!}`, `${/* c⏎d */ f()}`).
                //
                // Both halves read the value's PRINTED edges, not its span: a run the value's
                // printer hoists out of a stripped shell on its left spine sits inside the span
                // yet prints right behind the `${`, where the shell-free spelling writes it and
                // the reparse reads it (`${(/* c */ a)`⏎`}` prints `${/* c */ a`⏎`}`, which
                // wraps), and one in a shell on its right spine prints at its end. Bounding the
                // scan at the span hugged on the first pass what the second wraps
                // ([`Self::interpolation_leading_comment_on_page`]).
                let interpolation_has_comment_on_page = template_has_comments
                    && (self.interpolation_leading_comment_on_page(quasi.span.end, expr)
                        || self.has_comments_on_page_between(
                            self.right_spine_printed_end(expr),
                            next_quasi.span.start,
                        ));
                let has_trailing_line_comment = trailing_comments.iter().any(|c| !c.is_block);
                // Whether a broken interpolation wraps its value in `${`/`}` breaks — the
                // closing one is a line end a deferred run can take (below).
                let use_softline_wrap = has_trailing_line_comment
                    || Self::is_template_softline_expression(
                        expr,
                        interpolation_has_comment_on_page,
                    );

                // Combine expression with comments — one path, whose leading run goes
                // through the shared leading-comment emitter.
                let (full_expr_doc, leading_forces_break) = self
                    .build_template_comments_and_expr_doc(
                        &leading_comments,
                        expr_doc,
                        &trailing_comments,
                        expr.span().start,
                        use_softline_wrap,
                    );

                // Prettier's `interpolationHasNewline` gate (template-literal.js
                // `printTemplateExpression`): an interpolation stays on one line
                // unless its *source* already spans multiple lines, or the
                // expression would render with a newline anyway (e.g. a nested
                // function/block body). `quasi.span.end` sits just past `${` and
                // `next_quasi.span.start` at the closing `}`, so the range is the
                // interpolation interior — the equivalent of prettier's
                // `hasNewlineInRange(locEnd(quasi[i]), locStart(quasi[i + 1]))`.
                // `will_break` stands in for prettier's re-render at
                // `printWidth: Infinity`: a doc breaks at infinite width iff it
                // carries a hardline / forced break, i.e. renders with a newline.
                let interpolation_has_newline = self
                    .has_newline_between(quasi.span.end, next_quasi.span.start)
                    || d.will_break(full_expr_doc);
                // A resolved freeze reaches the broken arm and no other: the directive is
                // alone on its line, so the gap it sits in provably holds a newline and the
                // atomizing arm below — which would collapse the frozen slice's own line
                // structure onto one line — is unreachable.
                debug_assert!(
                    frozen.is_none() || interpolation_has_newline,
                    "a `${{`→expression freeze must reach the broken interpolation arm"
                );

                let inner = if interpolation_has_newline {
                    // Qualifying types and trailing line comments use softline
                    // wrapping at ${/} boundaries so the group can break there.
                    // Non-qualifying types keep the expression doc as-is (no ${/}
                    // softlines) so ${ hugs while the expression breaks internally.
                    if use_softline_wrap {
                        // The interpolation's CLOSING edge, through the shared obligation
                        // seam ([`Printer::obligated_break`], which carries the rule): a
                        // `//` deferred inside needs this separator to end its line, or a
                        // flattening welds `}` onto it (`` `${a // c}` ``, unparseable).
                        // The opening edge asks nothing — a leading run reaches this group
                        // as a real hardline of its own.
                        let close = self.obligated_break(has_trailing_line_comment, d.softline());
                        d.concat(&[d.indent(d.concat(&[d.softline(), full_expr_doc])), close])
                    } else {
                        full_expr_doc
                    }
                } else {
                    // No newline in the interpolation and nothing forces a break:
                    // atomize so the `${…}` stays on one line even past print width
                    // (prettier replaces the expression doc with its `printWidth: Infinity`
                    // rendering here).
                    //
                    // `atomize`, not `remove_lines`: this genuinely wants ONE
                    // line, hard lines included, which prettier's `removeLines` deliberately
                    // won't do. Sound only because the branch above already claimed every
                    // interpolation whose doc will break or whose source spans lines (an
                    // interior `//` or multi-line comment among them) — so nothing that must
                    // break reaches here. A single-line block comment can, and carries no
                    // newline.
                    d.atomize(full_expr_doc)
                };

                // Apply alignment based on quasi indent (Prettier's addAlignmentToDoc).
                // Three paths matching Prettier's `printTemplateExpression` (`print/template-literal.js`):
                // 1. indent_size==0 && quasi ends with \n: expression starts at column 0
                //    on a new line — reset indent to absolute 0 (align(-∞)).
                // 2. indent_size>0: expression follows indented template content —
                //    apply full alignment (indent levels + reset).
                // 3. indent_size==0 && quasi doesn't end with \n: inline quasi (e.g. ", ")
                //    — preserve code context indent, no wrapping.
                let aligned = if indent_size == 0 && text.ends_with('\n') {
                    d.align_root(0, inner)
                } else if indent_size > 0 {
                    self.add_alignment_to_doc(inner, indent_size)
                } else {
                    inner
                };
                let group_doc =
                    d.concat(&[d.text("${"), aligned, d.line_suffix_boundary(), d.text("}")]);
                // Force break when trailing line comments are present — a line
                // comment on a flat line would swallow the closing `}`. A freeze forces it
                // for the placement reason instead: the leading run's own line is where the
                // floor reads the directive, and tsv has no `propagateBreaks`, so a hardline
                // buried in that run is invisible to this group.
                parts.push(if has_trailing_line_comment || leading_forces_break {
                    d.group_break(group_doc)
                } else {
                    d.group(group_doc)
                });
            }
        }

        parts.push(d.text("`"));
        d.concat(&parts)
    }

    /// Whether a comment occupies the interpolation AHEAD of its value, as printed: in the
    /// `${`→value gap, or hoisted to the value's front from a stripped shell on its left
    /// spine.
    ///
    /// Walks [`Self::left_spine_steps`], and the two comment classes travel differently
    /// (`docs/comments.md` §The left-spine shell run). A run the shell gap EMITS is hoisted
    /// out in front of a pair the printer re-emits, but not out of a shell the chain prints
    /// mid-chain. A comment the leaf OWNS prints from the leaf's own doc, which the chain
    /// prints first however far below a mid-chain shell it sits
    /// (`${(/* c */ a.b.c())?.d⏎}`), but which a re-emitted pair holds
    /// (`${(/* c */ a + b).m⏎}` keeps the block behind the member object's `(`, where it
    /// occupies nothing at the interpolation's edge).
    fn interpolation_leading_comment_on_page(&self, gap_start: u32, expr: &Expression<'_>) -> bool {
        let start = expr.span().start;
        if self.has_comments_on_page_between(gap_start, start) {
            return true;
        }
        if self.source.as_bytes().get(start as usize) != Some(&b'(')
            || left_spine_shell_is_comment_free(self.source, start)
        {
            return false;
        }
        let mut owned_held = false;
        self.left_spine_steps(expr).any(
            |LeftSpineStep {
                 node,
                 child,
                 mid_chain,
             }| {
                owned_held |= self.left_shell_paren_is_emitted(node, child);
                node.span().start < child.span().start
                    && self
                        .comments_in_source_between(node.span().start, child.span().start)
                        .any(|c| {
                            if c.owned_by_node {
                                !owned_held
                            } else {
                                !mid_chain
                            }
                        })
            },
        )
    }

    /// Get the visual indent size of the last line in template quasi text.
    /// Equivalent to Prettier's `getIndentSize`.
    fn get_template_indent_size(&self, text: &str) -> usize {
        if let Some(last_nl) = text.rfind('\n') {
            let after_nl = &text[last_nl + 1..];
            let ws_end = after_nl
                .chars()
                .take_while(|c| *c == '\t' || *c == ' ')
                .count();
            visual_width(&after_nl[..ws_end], TAB_WIDTH)
        } else {
            0
        }
    }

    /// Apply alignment to a doc based on indent size.
    /// Equivalent to Prettier's `addAlignmentToDoc(doc, size, tabWidth)`.
    ///
    /// When size > 0: wraps with `align(0)` to reset indent to absolute 0,
    /// then applies indent levels from zero. This is critical because
    /// after `literalline` in template content, the output column resets
    /// to 0 but the indent level on the command stack still carries the
    /// code context. Without the reset, softlines would break at the
    /// inherited code indent instead of the template's visual position.
    ///
    /// When size == 0: returns doc unchanged (matching Prettier's behavior).
    fn add_alignment_to_doc(&self, doc: DocId, size: usize) -> DocId {
        if size == 0 {
            return doc;
        }
        let d = self.d();
        // In prettier's useTabs renderer, align(n%tw) creates a WIDTH command
        // that adds lastTabs=1 + lastSpaces=n%tw. When followed by INDENT,
        // flushTabs() emits the pending tab and resetLast() drops the fractional
        // spaces. So align(r) + indent effectively rounds up to a full tab.
        // Match this by using ceiling division for the indent count.
        let n = size.div_ceil(TAB_WIDTH);
        let mut result = doc;
        for _ in 0..n {
            result = d.indent(result);
        }
        // Reset to absolute indent 0. Uses align(0) not dedent because
        // dedent only decrements by 1 (saturating_sub), while we need
        // to reset to 0 regardless of the current indent depth.
        d.align_root(0, result)
    }

    /// Convert a string with newlines into a doc with literalline between parts.
    /// Equivalent to Prettier's `replaceEndOfLine(text)` for TemplateElement nodes.
    /// `has_newline` is the caller's precomputed `text.contains('\n')` (the quasi's
    /// `has_newline` flag), so the common no-newline fast path skips re-scanning.
    fn replace_end_of_line(&self, raw_span: Span, has_newline: bool) -> DocId {
        let d = self.d();
        if !has_newline {
            // Verbatim template chunk — emit the source slice, no allocation.
            return d.source_span(raw_span, self.source);
        }
        // Split on '\n' into literalline-separated parts. Each part is a subslice
        // of the verbatim source slice, so track a running byte offset from
        // `raw_span.start` and emit each as its own (allocation-free) span.
        let text = raw_span.extract(self.source);
        let mut doc_parts = d.pooled_docbuf();
        let mut offset = raw_span.start;
        for (i, part) in text.split('\n').enumerate() {
            if i > 0 {
                doc_parts.push(d.literalline());
            }
            let part_len = part.len() as u32;
            if !part.is_empty() {
                doc_parts.push(d.source_span(Span::new(offset, offset + part_len), self.source));
            }
            offset += part_len + 1; // +1 for the consumed '\n'
        }
        d.concat(&doc_parts)
    }

    /// Whether this expression type qualifies for softline wrapping at
    /// `${`/`}` boundaries. Matches Prettier's qualifying type list
    /// (`printTemplateExpression`, `print/template-literal.js`).
    ///
    /// Qualifying types: simple expressions with no inherent block structure.
    /// Softline wrapping lets the `${}` group break when the line exceeds width.
    ///
    /// Non-qualifying types (CallExpression, ArrowFunctionExpression,
    /// TemplateLiteral, etc.): have internal break points or their own
    /// visual formatting. `${}` hugs while the expression breaks internally.
    ///
    /// ⚠️ `has_comment_on_page` is named for its **axis**, not for a collect: the kind list
    /// below decides this layout only for an interpolation holding NO comment, so the
    /// question is whether one OCCUPIES the page here — an owned (glued) comment included,
    /// which no emitter's collect reports. See `docs/comments.md` §The three axes.
    fn is_template_softline_expression(expr: &Expression<'_>, has_comment_on_page: bool) -> bool {
        if has_comment_on_page {
            return true;
        }
        // Matches Prettier's qualifying types (`printTemplateExpression`, `print/template-literal.js`):
        // Identifier, `isMemberExpression(stripChainElementWrappers(node))`,
        // ConditionalExpression, SequenceExpression, isBinaryCastExpression
        // (TSAsExpression, TSSatisfiesExpression), isBinaryish (BinaryExpression —
        // folds &&, ||, ?? here). Other node kinds are non-qualifying: they either
        // hug at ${/} and break internally, or (now that the softline wrap is gated
        // on `interpolation_has_newline`) stay atomized inline, exactly as prettier
        // renders them.
        //
        // Applying prettier's `stripChainElementWrappers` (here just the non-null
        // peel — see `strip_non_null_wrappers`) before the member test makes
        // `obj.a.b!` qualify like the bare `obj.a.b`, breaking at ${/} rather than
        // mid-chain. Member-only: `foo()!` / `x!` stay non-qualifying, and
        // `(obj.a as T)` already qualifies via TSAsExpression below.
        matches!(
            strip_non_null_wrappers(expr).kind,
            ExpressionKind::MemberExpression(_)
        ) || matches!(
            expr.kind,
            ExpressionKind::Identifier(_)
                | ExpressionKind::ConditionalExpression(_)
                | ExpressionKind::BinaryExpression(_)
                | ExpressionKind::SequenceExpression(_)
                | ExpressionKind::TSAsExpression(_)
                | ExpressionKind::TSSatisfiesExpression(_)
        )
    }

    /// Build the comments-plus-expression doc for one `${…}` interpolation, and report
    /// whether the LEADING run ends a line unconditionally.
    ///
    /// The leading run goes through the shared leading-comment emitter
    /// ([`Printer::push_leading_comment_run`]), so this gap answers the placement question
    /// the way every other leading-comment site does: a comment the author **glued** to what
    /// follows leads it inline (`` `${/* c */ x}` ``), one the author gave its **own line**
    /// keeps that line, and a `//` always breaks. Both authorings are fixed points, which is
    /// what the *type*-side spelling of this same gap already guaranteed
    /// (conformance_prettier_ts_comments.md §A template-literal interpolation is
    /// authoring-dependent) — keying the expression side's separator on the comment's
    /// KIND alone would give every block a space, collapsing an own-line block onto the value's
    /// line and reflowing a break the author wrote. That is the hand-rolled `is_block` split
    /// `docs/comments.md` §Leading comments exists to prevent, and it is also what made an
    /// own-line block DIRECTIVE inert here, since the floor reads only a directive alone on
    /// its line.
    ///
    /// The returned flag is why the emitter is asked rather than re-derived: tsv has no
    /// `propagateBreaks`, so a hardline buried in the run is invisible to the `${…}` group
    /// that has to open for it.
    ///
    /// Trailing comments keep their own loop: the first glues onto the value's line, each
    /// later one follows a space — or a break, behind a `//` — and the LAST one takes no
    /// break after it, since the closing `}`'s own break ends its line.
    ///
    /// `closer_breaks` says the interpolation wraps its value in `${`/`}` breaks when it
    /// breaks, so its closer is a line end a run the value defers to its end can take.
    fn build_template_comments_and_expr_doc(
        &self,
        leading_comments: &[&crate::ast::Comment],
        expr_doc: DocId,
        trailing_comments: &[&crate::ast::Comment],
        expr_start: u32,
        closer_breaks: bool,
    ) -> (DocId, bool) {
        let d = self.d();
        let mut parts = DocBuf::new();

        // Leading comments
        let forces_break = self.push_leading_comment_run(
            &mut parts,
            leading_comments.iter().copied(),
            expr_start,
            crate::printer::comments::LeadingGlue::Adjacent,
        );

        // Expression
        parts.push(expr_doc);

        // A run the value DEFERS to its own end (a stripped pair's interior comment, a hugged
        // arrow's body) flushes at the interpolation's own line end, as the host prints a run
        // written after the value: an own-line comment glues onto the value's line, and a
        // `//` ends it. Left to the closer's break instead, it rides past the comments
        // written after the value, which print inline here, and welds onto the last one's
        // line (`a || b // d // c`, one comment lost) — or, alone, keeps an own-line break
        // the next pass reads as a glued comment. With no written run the embed end's break
        // is the closer's, one level out — which needs the closer to BE a break: a value the
        // interpolation hugs (`closer_breaks` false) breaks inside itself to flush the run
        // instead, as with no comment after it.
        if (closer_breaks || !trailing_comments.is_empty()) && d.holds_line_suffix(expr_doc) {
            let pending = d.pending_run_at_end(expr_doc, self.source);
            parts.push(d.embed_end(pending, trailing_comments.is_empty()).doc());
        }

        // Trailing comments: the separator goes BEFORE each one — a space, or a break after
        // a `//`, which nothing can follow on its line. The last takes none after it: the
        // closing `}`'s own break ends its line.
        let mut after_line_comment = false;
        for comment in trailing_comments {
            parts.push(if after_line_comment {
                d.hardline()
            } else {
                d.text(" ")
            });
            parts.push(self.build_comment_doc(comment));
            after_line_comment = !comment.is_block;
        }

        (d.concat(&parts), forces_break)
    }

    /// Build a Doc for a tagged template expression
    pub(super) fn build_tagged_template_doc(
        &self,
        tagged: &crate::ast::TaggedTemplateExpression<'_>,
        span: Span,
    ) -> DocId {
        let d = self.d();

        // Two tag positions print a required pair that keeps BOTH its gaps inside it: a
        // function/arrow tag — the template half of prettier's IIFE-callee-or-tag rule
        // (`paren_pair_keeps_leading_run` feeding its `printCommentsForFunction`) — and a
        // sealed optional chain, where the pair is what stops the chain. ⚠️ ONE variable
        // answers it: a `trailing_gap` this doc does not emit is a DROPPED comment, which
        // is exactly what a tag-kind test paired with a position-keyed gap produced. The
        // pair must be the author's own for a trailing gap to exist at all
        // (`paren_shell_close_after`).
        let pair_open = tag_paren_leading_start(tagged, span);
        let trailing_gap = self.owned_pair_trailing_gap(tagged.tag.span().end, pair_open.is_some());
        // Every window downstream — the type-argument gap, the tag→`` ` `` gap — opens
        // PAST the pair's `)`, so a comment this doc emitted inside the parens is not
        // emitted a second time outside them.
        let tag_gap_start =
            Printer::gap_start_after_owned_pair(tagged.tag.span().end, trailing_gap);

        let tag_doc = if let Some(pair_open) = pair_open {
            // The tag has one rendering, so every arm takes the same body.
            let inner = self.build_expression_doc(tagged.tag);
            self.build_owned_required_pair_doc(
                (pair_open, tagged.tag.span().start),
                trailing_gap,
                inner,
                ")",
            )
        } else {
            // Wrap tag in parens if needed (e.g., ternary: `(a ? b : c)`template``, or an
            // optional chain: `` (a?.b)`x` `` — a chain can't be a tag per spec). A
            // non-null assertion that seals a parenthesized chain (`` (a?.b)!`x` ``) keeps
            // the parens via the sealed-base rendering. This must happen BEFORE adding
            // removed-paren comments so comments stay outside.
            // The TAG's own erased-paren freeze — the region this arm's
            // `prepend_removed_paren_comments` below already emits
            // ([`Printer::build_left_spine_operand_doc`]).
            // A comment in a shell below a pair the tag's left spine prints leads the
            // whole tagged template (`Printer::left_spine_run_claim`).
            let claim = self.left_spine_run_claim(span, span.start, tagged.tag, || {
                self.needs_parens(tagged.tag, ParenContext::TaggedTemplateTag)
            });
            self.with_left_spine_claim(claim, || {
                let tag_doc = self.build_left_spine_operand_doc(
                    span.start,
                    tagged.tag,
                    FrozenOperandPair::position(ParenContext::TaggedTemplateTag),
                    || {
                        if let Some(sealed) = self.build_sealed_non_null_paren_doc(tagged.tag) {
                            sealed
                        } else if self.needs_parens(tagged.tag, ParenContext::TaggedTemplateTag)
                            || tagged.type_arguments.as_ref().is_some_and(|_| {
                                self.instantiation_keeps_pair(
                                    tagged.tag,
                                    SecondTypeArgs::Expression,
                                )
                            })
                        {
                            d.parens(self.build_expression_doc(tagged.tag))
                        } else {
                            self.build_expression_doc(tagged.tag)
                        }
                    },
                );
                // Check for comments between removed parentheses and tag
                // e.g., (/* comment */ tag)`template` has span.start at '(' and tag.span.start at 'tag'
                self.prepend_removed_paren_comments(
                    span,
                    span.start,
                    tagged.tag.span().start,
                    tag_doc,
                )
            })
        };

        let mut parts: DocBuf = smallvec![tag_doc];
        if let Some(type_args) = &tagged.type_arguments {
            // Preserve comments between tag and type args: `fn/* c */ <string>`template``
            let ta_start = type_args.span.start;
            if let Some(doc) = self.build_name_to_type_params_comments_opt(
                tag_gap_start,
                ta_start,
                CommentSpacing::Trailing,
            ) {
                parts.push(doc);
            }
            parts.push(self.build_type_parameter_instantiation_doc(type_args));
        }

        // Emit comments between tag (or type_args) and template literal
        // e.g., `foo /* c */ \`x\`` or `foo\n// c\n\`x\``
        let comment_start = tagged
            .type_arguments
            .as_ref()
            .map_or(tag_gap_start, |ta| ta.span.end);
        let comment_end = tagged.quasi.span.start;
        let gap_comments: CommentVec<'_> = self
            .comments_to_emit_between(comment_start, comment_end)
            .collect();
        if !gap_comments.is_empty() {
            let mut prev_end = comment_start;
            let mut ends_with_hardline = false;
            for comment in &gap_comments {
                if comment.is_block {
                    let on_own_line = self.has_newline_between(prev_end, comment.span.start);
                    if on_own_line || ends_with_hardline {
                        parts.push(d.hardline());
                        parts.push(self.build_comment_doc(comment));
                        // Check if there's a newline after this block comment
                        ends_with_hardline =
                            self.has_newline_between(comment.span.end, comment_end);
                    } else {
                        parts.push(d.text(" "));
                        parts.push(self.build_comment_doc(comment));
                        ends_with_hardline = false;
                    }
                } else {
                    // Line comment: hardline or space before, always hardline after
                    let on_own_line = self.has_newline_between(prev_end, comment.span.start);
                    if on_own_line || ends_with_hardline {
                        parts.push(d.hardline());
                    } else {
                        parts.push(d.text(" "));
                    }
                    parts.push(self.build_comment_doc(comment));
                    ends_with_hardline = true;
                }
                prev_end = comment.span.end;
            }
            if ends_with_hardline {
                parts.push(d.hardline());
            } else {
                parts.push(d.text(" "));
            }
        }

        parts.push(self.build_template_literal_doc(&tagged.quasi));
        d.concat(&parts)
    }
}