tsv_css 0.8.0

CSS formatter and parser compatible with Svelte's parseCss — 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
// Conversion from the internal AST to the public wire JSON.
//
// ARCHITECTURE: clean model inside, Svelte's scan semantics at the boundary.
//
// The internal AST is the spec-faithful semantic representation (decoded
// strings/escapes, structured values, normalized once during parsing) and is
// what the FORMATTER derives from. The public JSON strings, by contrast, are
// deliberately reconstructed from RAW SOURCE, because Svelte's parseCss builds
// them by raw text scanning and tsv's public AST is a drop-in for it:
// - Declaration `property`/`value` — raw split at the colon, block comments
//   stripped, ends trimmed (`read_declaration`/`read_value` semantics; the
//   structured internal value is never re-serialized into the JSON)
// - Declaration `end` — the `;`/`}` terminator scan position
// - Selector names — half-decoded like `read_identifier` (hex escapes decode,
//   identity escapes keep the backslash)
// Spans always index the real file; how an emitted position counts lines and a
// leading BOM is `WIRE_COORDINATES`'s statement, applied at EMISSION.
//
// The writer (`write.rs`) emits the wire JSON directly from the internal AST
// in one walk and **reuses the raw-source reconstruction helpers below**
// (`strip_css_comments_collecting`, `split_declaration_svelte_compat`,
// `raw_selector_name`, …), so the Svelte scan semantics live in one place. It
// is the sole emission path; `convert_ast_json_bytes` and its `_with_locations`
// sibling call it. That one walk also GATHERS the wire's flat `CSSComment[]`: a
// declaration's comments exist only in the strip its `value` comes from, so
// collecting them there rather than in a second pass is what keeps their offsets
// indexing the string they describe.

use crate::ast;
use std::borrow::Cow;
use tsv_lang::Span;
use tsv_lang::is_js_whitespace;
use tsv_lang::{trim_end_js_whitespace, trim_js_whitespace, trim_start_js_whitespace};

mod write;
pub(crate) use write::write_stylesheet_file_bytes;
pub use write::{write_css_children, write_css_comments};

/// Trim a wire STRING the way `parseCss` does.
///
/// ⚠️ [`is_js_whitespace`], **not** `str::trim`: every trim on this path mirrors a JS
/// `.trim()` / `.trimStart()` in Svelte's `read_value` / `read_attribute_value`
/// (`1-parse/read/style.js`), which is the JS `\s` class. Rust's `White_Space` **deletes a
/// U+0085** `parseCss` keeps and **keeps a U+FEFF** it deletes — `color: red<NEL>` came back
/// through the wire as `red`, a character of the author's declaration gone, and the printer
/// then emitted the shortened value.
///
/// ⚠️ Not `is_css_whitespace` either. That one is ASCII-only (css-syntax-3 §4.2) and is the
/// right class for value *separation* and value-text *collapsing* — a different question one
/// layer down. Here the oracle is a JS `.trim()` and nothing else.
///
/// The bodies are `tsv_lang`'s byte trims — the same class, answered on bytes for
/// the common case (the space after a colon, an empty tail) and handed to the char searcher
/// only when the stop byte could begin a non-ASCII member.
///
/// The three spellings exist so a call site names which end it trims; they are one rule, and
/// [`strip_css_comments_inner`]'s trim and the no-comment fast paths that stand in for it
/// (`write_declaration`, [`split_declaration_svelte_compat`]) must all ask the same one — a
/// fast path on a different class is the shortcut silently disagreeing with what it shortcuts.
#[inline]
fn trim_wire(s: &str) -> &str {
    trim_js_whitespace(s)
}

/// The leading half of [`trim_wire`], same class and same reason.
#[inline]
pub(super) fn trim_wire_start(s: &str) -> &str {
    trim_start_js_whitespace(s)
}

/// The trailing half of [`trim_wire`], same class and same reason.
#[inline]
pub(super) fn trim_wire_end(s: &str) -> &str {
    trim_end_js_whitespace(s)
}

/// Split a declaration source into property and value, matching Svelte's quirky behavior.
///
/// SVELTE QUIRK: When there's a CSS comment between the property name and the colon,
/// Svelte puts the comment AND the colon into the value instead of the property.
///
/// Example: `color /* comment */ : red`
/// - Normal split: property=`color /* comment */ `, value=`red`
/// - Svelte quirk: property=`color`, value=`/* comment */ : red`
///
/// This is a tokenization bug in Svelte's CSS parser, but we replicate it for compatibility.
/// Our internal AST remains semantically correct; this quirk is only applied in conversion.
///
/// Note: the writer runs `strip_css_comments` on the returned value, so the
/// public AST for `color /* c */ : red` ends up as property=`color`, value=`": red"`
/// (Svelte 5.55+ strips block comments from value strings post-split).
///
/// `colon_pos` is the declaration-relative byte offset of the real
/// `property : value` colon, recorded at parse time (`CssDeclaration::colon_offset`
/// minus the declaration start) — the writer only calls this on the comment-bearing
/// path, so the no-comment common case never re-derives it. A property comment may
/// itself contain a `:` (`color /* x:y */: red`), which is why the parser's colon is
/// the authority rather than a naive `find(':')`.
pub(super) fn split_declaration_svelte_compat(decl_source: &str, colon_pos: usize) -> (&str, &str) {
    let before_colon = &decl_source[..colon_pos];

    // Look for /* that appears after some property text
    if let Some(comment_idx) = before_colon.find("/*") {
        // Only apply quirk if there's actual property content before the comment
        let before_comment = &before_colon[..comment_idx];
        if !trim_wire(before_comment).is_empty() {
            // SVELTE QUIRK: Comment between property and colon
            // Property = just the text before the comment (trimmed)
            // Value = comment + colon + actual value (everything from comment onward)
            let property = trim_wire(before_comment);
            let value = &decl_source[comment_idx..];
            return (property, value);
        }
    }

    // Normal case: split at colon
    let property = &decl_source[..colon_pos];
    let value = trim_wire_start(&decl_source[colon_pos + 1..]);
    (property, value)
}

/// Remove all `/* ... */` block comments from a CSS string, then trim outer whitespace.
///
/// Matches Svelte 5.55+ behavior for Declaration `value` and Atrule `prelude` strings:
/// comments are stripped in place (surrounding whitespace preserved), then the result
/// is trimmed.
///
/// ⚠️ The trim is [`is_js_whitespace`], because the oracle's is `value.trim()` /
/// `value.trimStart()` in `read_value` (`1-parse/read/style.js`) — JS `\s`. Rust's
/// `str::trim` is `White_Space`, which **deletes a U+0085** `parseCss` keeps: `color:
/// red<NEL>` came back through the wire as `red`, a character of the author's declaration
/// gone, and the printer then emitted the shortened value. Not `is_css_whitespace`
/// either — that one is ASCII-only and is the right class for value *separation*, a
/// different question one layer down; here the oracle is a JS `.trim()` and nothing else.
///
/// String- and url()-aware: `/*` sequences inside `"..."`, `'...'`, or `url(...)` are
/// treated as content, not comments. Unterminated comments are left intact (parse
/// error caught elsewhere).
fn strip_css_comments(input: &str) -> Cow<'_, str> {
    strip_css_comments_inner(input, 0, None)
}

/// `strip_css_comments`, additionally recording every comment it strips as a wire
/// `CSSComment` on `sink` — the shape Svelte's `read_value` produces for a
/// declaration value or an at-rule prelude, where a captured comment carries both
/// its source span and a `position` offset into the emitted string.
///
/// `base` is `input`'s start offset in the document, so the recorded spans are in
/// document coordinates. Comments arrive in source order.
pub(super) fn strip_css_comments_collecting<'a>(
    input: &'a str,
    base: u32,
    sink: &mut Vec<WireComment>,
) -> Cow<'a, str> {
    strip_css_comments_inner(input, base, Some(sink))
}

/// One emitted `CSSComment`. `position` is `Some` only for a comment lifted out of
/// a declaration `value` / at-rule `prelude` string — Svelte sets the field there
/// and nowhere else, so a structural comment (between rules, in a block, in a
/// selector gap) carries the span alone.
#[derive(Clone, Copy)]
pub(super) struct WireComment {
    pub(super) span: Span,
    pub(super) position: Option<u32>,
}

fn strip_css_comments_inner<'a>(
    input: &'a str,
    base: u32,
    sink: Option<&mut Vec<WireComment>>,
) -> Cow<'a, str> {
    // Fast path: no block-comment delimiter anywhere means nothing is stripped, so
    // the result is just the trimmed input — a borrowed sub-slice, no allocation,
    // and nothing to record. (Conservative: a `/*` inside a string/url is preserved
    // either way, so those rare inputs fall to the owned path; correctness is
    // unaffected — the owned path records only what it actually strips.)
    if !input.contains("/*") {
        return Cow::Borrowed(trim_wire(input));
    }
    let mut out = String::with_capacity(input.len());
    // `(byte offset in `out`, source span)` per stripped comment. The offset is the
    // UNTRIMMED accumulated length, matching Svelte's `value.length` read at the
    // moment it consumes the comment; it becomes a `position` once the leading
    // whitespace is known (below).
    let mut stripped: Vec<(usize, Span)> = Vec::new();
    let mut rest = input;
    while let Some(ch) = rest.chars().next() {
        // Block comment — strip
        if crate::comments::is_comment_start(rest.as_bytes(), 0) {
            // `comment_end_checked`, not `comment_end`: an unterminated comment is kept
            // verbatim here rather than swallowed to end-of-input. Svelte's `read_comment`
            // errors on one instead of capturing it, so it goes unrecorded either way.
            let Some(end) = crate::comments::comment_end_checked(rest.as_bytes(), 0) else {
                out.push_str(rest);
                break;
            };
            if sink.is_some() {
                let start = base as usize + (input.len() - rest.len());
                stripped.push((out.len(), Span::new(start as u32, (start + end) as u32)));
            }
            rest = &rest[end..];
            continue;
        }
        // String literal — copy through unchanged (escape-aware)
        if ch == '"' || ch == '\'' {
            emit(&mut out, &mut rest, ch);
            copy_quoted(&mut out, &mut rest, ch);
            continue;
        }
        // url(...) — copy through to matching ')'
        if starts_with_url_open(rest) {
            out.push_str(&rest[..4]);
            rest = &rest[4..];
            copy_balanced_parens(&mut out, &mut rest);
            continue;
        }
        emit(&mut out, &mut rest, ch);
    }
    // Trim in place — truncate trailing whitespace, then drain leading — instead of
    // `out.trim().to_string()`, which copied the whole (already-owned) buffer again.
    let end = trim_wire_end(&out).len();
    let leading = out.len() - trim_wire_start(&out).len();
    if let Some(sink) = sink {
        // Svelte re-bases each captured position on the trimmed value by subtracting
        // the leading whitespace (clamped at zero, since a comment can sit ahead of
        // it), and never re-bases on the TRAILING trim — so a comment after the last
        // value token keeps a position past the end of the value it is attached to.
        // Positions are JS string indices, i.e. UTF-16 code units of that value.
        //
        // The offsets are recorded in source order, so the conversion walks `out`
        // once with a cursor rather than re-measuring each prefix from `leading`.
        sink.reserve(stripped.len());
        let (mut cursor, mut position) = (leading, 0usize);
        for (offset, span) in stripped {
            let offset = offset.max(leading);
            position += out[cursor..offset]
                .chars()
                .map(char::len_utf16)
                .sum::<usize>();
            cursor = offset;
            sink.push(WireComment {
                span,
                position: Some(position as u32),
            });
        }
    }
    out.truncate(end);
    out.drain(..leading.min(end));
    Cow::Owned(out)
}

/// Push `ch` to `out` and advance `rest` past it.
fn emit(out: &mut String, rest: &mut &str, ch: char) {
    out.push(ch);
    *rest = &rest[ch.len_utf8()..];
}

/// Copy a CSS string body (opening quote already emitted) through `out`,
/// advancing `rest` past the closing quote. Handles backslash escapes.
fn copy_quoted(out: &mut String, rest: &mut &str, quote: char) {
    while let Some(ch) = rest.chars().next() {
        emit(out, rest, ch);
        if ch == '\\' {
            if let Some(esc) = rest.chars().next() {
                emit(out, rest, esc);
            }
        } else if ch == quote {
            break;
        }
    }
}

/// Copy through `out` until the depth-1 close paren that ends `url(...)` (or eof).
/// Skips over quoted strings so embedded `)` characters are not treated as terminators.
fn copy_balanced_parens(out: &mut String, rest: &mut &str) {
    let mut depth: u32 = 1;
    while depth > 0 {
        let Some(ch) = rest.chars().next() else { break };
        emit(out, rest, ch);
        match ch {
            '(' => depth += 1,
            ')' => depth -= 1,
            '"' | '\'' => copy_quoted(out, rest, ch),
            _ => {}
        }
    }
}

/// Whether `s` begins with `url(` (case-insensitive for `url`).
fn starts_with_url_open(s: &str) -> bool {
    let bytes = s.as_bytes();
    bytes.len() >= 4
        && bytes[0].eq_ignore_ascii_case(&b'u')
        && bytes[1].eq_ignore_ascii_case(&b'r')
        && bytes[2].eq_ignore_ascii_case(&b'l')
        && bytes[3] == b'('
}

/// Convert PreludeValue to string representation for the public AST.
///
/// Svelte 5.55.x strips `/* ... */` block comments from at-rule preludes (surrounding
/// whitespace preserved, then trimmed). Applied to all source-extracted variants;
/// `Values` is built from parsed tokens that never contained comments.
pub(super) fn convert_prelude_to_string<'src>(
    prelude: &ast::PreludeValue<'_>,
    source: &'src str,
) -> Cow<'src, str> {
    match prelude {
        ast::PreludeValue::Values { span, .. } => {
            // Extract the prelude verbatim from source and strip comments, matching
            // Svelte (which removes `/* ... */` from the `@import` prelude string while
            // preserving the surrounding whitespace, then trims). Extracting from the
            // span (rather than rejoining the structured values) keeps the public AST
            // byte-for-byte with Svelte even when comments sit between the url/string and
            // the media query — the structured values exist for the printer's quote
            // normalization and media-query wrapping.
            strip_css_comments(span.extract(source))
        }
        // Extract verbatim from source (comments stripped, outer-trimmed) so the public
        // AST matches Svelte, which stores the raw prelude — e.g. `@layer a , b` → `a , b`
        // and `@namespace url(  x  )` → `url(  x  )`. The internal `content` string is a
        // normalized (printer-facing) form; the AST must stay source-faithful, like the
        // `Media`/`Supports`/`Container`/`Values` branches.
        ast::PreludeValue::Raw { span, .. } => strip_css_comments(span.extract(source)),
        // @scope selector lists: `[(root)]? [to (limit)]?`. Extracted verbatim from
        // `span` for fidelity (a bare `@scope` has a zero-width span → `""`), like the
        // sibling raw/condition branches.
        ast::PreludeValue::Selectors { span, .. } => strip_css_comments(span.extract(source)),
        // `@custom-selector`: the span is the raw reader's, so the routed and the
        // verbatim-fallback preludes emit the same bytes.
        ast::PreludeValue::CustomSelector { span, .. } => strip_css_comments(span.extract(source)),
        ast::PreludeValue::Supports { span, .. } => strip_css_comments(span.extract(source)),
        ast::PreludeValue::Container { span, .. } => strip_css_comments(span.extract(source)),
        ast::PreludeValue::Media { span, .. } => strip_css_comments(span.extract(source)),
    }
}

/// Check if a complex selector contains Invalid simple selectors (from forgiving parsing)
pub(super) fn selector_contains_invalid(complex: &ast::ComplexSelector<'_>) -> bool {
    for relative in complex.children {
        for simple in relative.selectors {
            if matches!(simple, ast::SimpleSelector::Invalid { .. }) {
                return true;
            }
        }
    }
    false
}

/// Extract a selector name from source, skipping `prefix_len` bytes of sigil (`.`/`#`),
/// half-decoded the way Svelte's `read_identifier` does it: hex escapes (`\3A `,
/// `\1F4A9`, optional single whitespace terminator) decode to their codepoint — except a
/// decoded backslash (`\5c`), which is spelled `\\` — while identity escapes (`\?`)
/// keep the backslash. The internal AST stores the fully
/// decoded spec form; this reconstructs Svelte's public form at the boundary.
///
/// Every selector, at-rule and attribute name the wire writes comes through here, and
/// almost none holds an escape — so the common answer is asked of the **host** before the
/// name is ever sliced: [`tsv_lang::swar::next_byte_below_or_of`] reads the document a
/// word at a time over `[start, end)` and reports the first `\` or non-ASCII byte, and a
/// name with neither is its own source slice, borrowed. A name that holds either goes to
/// [`raw_selector_name_decode`], which asks the exact question (a non-ASCII name with no
/// `\` is still borrowed there).
pub(super) fn raw_selector_name(source: &str, span: Span, prefix_len: usize) -> Cow<'_, str> {
    let (start, end) = (span.start as usize + prefix_len, span.end as usize);
    if end <= source.len()
        && tsv_lang::swar::next_byte_below_or_of(source.as_bytes(), start, end, 0, [b'\\']) == end
    {
        // No `\` and no byte at or above `0x80` in `[start, end)`: both ends sit on ASCII
        // bytes or the document's end, so the slice is on char boundaries.
        return Cow::Borrowed(&source[start..end]);
    }
    raw_selector_name_decode(source, span, prefix_len)
}

/// [`raw_selector_name`]'s arm for a name the host-word test did not clear — one holding a
/// `\` or a non-ASCII byte. Outlined and cold so the host-word test is all that inlines
/// where a writer asks for a name.
#[cold]
#[inline(never)]
fn raw_selector_name_decode(source: &str, span: Span, prefix_len: usize) -> Cow<'_, str> {
    let raw = &source[span.start as usize + prefix_len..span.end as usize];
    // No backslash means no escapes to decode, so the name is the raw source slice
    // verbatim — borrowed, no allocation (a non-ASCII name the host-word test refused).
    if !raw.contains('\\') {
        return Cow::Borrowed(raw);
    }
    let mut out = String::with_capacity(raw.len());
    let mut chars = raw.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch != '\\' {
            out.push(ch);
            continue;
        }
        if chars.peek().is_some_and(char::is_ascii_hexdigit) {
            let mut hex = String::new();
            for _ in 0..6 {
                match chars.peek() {
                    Some(&d) if d.is_ascii_hexdigit() => {
                        hex.push(d);
                        chars.next();
                    }
                    _ => break,
                }
            }
            // Optional single whitespace terminator (Svelte: `(\r\n|\s)?`).
            //
            // ⚠️ [`is_js_whitespace`] — that `\s` is a JS regex class, **not**
            // `char::is_whitespace`. This is the SECOND reader of the terminator rule (the
            // lexer's `read_identifier` is the first, and decides the token BOUNDARY), so
            // the two must ask the same class or the wire disagrees with the span it was cut
            // from: `.a\41<ZWNBSP>b` is `aAb` to `parseCss` and `.a\41<NEL>b` is a rejection.
            if chars.peek() == Some(&'\r') {
                chars.next();
                if chars.peek() == Some(&'\n') {
                    chars.next();
                }
            } else if chars.peek().copied().is_some_and(is_js_whitespace) {
                chars.next();
            }
            // Surrogate/overflow codepoints are unrepresentable in Rust strings —
            // dropped, same as `escapes::decode_escape_sequences`
            if let Ok(cp) = u32::from_str_radix(&hex, 16)
                && let Some(c) = char::from_u32(cp)
            {
                // A decoded backslash is spelled `\\`, the same two characters an identity
                // escape keeps (`\\` stays `\\` below), so a backslash in the wire name
                // always reads as the head of an escape. The internal AST keeps the one
                // decoded character; this is the public form only.
                if c == '\\' {
                    out.push_str("\\\\");
                } else {
                    out.push(c);
                }
            }
        } else if let Some(next) = chars.next() {
            out.push('\\');
            out.push(next);
        } else {
            out.push('\\');
        }
    }
    Cow::Owned(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Owns the `Cow` result so assertions can compare against `&str` literals.
    fn strip(s: &str) -> String {
        strip_css_comments(s).into_owned()
    }

    #[test]
    fn strip_css_comments_basic_removal_and_trim() {
        assert_eq!(strip("/* c */ 12px"), "12px");
        assert_eq!(strip("blue /* c */"), "blue");
        assert_eq!(strip("/* a */ red"), "red");
    }

    #[test]
    fn strip_css_comments_interior_whitespace_preserved() {
        assert_eq!(strip("var(--a, /* c */ red)"), "var(--a,  red)",);
        assert_eq!(
            strip("sidebar /* x */ (min-width: 100px)"),
            "sidebar  (min-width: 100px)",
        );
    }

    #[test]
    fn strip_css_comments_inside_strings_are_preserved() {
        assert_eq!(strip("\"/* not a comment */\""), "\"/* not a comment */\"",);
        assert_eq!(strip("'/* keep */'"), "'/* keep */'");
    }

    #[test]
    fn strip_css_comments_inside_url_are_preserved() {
        assert_eq!(
            strip("url(\"data:image/svg+xml,/* x */\")"),
            "url(\"data:image/svg+xml,/* x */\")",
        );
    }

    #[test]
    fn strip_css_comments_inside_other_functions_are_stripped() {
        // Only url() is special — calc/var/etc. follow normal CSS tokenization,
        // so block comments inside them are stripped just like at top level.
        assert_eq!(strip("calc(/* x */ 1px + 2px)"), "calc( 1px + 2px)",);
        assert_eq!(strip("URL(/* keep */)"), "URL(/* keep */)");
    }

    #[test]
    fn strip_css_comments_unterminated_kept_verbatim() {
        assert_eq!(strip("red /* oops"), "red /* oops");
    }

    #[test]
    fn strip_css_comments_escaped_quote_does_not_close_string() {
        assert_eq!(
            strip("\"a\\\" /* in str */ b\" /* real */ c"),
            "\"a\\\" /* in str */ b\"  c",
        );
    }

    /// One recorded comment, flattened: `(span start, span end, position)`.
    type Recorded = (u32, u32, Option<u32>);

    /// The emitted value plus what a collecting strip recorded alongside it.
    fn collect(input: &str, base: u32) -> (String, Vec<Recorded>) {
        let mut sink = Vec::new();
        let value = strip_css_comments_collecting(input, base, &mut sink).into_owned();
        let recorded = sink
            .iter()
            .map(|c| (c.span.start, c.span.end, c.position))
            .collect();
        (value, recorded)
    }

    /// A `position` is an index into the *emitted* value, so it can only be
    /// graded beside the value it indexes — which is why these assert the pair.
    #[test]
    fn strip_css_comments_collecting_records_spans_and_positions() {
        // Spans are document-absolute (`base`-shifted); positions are not.
        assert_eq!(
            collect(" red /* c */ blue", 100),
            ("red  blue".to_owned(), vec![(105, 112, Some(4))]),
        );
        // Every comment, in source order, each measured on the value so far.
        assert_eq!(
            collect("a /* c1 */ b /* c2 */ c", 0),
            (
                "a  b  c".to_owned(),
                vec![(2, 10, Some(2)), (13, 21, Some(5))],
            ),
        );
    }

    /// The three edges of Svelte's own rebasing, which is what the offsets mean.
    #[test]
    fn strip_css_comments_collecting_position_edges() {
        // Clamped at zero: a comment AHEAD of the leading whitespace it is
        // rebased by (`max(0, …)` in `read_value`).
        assert_eq!(
            collect("/* c */ red", 0),
            ("red".to_owned(), vec![(0, 7, Some(0))])
        );
        // Never rebased on the TRAILING trim, so a comment after the last token
        // keeps a position one past the end of the value it is attached to.
        assert_eq!(
            collect("red /* c */", 0),
            ("red".to_owned(), vec![(4, 11, Some(4))])
        );
        // UTF-16 code units, not bytes or chars — a JS string index. The span
        // stays in BYTES (7 of them precede the comment; the value is 5 units).
        assert_eq!(
            collect("'😀' /* c */", 0),
            ("'😀'".to_owned(), vec![(7, 14, Some(5))]),
        );
    }

    /// An unterminated comment is kept verbatim rather than swallowed, and goes
    /// unrecorded — Svelte's `read_comment` errors on one instead of capturing it.
    #[test]
    fn strip_css_comments_collecting_skips_unterminated() {
        assert_eq!(
            collect("red /* oops", 0),
            ("red /* oops".to_owned(), vec![])
        );
    }
}