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
// Import expression and meta property printing
//
// Handles:
// - Dynamic import: `import('module')`, `import('module', options)`
// - Meta properties: `import.meta`, `new.target`

use super::super::Printer;
use super::super::comments::ParenLeadingValue;
use super::arg_comments::{PartitionedComments, should_force_expansion_for_comments};
use super::arg_predicates::{is_expandable_object, is_hook_callback_with_deps};
use super::arg_wrapping::try_hug_multiline_template_arg;
use crate::ast;
use smallvec::smallvec;
use tsv_lang::Span;
use tsv_lang::doc::DocBuf;
use tsv_lang::doc::arena::{DocArena, DocId};

/// The phase keyword of a phased dynamic import (`source` / `defer`), or `None` for a
/// plain `import(`.
fn import_phase_word(phase: ast::ImportPhase) -> Option<&'static str> {
    match phase {
        ast::ImportPhase::None => None,
        ast::ImportPhase::Source => Some("source"),
        ast::ImportPhase::Defer => Some("defer"),
    }
}

/// The HEAD of a (possibly phased) dynamic import — `import` plus the phase (`.source` /
/// `.defer`) when there is one, everything before the `(` — plus the offset where the
/// caller's leading-comment scan must begin.
///
/// The `(` is the layout's, not the head's: it is what a call and a `new` hand their own
/// argument builders as the callee, so the multiline-template hug — the one layout here
/// that owns its `(` — can take the same shared builder they do.
///
/// A phased import is a dotted pair (`import` `.` `source`), so both gaps around its dot
/// are positions an author can comment in; the shared printer emits them. Baking the
/// pair into one fixed text (`"import.source("`) scans neither and drops what's there.
///
/// The returned offset is the **head's end** — just past `import`, or past the phase
/// word — deliberately *before* the `(` rather than after it. The caller scans from
/// there to the first argument, so that one range covers both the head→`(` gap and the
/// `(`→argument gap, and every comment in the opening reaches the caller's emitter
/// exactly as before. `span.start + import_open(phase).len()` could not: it assumes the
/// opening is contiguous, so a comment inside it shifts the real `(` past that offset
/// and the scan starts mid-comment, missing it. That was the drop.
fn build_import_head_doc(
    printer: &Printer<'_>,
    import_expr: &ast::ImportExpression<'_>,
    import_expr_span: Span,
) -> (DocId, u32) {
    let d = printer.d();
    let start = import_expr_span.start;
    let import_end = start + "import".len() as u32;
    // Bounds every scan below: the first argument is the next real token, and no `(` or
    // phase word of this import's own lies past it.
    let source_start = import_expr.source.span().start;

    // The head (`import`, plus `.source` / `.defer`) and where it ends in source.
    let (head, head_end) = match import_phase_word(import_expr.phase) {
        None => (d.text("import"), import_end),
        Some(word) => match printer.find_keyword_in_range(import_end, source_start, word) {
            Some(word_start) => (
                printer.build_dotted_pair_doc(
                    d.text("import"),
                    d.text(word),
                    import_end,
                    word_start,
                ),
                word_start + word.len() as u32,
            ),
            None => {
                // Only reachable on a synthetic span: the parser set the phase by
                // reading this very word out of the source.
                debug_assert!(
                    false,
                    "phased import has no `{word}` in source[{import_end}..{source_start}]"
                );
                (d.text("import"), import_end)
            }
        },
    };

    (head, head_end)
}

/// The import-argument shell, in whichever of its two shapes the layout calls for: the
/// forced-multiline one (`<open>` + the `(`-line run + hardline-indented `inner` +
/// hardline + `)`) whenever a line comment, an own-line comment or an author blank forces
/// the parens open, else the breakable group (`<open>` + softline-indented `inner` +
/// softline + `)`) that stays inline when it fits.
///
/// One function rather than two because the shape and the `(`-line run are the same fact:
/// a run pulled onto the `(` line can only be emitted where the shell is already committed
/// to breaking ([`Printer::build_paren_leading_value_doc`] reports the pull *as* a forced
/// break), and a `//` in it would swallow the arguments anywhere else. Stating that as
/// `force_break || !paren_line.is_empty()` **enforces** it; the two-function form could
/// only assert it, and a `debug_assert` that elides in release turns a broken invariant
/// into a silently DROPPED comment.
fn wrap_import_args(
    d: &DocArena,
    open: DocId,
    paren_line: &[DocId],
    inner: DocId,
    force_break: bool,
) -> DocId {
    if force_break || !paren_line.is_empty() {
        d.concat(&[
            open,
            d.concat(paren_line),
            d.indent_hardline(inner),
            d.hardline(),
            d.text(")"),
        ])
    } else {
        d.group(d.concat(&[open, d.indent_softline(inner), d.softline(), d.text(")")]))
    }
}

/// An import call's **options** argument — its already-built doc plus the source bounds
/// the two comment gaps around it are measured from.
///
/// A struct rather than a tuple because both consumers of
/// [`build_import_args_comment_layout`] hand it three values that are trivially
/// swappable at a call site (`start`/`end` are both `u32`).
#[derive(Clone, Copy)]
pub(in crate::printer) struct ImportOptionsArg {
    pub doc: DocId,
    pub start: u32,
    pub end: u32,
}

/// The `import(…)` argument body for every input the plain layouts cannot print — a
/// comment in any of the three gaps the arguments open (`(`→specifier,
/// specifier→options, last argument→`)`) or an author blank line between the two
/// arguments — and `None` when the region holds neither, leaving the caller its own
/// layout.
///
/// ⚠️ **Shared by the dynamic-import EXPRESSION and the TS import TYPE**
/// ([`build_import_expression_doc`], [`Printer::build_import_type_call_doc`]). The two
/// constructs are the same `import(<specifier>[, <options>])` shape and prettier answers
/// every gap in them identically — including at the width boundary, where a trailing
/// comment breaks the parens in both while a bare over-width specifier hangs off the `=`
/// in both. Stating the rule once is what keeps them in step: the type-level printer
/// began as a stripped-down mirror of this one and silently DROPPED every own-line
/// comment before `)`, every comment in the options gaps, and the author blank between
/// the arguments — content loss no gate could see, because the type-level shapes had no
/// fixture and the corpus has no import type carrying a comment.
///
/// What stays with each caller is only what genuinely differs: the `open` doc (the
/// expression's phase-aware `import.source(` head vs the type's fixed `import(`), each
/// argument's own doc, and the **clean-region** layout — the expression's expand-last-arg
/// conditional group, the type's flat concat.
pub(in crate::printer) fn build_import_args_comment_layout(
    printer: &Printer<'_>,
    open: DocId,
    source: &ParenLeadingValue,
    source_end: u32,
    options: Option<ImportOptionsArg>,
    paren_close: u32,
) -> Option<DocId> {
    let d = printer.d();
    // The `(`→specifier gap's three answers travel together — the run the `(` line keeps,
    // the specifier with the rest of its leading run, and the break that run reports —
    // because one call produced all three and no caller can supply them separately.
    let (paren_line, source_doc, leading_forces_break) = (
        source.paren_line.as_slice(),
        source.value,
        source.forces_break,
    );

    let Some(options) = options else {
        // Sole argument: everything between it and `)` is its trailing region.
        if printer.has_comments_to_emit_between(source_end, paren_close) {
            // A last-item→`)` gap, comma or not: the claim is the source reading
            // (`for_closer_gap`). The delimiter reading is blind to every byte no argument
            // span covers, and a preceding comment's `*/` is one — so a comment the author
            // glued behind it read as own-line and dangled below the argument, splitting a
            // pair written as one (`docs/comments.md` §Own-line-ness is a SOURCE question).
            let pc = PartitionedComments::for_closer_gap(printer, source_end, paren_close);

            // Trailing region after the arg: same-line block/line comments inline, then
            // own-line comments each on their own line (dangling — import takes no
            // trailing comma). Without the dangling pass, own-line comments are dropped.
            let mut parts = smallvec![source_doc];
            pc.emit_last_arg_comments(&mut parts, printer);
            let inner = d.concat(&parts);

            // A line comment (runs to EOL), any own-line comment, or an own-line leading
            // comment before the source forces the multiline layout; a lone same-line
            // block stays inline and breaks only on width. (variable.rs special-cases
            // the assignment break.)
            return Some(wrap_import_args(
                d,
                open,
                paren_line,
                inner,
                pc.forces_closer_break() || leading_forces_break,
            ));
        }

        // Own-line leading comment: force hardline layout to preserve comment position.
        // Prettier's printLeadingComment() keeps own-line comments on their own line.
        return leading_forces_break
            .then(|| wrap_import_args(d, open, paren_line, source_doc, true));
    };

    let has_inter_comments = printer.has_comments_on_page_between(source_end, options.start);
    let has_trailing_comments = printer.has_comments_to_emit_between(options.end, paren_close);
    // A blank line in the source→options gap (with no comment there) is preserved like
    // every other argument gap; the comment case re-derives it comment-aware below.
    let inter_blank_no_comments =
        !has_inter_comments && printer.is_next_line_empty(source_end, options.start);

    // All comment cases — plus a blank-line gap — share one layout: a comment in the
    // inter-argument gap (source→options), which the plain layouts never examine and
    // would otherwise drop (content loss); a trailing comment after options; or an
    // own-line comment before the source (`leading_forces_break`). Route both gaps
    // through the unified argument-comment helpers, so the respect-the-newline rule (a
    // hugging block leads the next arg; a stranded block stays on the comma line) is
    // inherited rather than re-implemented, and bypass the caller's expand-last-arg
    // conditional group — matching prettier disabling shouldExpandLastArg whenever an
    // argument carries a comment.
    if !(leading_forces_break
        || has_inter_comments
        || has_trailing_comments
        || inter_blank_no_comments)
    {
        return None;
    }

    let inter = PartitionedComments::for_routed_arg_gap(printer, source_end, options.start);

    // Source arg + comma: before-comma blocks trail the source; stranded after-comma
    // blocks and line comments follow the comma.
    let mut head = smallvec![source_doc];
    inter.emit_trailing_comments_around_comma(&mut head, printer);

    // Blank line in the gap, comment-aware once routed (so a comment's own newlines
    // don't read as a blank line).
    let inter_blank =
        inter_blank_no_comments || (has_inter_comments && inter.has_blank_line_in_gap(printer));

    // Leading comments (own-line + hugged after-comma) lead the options arg; its
    // trailing region follows: same-line block/line comments inline, then own-line
    // comments each on their own line (dangling — import takes no trailing comma).
    let mut tail = DocBuf::new();
    inter.emit_leading_comments_inline_aware(&mut tail, printer);
    tail.push(options.doc);
    // The options argument's gap holds the list's own comma, so it takes the item
    // reading like every other last-argument gap (`emit_last_arg_trailing_comments`).
    let trailing = PartitionedComments::for_closer_gap(printer, options.end, paren_close);
    trailing.emit_last_arg_comments(&mut tail, printer);

    // A line comment (runs to EOL), an own-line comment (leading before source, in
    // the gap, or dangling after options), or a blank line forces the multiline
    // layout; inline blocks (hugging / before-comma / same-line trailing) leave the
    // group free to stay inline and break only on width.
    let force_break = leading_forces_break
        || inter_blank
        || trailing.forces_closer_break()
        || should_force_expansion_for_comments(printer, source_end, options.start);

    // The source→options separator: a blank line when the author left one (a blank
    // line always forces the break), else a hardline when broken, else a soft `line`.
    let sep = if inter_blank {
        d.concat(&[d.literalline(), d.hardline()])
    } else if force_break {
        d.hardline()
    } else {
        d.line()
    };

    let body = d.concat(&[d.concat(&head), sep, d.concat(&tail)]);
    Some(wrap_import_args(d, open, paren_line, body, force_break))
}

/// Build a Doc for a dynamic import expression: `import('module')` or `import('module', options)`
///
/// Uses "expand last arg" pattern when options is an object:
/// - First arg stays on same line as `import(`
/// - Only the options object expands with its properties indented
pub(super) fn build_import_expression_doc(
    printer: &Printer<'_>,
    import_expr: &ast::ImportExpression<'_>,
    span: Span,
) -> DocId {
    let d = printer.d();

    // The head — `import` plus any phase (`.source` / `.defer`) — built once and threaded
    // into every wrapper below with the `(` appended. It also reports where the
    // leading-comment scan starts: the head's end, so the scan spans the `(` and catches a
    // comment on either side of it (`import /* c */ ('m')`, `import(/* @vite-ignore */ m)`).
    // Own-line comments force the parens to break; `leading_forces_break` drives that below.
    let (head, leading_scan_start) = build_import_head_doc(printer, import_expr, span);

    // The head→`(` gap's pre-paren half ([`super::CalleeGap::paren_split`]) — a `//` written
    // there trails the head and the whole argument list hangs under it, exactly as at a call
    // and a `new`. This is where `import()` STOPS matching prettier: prettier attaches such a
    // comment to the following node — `import` has no preceding node for it to trail — and
    // relocates it inside the parens. tsv answers the gap by POSITION at all three keywords
    // instead, which is the point (`docs/conformance_prettier_ts_comments.md` §Comment
    // relocation, the callee→`(` entries).
    if let Some((split_start, paren)) = super::paren_split_for_outlined(
        printer,
        leading_scan_start,
        std::slice::from_ref(&import_expr.source),
    ) {
        // The argument side opens AT the `(`, so its scans no longer see the half the head
        // has taken.
        let args = build_import_args_doc(printer, import_expr, span, d.empty(), paren);
        return super::hang_args_under_split(printer, head, (split_start, paren), args);
    }
    build_import_args_doc(printer, import_expr, span, head, leading_scan_start)
}

/// The argument half of [`build_import_expression_doc`] — `head` is what it hangs off, an
/// empty doc when the head→`(` gap's pre-paren half has taken it.
fn build_import_args_doc(
    printer: &Printer<'_>,
    import_expr: &ast::ImportExpression<'_>,
    span: Span,
    head: DocId,
    leading_scan_start: u32,
) -> DocId {
    let d = printer.d();
    let source_start = import_expr.source.span().start;

    // Single multiline-template specifier on the `(` line — hug it, through the same
    // builder the plain call, `new` and the member chain take. Prettier reaches it the same
    // way: `isTemplateLiteralSingleArg` is asked at the top of `printCallExpression`, whose
    // node list has `ImportExpression` in it, so the rule is one rule across all four
    // spellings rather than a call-only special case. `options.is_none()` is this node's
    // spelling of the shared `args.len() == 1` — an import's arguments are two named fields,
    // not a slice.
    if import_expr.options.is_none()
        && let Some(doc) = try_hug_multiline_template_arg(
            printer,
            head,
            std::slice::from_ref(&import_expr.source),
            leading_scan_start,
            span.end,
        )
    {
        return doc;
    }

    let open = d.concat(&[head, d.text("(")]);

    // The two arguments are a comma list like a call's, so the list owner's authored-pair
    // rule reaches them too ([`Printer::mark_relational_sibling_authored_pairs`]): a source
    // holding a `<` keeps its pair when the options hold a closer. Marked ahead of either
    // argument's build, as every list owner marks.
    printer.mark_relational_sibling_authored_pairs(
        [Some(import_expr.source), import_expr.options].into_iter(),
    );

    // Rule A over the two arguments: `import()`'s source and options are an argument list
    // like any other, so an own-line directive in the head→source gap (which spans the
    // `(`, matching the leading-comment scan above it) or in the source→options gap
    // freezes the argument that follows it. The two items are named fields rather than a
    // slice, so each asks the seam with its own gap anchor.
    let frozen_source = printer.gap_frozen_span(leading_scan_start, import_expr.source.span());

    // Parenthesize an `in` argument inside a for-header init (`for (a = import(m,
    // (b in c));…)`); a no-op elsewhere. Dynamic-import args are hand-rolled here
    // rather than routed through `needs_parens(Argument)`, so apply the rule directly —
    // and the kept comparison pair with it, which that `in` pair already supplies when
    // both apply. The frozen arm asks `needs_parens(Argument)`, which answers both.
    let raw_source_doc = frozen_source.map_or_else(
        || {
            printer.wrap_relational_sibling_pair(
                import_expr.source,
                printer.for_init_in_needs_parens(import_expr.source),
                printer.wrap_for_init_in(
                    import_expr.source,
                    printer.build_expression_doc_claiming_outermost(import_expr.source),
                ),
            )
        },
        |frozen| printer.build_frozen_arg_doc(import_expr.source, frozen),
    );
    // The `(`→specifier gap splits three ways: the run the `(` line keeps, the run that
    // leads the specifier, and whether the parens must open.
    let leading =
        printer.build_paren_leading_value_doc(leading_scan_start, source_start, raw_source_doc);
    let source_doc = leading.value;

    let source_end = import_expr.source.span().end;
    let paren_close = span.end;

    let options_arg = import_expr
        .options
        .as_ref()
        .map(|options| ImportOptionsArg {
            doc: printer
                .gap_frozen_span(source_end, options.span())
                .map_or_else(
                    || printer.wrap_for_init_in(options, printer.build_expression_doc(options)),
                    |frozen| printer.build_frozen_arg_doc(options, frozen),
                ),
            start: options.span().start,
            end: options.span().end,
        });

    // Every comment gap, and the author blank between the arguments, is answered by the
    // shared layout — the same one the TS import TYPE takes, so the two constructs can't
    // drift. `None` means the region is clean and the plain layouts below apply.
    if let Some(doc) = build_import_args_comment_layout(
        printer,
        open,
        &leading,
        source_end,
        options_arg,
        paren_close,
    ) {
        return doc;
    }

    // Zipped rather than matched separately: the two are `Some` together by construction
    // (`options_arg` is built from this very field), and pairing them says so without a
    // second arm that could only be reached by breaking that.
    let Some((options, options_doc)) = import_expr
        .options
        .as_ref()
        .zip(options_arg.map(|options| options.doc))
    else {
        // Group with softline break points so the outer import() can break when the
        // line exceeds print width, matching Prettier's call-arg expansion. Without
        // this, only the inner arg's groups can break (e.g., `import(fn(\n  'long',\n))`
        // instead of the correct `import(\n  fn('long')\n)`).
        return wrap_import_args(d, open, &[], source_doc, false);
    };

    // Prettier's React-hook deps-array layout, which `ImportExpression` reaches through the
    // same `printCallArguments` as a call: a zero-parameter block arrow plus an array
    // literal stays flat. The comment conjunct is asked of the `(`→specifier gap only —
    // every other gap's comment already routed through the shared comment layout above.
    if is_hook_callback_with_deps(import_expr.source, options)
        && !printer.has_comments_on_page_between(leading_scan_start, source_start)
    {
        return d.concat(&[open, source_doc, d.text(", "), options_doc, d.text(")")]);
    }

    if is_expandable_object(options) {
        // Three-state conditional group matching Prettier's expand-last-arg:
        // State 0: all flat — import('source', {with: {type: 'json'}})
        // State 1: expand-last — import('source', {\n\twith: ...\n})
        // State 2: expand-all — import(\n\t'source',\n\t{...}\n)
        let state_flat = d.concat(&[open, source_doc, d.text(", "), options_doc, d.text(")")]);

        let expanded_options = d.group_break(options_doc);
        let state_expand_last = d.concat(&[
            open,
            source_doc,
            d.text(", "),
            expanded_options,
            d.text(")"),
        ]);

        let arg_parts = d.join_doc([source_doc, options_doc], d.comma_line());
        let state_expand_all = d.concat(&[
            open,
            d.indent_softline(arg_parts),
            d.softline(),
            d.text(")"),
        ]);

        d.conditional_group(&[state_flat, state_expand_last, state_expand_all])
    } else {
        // Standard group wrapping for non-expandable options
        let arg_parts = d.join_doc([source_doc, options_doc], d.comma_line());
        wrap_import_args(d, open, &[], arg_parts, false)
    }
}

/// Build a Doc for a meta property: `import.meta`, `new.target`
///
/// A meta property is a dotted pair of names, so it delegates to the shared
/// [`Printer::build_dotted_pair_doc`] — which emits both gaps around the `.`, the
/// positions an author can comment in (`new /* c */.target`, `new./* c */ target`).
/// A qualified name (`ns.Type`) is the same shape and shares that printer.
pub(super) fn build_meta_property_doc(
    printer: &Printer<'_>,
    meta: &ast::MetaProperty<'_>,
) -> DocId {
    printer.build_dotted_pair_doc(
        printer.identifier_name_doc(&meta.meta),
        printer.identifier_name_doc(&meta.property),
        meta.meta.span.end,
        meta.property.span.start,
    )
}