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
// Test function pattern detection for TypeScript call expressions
//
// Detects test framework patterns that Prettier keeps on a single line:
// Jest, Mocha, Jasmine, Playwright, Vitest patterns

use super::super::Printer;
use super::arg_comments::has_inter_argument_comments_slice;
use super::call_paren_open;
use crate::ast::{self, IdentName};
use smallvec::SmallVec;
use tsv_lang::Span;
use tsv_lang::doc::arena::DocId;

/// Test function patterns that Prettier keeps on a single line
/// Includes: Jest, Mocha, Jasmine, Playwright, Vitest patterns
const TEST_CALL_PATTERNS: &[&str] = &[
    // Core test functions
    "it",
    "it.only",
    "it.skip",
    "describe",
    "describe.only",
    "describe.skip",
    "test",
    "test.only",
    "test.skip",
    "test.fixme", // Playwright 3.7
    "test.step",
    // Playwright describe variants
    "test.describe",
    "test.describe.only",
    "test.describe.skip",
    "test.describe.fixme", // Playwright 3.7
    "test.describe.parallel",
    "test.describe.parallel.only",
    "test.describe.serial",
    "test.describe.serial.only",
    // Focus/skip prefixes
    "skip",
    "xit",
    "xdescribe",
    "xtest",
    "fit",
    "fdescribe",
    "ftest",
];

/// Get the name channel (+ span start) of an identifier if it's a simple identifier
fn get_identifier_name<'a>(expr: &ast::Expression<'a>) -> Option<(IdentName<'a>, u32, u32)> {
    if let ast::ExpressionKind::Identifier(id) = &expr.kind {
        Some((id.ident_name(), id.span.start, id.span.end))
    } else {
        None
    }
}

/// Build the flat, break-free callee doc (e.g. `test.describe.only`) for the
/// test-call layout, straight from the span-identity member-chain parts of a simple
/// identifier or non-computed, non-optional member chain — no intermediate
/// `String`. Returns `None` for anything else (computed access, optional chains,
/// or non-identifier callees), so the caller falls back to the general callee doc.
///
/// Emitting `source_span` name + `.` doc nodes is byte-identical to the old
/// resolve-and-`join(".")` `String`: identifiers never contain `.`, and
/// the concatenated text nodes carry no break point, so the flat callee still
/// never breaks at `.skip` — at zero heap allocation. `is_test_call` accepts the same
/// callee shapes ([`callee_is_test_pattern`]: an identifier root, identifier properties,
/// no computed or optional link), so the two stay in lockstep on which callees qualify.
///
/// Each `.` goes through the shared dotted-pair printer, which emits the gaps
/// around it — the positions an author can comment in (`describe /* c */.only`,
/// `test./* c */ skip`). Joining the parts with a bare `d.text(".")` scans none of
/// them and drops what's there. It stays break-free: with no comment in a gap the
/// pair is the same three text nodes as before.
///
/// Building from parts makes this a **reassembly** (`docs/comments.md` hazard 1): the callee
/// never routes through `build_expression_doc`, so the comment glued to its first token has
/// no other printer and `/* c */ it('a', fn)` loses it. The call above cannot cover for it —
/// `prepend_owned_leading_comment` hands the claim DOWN when the left-spine child starts
/// where the call does, and that child is exactly this callee. So the claim is made here, at
/// the chain root's own span start: the callee's first printed byte, the left-edge fact
/// [`Printer::prepend_owned_leading_comment_at`] asks each caller for.
pub(super) fn build_test_callee_flat_doc(
    expr: &ast::Expression<'_>,
    printer: &Printer<'_>,
) -> Option<DocId> {
    let parts = get_member_chain_parts(expr)?;
    // Parts come out leaf→root; reverse to root→leaf (`test.describe.only`), which is
    // also the AST's own association — each pair's left is everything before its dot.
    let mut iter = parts.iter().rev();
    // A single-part callee (`it`) is the bare name: the loop never runs.
    let &(name, start, mut prev_end) = iter.next()?;
    let mut doc = printer.ident_name_doc(name, start);
    for &(name, start, end) in iter {
        doc = printer.build_dotted_pair_doc(
            doc,
            printer.ident_name_doc(name, start),
            prev_end,
            start,
        );
        prev_end = end;
    }
    Some(printer.prepend_owned_leading_comment_at(expr.span().start, doc))
}

/// Get the member chain parts from an expression
/// Returns parts reversed, e.g. `["skip", "test"]` for `test.skip`.
///
/// Out of line: its parts buffer is a large frame, and inlined into the call printer it
/// would ride every call the printer re-enters for (a curried call, `f()()`), which the
/// stack contract prices per link.
#[inline(never)]
fn get_member_chain_parts<'a>(
    expr: &ast::Expression<'a>,
) -> Option<SmallVec<[(IdentName<'a>, u32, u32); 8]>> {
    let mut parts: SmallVec<[(IdentName<'a>, u32, u32); 8]> = SmallVec::new();

    // Property names outermost first, then the base — a loop down the spine, not a
    // recursion, since a frame per link is what the stack contract prices
    let mut expr = expr;
    loop {
        match &expr.kind {
            ast::ExpressionKind::Identifier(id) => {
                parts.push((id.ident_name(), id.span.start, id.span.end));
                return Some(parts);
            }
            ast::ExpressionKind::MemberExpression(member) => {
                // Don't match computed or optional chains (a[b] or a?.b)
                if member.computed || member.optional {
                    return None;
                }
                parts.push(get_identifier_name(member.property)?);
                expr = member.object;
            }
            _ => return None,
        }
    }
}

/// Whether the test-call FLAT layout applies to `call` — [`is_test_call`] plus the one
/// thing that layout cannot do: print a comment sitting in an argument gap
/// ([`test_call_flat_gaps_clear`]).
///
/// Both routing sites decide the flat layout this way (`calls/mod.rs`'s chain-bypass, and
/// `build_call_doc_with_wrapping`, which asks ONCE at its head and reads the answer twice —
/// once to build the break-free callee, once to pick the layout), so they cannot disagree
/// about which calls take the flat form. The head spells it out as its two halves because it
/// needs the [`is_test_call`] half on its own as well.
pub(super) fn test_call_flat_layout_applies(
    call: &ast::CallExpression<'_>,
    span: Span,
    printer: &Printer<'_>,
) -> bool {
    is_test_call(call, printer) && test_call_flat_gaps_clear(call, span, printer)
}

/// Whether no argument gap of a test call holds a comment the flat layout would drop — the
/// half of [`test_call_flat_layout_applies`] past [`is_test_call`].
///
/// The flat layout joins the argument docs with `", "` and appends a trailing-comment
/// suffix after the last argument; it never opens the `(`→first-argument gap or any
/// inter-argument gap, so a comment there is DROPPED outright (the hazard-4 shape in
/// docs/comments.md — an alternate-layout builder that emits only its children's docs).
/// A layout that cannot print a comment must not be chosen for a call that has one, so
/// such a call falls through and expands like any other.
///
/// **To-emit axis**, deliberately: a GLUED block comment is owned by the argument it
/// precedes and rides inside that argument's own doc, so the flat layout does print it
/// — and keeping the flat layout there matches prettier. The question here is exactly
/// "would a comment be dropped?", which is the to-emit axis's question. A comment
/// trailing the whole call is likewise fine — the layout emits that itself. See
/// conformance_prettier_ts_comments.md §Comment relocation.
pub(super) fn test_call_flat_gaps_clear(
    call: &ast::CallExpression<'_>,
    span: Span,
    printer: &Printer<'_>,
) -> bool {
    let paren_open = call_paren_open(printer, call, span);
    // Zero-comment fast gate: ONE binary search over `[paren_open, last argument's start)`,
    // which strictly contains every gap the check below looks at — the `(`→first-argument
    // gap and each inter-argument gap all end at or before the last argument. So with no
    // comment in it they are provably all empty.
    //
    // The window deliberately stops at the LAST argument rather than at the call's end: the
    // last argument of a test call is its callback body, which is where a test file's
    // comments overwhelmingly live, so a gate spanning it fires on nearly every call and
    // gates nothing. Canonical reference: `build_params_doc_with_comments`.
    let Some(last_start) = call.arguments.last().map(|a| a.span().start) else {
        return true;
    };
    !(printer.has_comments_to_emit_between(paren_open, last_start)
        && test_call_gaps_have_comments(call, printer, paren_open))
}

/// Whether any argument gap of `call` holds a comment this call would have to emit —
/// the `(`→first-argument gap or an inter-argument gap. Only the gaps, never an
/// argument's interior: a comment inside an argument is printed by that argument's own
/// doc.
fn test_call_gaps_have_comments(
    call: &ast::CallExpression<'_>,
    printer: &Printer<'_>,
    paren_open: u32,
) -> bool {
    call.arguments
        .first()
        .is_some_and(|first| printer.has_comments_to_emit_between(paren_open, first.span().start))
        || has_inter_argument_comments_slice(call.arguments, printer)
}

/// Check if a call expression is a test function call that should stay on one line
pub(super) fn is_test_call(call: &ast::CallExpression<'_>, printer: &Printer<'_>) -> bool {
    // Optional calls (`describe?.(...)`) are never test calls — they format like
    // a normal call (wrap when long), preserving the `?.`. Mirrors prettier's
    // isTestCall guard (`utilities/test-libraries.js`: `node.optional` → false).
    if call.optional {
        return false;
    }

    // Must have 2-3 arguments
    let arg_count = call.arguments.len();
    if !(2..=3).contains(&arg_count) {
        return false;
    }

    // First argument must be a string or template literal
    let first_is_string = match &call.arguments[0].kind {
        ast::ExpressionKind::Literal(lit) => {
            matches!(lit.value, ast::LiteralValue::String { .. })
        }
        ast::ExpressionKind::TemplateLiteral(_) => true,
        _ => false,
    };
    if !first_is_string {
        return false;
    }

    // Third argument (if present) must be a number (timeout)
    if arg_count == 3 {
        let third_is_number = match &call.arguments[2].kind {
            ast::ExpressionKind::Literal(lit) => {
                matches!(lit.value, ast::LiteralValue::Number(_))
            }
            _ => false,
        };
        if !third_is_number {
            return false;
        }
    }

    // Second argument must be the test's callback — and the two arities ask for different
    // callbacks, mirroring prettier's `isTestCall` (`utilities/test-libraries.js`). The
    // 2-argument form takes any function or arrow; the **3-argument** form additionally
    // requires a BLOCK body and at most ONE parameter (`isFunctionOrArrowExpressionWithBody`
    // + `getFunctionParameters(args[1]).length <= 1`). A function expression always has a
    // block body, so only the parameter count narrows it there.
    //
    // The narrowing is load-bearing, not decoration: without it a timeout call whose callback
    // takes two parameters, or whose arrow returns an expression, takes the flat layout and
    // runs past print width in a position where prettier breaks every argument out and holds
    // 100 — tsv over-width exactly where prettier is not. Pinned by
    // `tests/fixtures/typescript/expressions/calls/test_functions_timeout`.
    let callback_ok = match &call.arguments[1].kind {
        ast::ExpressionKind::FunctionExpression(func) => arg_count == 2 || func.params.len() <= 1,
        ast::ExpressionKind::ArrowFunctionExpression(arrow) => {
            arg_count == 2 || (!arrow.body.is_expression() && arrow.params.len() <= 1)
        }
        _ => false,
    };
    if !callback_ok {
        return false;
    }

    callee_is_test_pattern(call.callee, printer.source)
}

/// Whether `callee` is a simple identifier or a non-computed, non-optional member chain of
/// identifiers whose resolved names spell one of [`TEST_CALL_PATTERNS`].
///
/// Walks the chain leaf→root into a fixed array — no pattern is deeper than
/// [`TEST_PATTERN_MAX_DEPTH`], so a deeper chain is refused the moment it outgrows it — and
/// resolves each name through [`IdentName::resolve`], the channel every other name read uses
/// (an escaped `te\u0073t` is `test`). The same callees qualify as for the flat callee doc
/// ([`build_test_callee_flat_doc`], over [`get_member_chain_parts`]): an identifier root and
/// identifier properties only.
fn callee_is_test_pattern(callee: &ast::Expression<'_>, source: &str) -> bool {
    let mut names = [""; TEST_PATTERN_MAX_DEPTH];
    let mut depth = 0;
    let mut expr = callee;
    loop {
        let (id, object) = match &expr.kind {
            ast::ExpressionKind::Identifier(id) => (id, None),
            ast::ExpressionKind::MemberExpression(member) => {
                if member.computed || member.optional {
                    return false;
                }
                let ast::ExpressionKind::Identifier(property) = &member.property.kind else {
                    return false;
                };
                (property, Some(member.object))
            }
            _ => return false,
        };
        let Some(slot) = names.get_mut(depth) else {
            return false;
        };
        *slot = id.ident_name().resolve(id.span.start, source);
        depth += 1;
        match object {
            Some(object) => expr = object,
            None => break,
        }
    }
    let names = &mut names[..depth];
    names.reverse();
    is_test_pattern(names)
}

/// The most segments any [`TEST_CALL_PATTERNS`] entry has (`test.describe.parallel.only`).
const TEST_PATTERN_MAX_DEPTH: usize = max_pattern_segments(TEST_CALL_PATTERNS);

/// The most `.`-separated segments any of `patterns` has.
const fn max_pattern_segments(patterns: &[&str]) -> usize {
    let mut max = 0;
    let mut i = 0;
    while i < patterns.len() {
        let bytes = patterns[i].as_bytes();
        let mut segments = 1;
        let mut j = 0;
        while j < bytes.len() {
            if bytes[j] == b'.' {
                segments += 1;
            }
            j += 1;
        }
        if segments > max {
            max = segments;
        }
        i += 1;
    }
    max
}

/// Whether the root→leaf names `names` spell one of [`TEST_CALL_PATTERNS`] — the list written
/// as a match, root name first, so a callee is told apart without comparing it against each
/// entry in turn. Pinned to the list by this module's tests.
fn is_test_pattern(names: &[&str]) -> bool {
    matches!(
        names,
        ["it"
            | "describe"
            | "test"
            | "skip"
            | "xit"
            | "xdescribe"
            | "xtest"
            | "fit"
            | "fdescribe"
            | "ftest"]
            | ["it" | "describe", "only" | "skip"]
            | ["test", "only" | "skip" | "fixme" | "step" | "describe"]
            | [
                "test",
                "describe",
                "only" | "skip" | "fixme" | "parallel" | "serial"
            ]
            | ["test", "describe", "parallel" | "serial", "only"]
    )
}

#[cfg(test)]
mod tests {
    use super::{TEST_CALL_PATTERNS, TEST_PATTERN_MAX_DEPTH, is_test_pattern};

    /// Every name sequence up to one segment past the deepest pattern, over the patterns'
    /// own segments plus near misses, agrees with a plain membership test of the list.
    #[test]
    fn is_test_pattern_matches_the_pattern_list() {
        let mut alphabet: Vec<&str> = TEST_CALL_PATTERNS
            .iter()
            .flat_map(|pattern| pattern.split('.'))
            .collect();
        alphabet.extend(["Test", "a"]);
        alphabet.sort_unstable();
        alphabet.dedup();
        let in_list = |names: &[&str]| {
            TEST_CALL_PATTERNS
                .iter()
                .any(|pattern| pattern.split('.').eq(names.iter().copied()))
        };
        let mut matched = 0;
        let mut frontier: Vec<Vec<&str>> = vec![Vec::new()];
        for _ in 0..TEST_PATTERN_MAX_DEPTH {
            let mut next = Vec::new();
            for names in &frontier {
                for &segment in &alphabet {
                    let mut longer = names.clone();
                    longer.push(segment);
                    let expected = in_list(&longer);
                    assert_eq!(is_test_pattern(&longer), expected, "{}", longer.join("."));
                    matched += usize::from(expected);
                    next.push(longer);
                }
            }
            frontier = next;
        }
        assert_eq!(matched, TEST_CALL_PATTERNS.len());
        // One segment past any pattern is never a test callee.
        for pattern in TEST_CALL_PATTERNS {
            for &segment in &alphabet {
                let mut longer: Vec<&str> = pattern.split('.').collect();
                longer.push(segment);
                assert_eq!(
                    is_test_pattern(&longer),
                    in_list(&longer),
                    "{}",
                    longer.join(".")
                );
            }
        }
        assert_eq!(
            TEST_CALL_PATTERNS
                .iter()
                .map(|pattern| pattern.split('.').count())
                .max(),
            Some(TEST_PATTERN_MAX_DEPTH)
        );
    }
}