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
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
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
// Value splitting and whitespace normalization: top-level splits that respect
// parens/quotes/comments, plus prettier-style whitespace collapsing.

use std::borrow::Cow;
use tsv_lang::Span;

use crate::whitespace::is_css_whitespace;

/// Emit `consumed` — an opaque construct starting at the char the loop already took from
/// `chars` — and step the iterator and `byte_pos` past it.
///
/// These loops walk **chars** but ask **byte**-indexed helpers how far a construct reaches
/// (`escapes::escape_len` for an escape, `comments::comment_end` for a comment — each the
/// single definition of its own rule), so after emitting one the iterator has to be moved
/// to match. Doing that in one place keeps the two off-by-one hazards — the leading char is
/// already consumed, and `chars` counts code points while the span counts bytes — from
/// being re-derived per arm.
fn emit_opaque(
    result: &mut String,
    chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
    byte_pos: &mut usize,
    consumed: &str,
) {
    result.push_str(consumed);
    step_past(chars, byte_pos, consumed);
}

/// Step the iterator and `byte_pos` past `consumed` — the stepping half of
/// [`emit_opaque`], for an arm that emits a *rewritten* form of what it consumed.
fn step_past(
    chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
    byte_pos: &mut usize,
    consumed: &str,
) {
    for _ in 0..consumed.chars().count() - 1 {
        chars.next();
    }
    *byte_pos += consumed.len() - consumed.chars().next().map_or(0, char::len_utf8);
}

/// The unquoted `url(...)` token starting at `start` in `s`, if one does: the ident `url`
/// (ASCII case-insensitive, and not the tail of a longer ident — `foourl(` is the function
/// `foourl`) glued to a `(` whose argument is not a string. The extent is
/// [`super::consume_paren_group`]'s, the same bounding scan the value pass copies the
/// token with.
fn unquoted_url_token_at(s: &str, start: usize) -> Option<&str> {
    let bytes = s.as_bytes();
    let open = start + 3;
    if bytes.get(open) != Some(&b'(') || !s[start..open].eq_ignore_ascii_case("url") {
        return None;
    }
    if s[..start]
        .chars()
        .next_back()
        .is_some_and(super::is_css_ident_code_point)
    {
        return None;
    }
    if super::url_arg_is_quoted(s, open) {
        return None;
    }
    let mut end = open;
    super::consume_paren_group(s, &mut end);
    Some(&s[start..end])
}

/// Normalize CSS whitespace in extracted source text
///
/// Single-pass normalization that:
/// - Collapses consecutive whitespace to single spaces
/// - Removes spaces after opening parentheses: `( expr` → `(expr`
/// - Removes spaces before closing parentheses: `expr )` → `expr)`
/// - Preserves content inside quoted strings (`'...'` and `"..."`)
/// - Preserves content inside CSS comments (`/* ... */`)
///
/// This matches Prettier's normalization for calc(), var(), and other CSS functions.
///
/// # Example
/// ```ignore
/// assert_eq!(
///     normalize_css_whitespace("10px  /* test */  20px"),
///     "10px /* test */ 20px"
/// );
/// assert_eq!(
///     normalize_css_whitespace("var( --a, /* comment */ red )"),
///     "var(--a, /* comment */ red)"
/// );
/// assert_eq!(
///     normalize_css_whitespace("url( 'path with spaces' )"),
///     "url('path with spaces')"
/// );
/// ```
pub(crate) fn normalize_css_whitespace(s: &str) -> Cow<'_, str> {
    // Fast path: input with no byte the normalizer acts on normalizes to itself.
    // With no ASCII whitespace (to collapse), no `(`/`)` (paren-space stripping),
    // no `,` (comma spacing), no `/` (comment spacing) and no quote (string
    // handling), every char takes the loop's regular-character branch, so
    // `pending_space` never sets and the final `trim()` is a no-op — the output
    // equals the input. Return the source slice *borrowed* — no allocation, no
    // char scan, no per-char push, no trim; a caller holding the value's span can
    // then emit it as a zero-allocation `DocText::SourceSpan` (see
    // `build_identifier_doc`). The overwhelmingly-common `CssValue::Identifier`
    // value (`red`, `flex`, `1px`) hits this. Non-ASCII bytes still bail to the
    // slow path (conservative), but there they are preserved: CSS whitespace is
    // ASCII-only, so NBSP/U+2028/… are value content, not separators to collapse.
    if s.bytes().all(is_normalize_noop_byte) {
        return Cow::Borrowed(s);
    }

    let mut result = String::with_capacity(s.len());
    // `char_indices`, not `chars`: the escape arm below needs the byte offset to ask
    // `escape_len` how far the escape reaches (the single definition of that rule).
    let mut chars = s.char_indices().map(|(_, c)| c).peekable();
    let mut byte_pos = 0usize;
    let mut in_string = false;
    let mut string_delim = '\0';
    let mut pending_space = false;
    // Open `(` groups: the `)` arm strips the padding before a `)` only when it closes
    // one. A `)` with no `(` open is a bare `<)-token>` between two values (CSS Syntax 3
    // §5.4.7), and the whitespace before it is a separator like any other — collapsed to
    // one space, never removed (`screen ) print` keeps its space; prettier's value parser
    // throws on the unbalanced paren and freezes the whole value verbatim, so it keeps it
    // too — see `stray_close_paren`).
    let mut paren_depth = 0usize;
    // How far into `result` the last **literal whitespace escape** emitted below reaches.
    // The `)` and `,` arms strip the spaces before their delimiter, and an escape's payload
    // IS a space (`(1px\ )`, `(a\ ,b)`) — stripping it strands the backslash onto the
    // delimiter the arm is about to push, which is either output that no longer parses
    // (`(1px\)`) or a different value (`(a\, b)`, one argument where there were two).
    // Neither strip may cross this mark. Position, not kind: only the escape's own payload
    // is protected, so a separator space pushed after it still strips.
    let mut escape_payload_end = 0usize;

    while let Some(ch) = chars.next() {
        let ch_start = byte_pos;
        byte_pos += ch.len_utf8();
        // A comment is opaque: emit it whole (`crate::comments::comment_end`) rather than
        // tracking an `in_comment` flag through three per-char branches. A space is added
        // on each side when one isn't already there — `foo,/*` → `foo, /*`, and
        // `*/bar` → `*/ bar` via `pending_space` — which is what makes a glued comment
        // normalize the same way in every value position.
        if !in_string && crate::comments::is_comment_start(s.as_bytes(), ch_start) {
            if !result.is_empty() && !result.ends_with(' ') && !result.ends_with('(') {
                result.push(' ');
            }
            let end = crate::comments::comment_end(s.as_bytes(), ch_start);
            emit_opaque(&mut result, &mut chars, &mut byte_pos, &s[ch_start..end]);
            pending_space = true;
            continue;
        }

        // String delimiter handling (outside comments)
        if !in_string && (ch == '\'' || ch == '"') {
            if pending_space && !result.is_empty() && !result.ends_with('(') {
                result.push(' ');
            }
            pending_space = false;
            in_string = true;
            string_delim = ch;
            result.push(ch);
            continue;
        }

        // Inside a string, a `\` escapes the next code point — so an escaped quote
        // (`"a\"b"`) must NOT close the string. Copy the pair verbatim, exactly as
        // `collapse_whitespace_runs` does. (No input reaches here with one today: a
        // string-valued declaration prints through the string-aware `format_string_literal`,
        // not this normalizer — verified across `content`, custom properties, `url()`,
        // font lists, `@media` preludes, attribute selectors and `grid-template-areas`.
        // The arm is here so the invariant does not depend on that routing holding.)
        if in_string && ch == '\\' {
            result.push(ch);
            if let Some(next) = chars.next() {
                result.push(next);
                byte_pos += next.len_utf8();
            }
            continue;
        }

        if in_string && ch == string_delim {
            in_string = false;
            result.push(ch);
            pending_space = false;
            continue;
        }

        // Inside string - preserve everything
        if in_string {
            result.push(ch);
            continue;
        }

        // A CSS escape is opaque: copy it WHOLE and verbatim, so none of its interior
        // can reach the whitespace branch below (`crate::escapes::escape_len`).
        //
        // Its payload is value **content**, never a separator: `\` followed by
        // whitespace is a valid escape whose escaped code point IS that whitespace
        // (CSS Syntax 3 §4.3.4 / §4.3.7). Collapsing or trimming the payload strands
        // the backslash, which then escapes the next delimiter (`;`, `)`, `!`) and the
        // output no longer parses. A hex escape's whitespace *terminator* is part of
        // the escape too (`\41 2px` is one ident), so collapsing a run there would
        // fuse two tokens into one.
        if ch == '\\'
            && let Some(len) = crate::escapes::escape_len(s, ch_start)
        {
            if pending_space && !result.is_empty() {
                result.push(' ');
            }
            pending_space = false;
            emit_opaque(
                &mut result,
                &mut chars,
                &mut byte_pos,
                &s[ch_start..ch_start + len],
            );
            // A **literal** escape of a whitespace character — `\` plus that one code point,
            // so exactly two bytes, CSS whitespace being ASCII. §4.3.4's "anything else"
            // branch *returns the current input code point*, so the payload is the escaped
            // character itself: content the strips below must not reach. A **hex** escape's
            // trailing whitespace is the optional *terminator* of §4.3.7 ("if the next input
            // code point is whitespace, consume it as well", the returned code point being
            // the hex value), so it carries nothing and both formatters drop it
            // (`a\41 ` → `a\41`) — deliberately unguarded, the same split
            // `trim_end_preserving_escape` makes at the value's own end.
            if len == 2 && is_css_whitespace(s.as_bytes()[ch_start + 1] as char) {
                escape_payload_end = result.len();
            }
            continue;
        }

        // An unquoted `url(...)` is one opaque `<url-token>` (CSS Syntax 3 §4.3.6): its
        // content is copied verbatim — a comma inside it is content, not the separator the
        // arm below spaces (`url(x,y)` and `url(x ,y)` are two different urls, and
        // prettier keeps each) — and only the padding inside its parens trims, the same
        // `trim_url_raw` the token takes when the lexer hands it to the printer whole. This
        // normalizer sees one only where a value is re-read from source text: an `@import`
        // prelude's tail, or a declaration value carrying a comment.
        if let Some(raw) = unquoted_url_token_at(s, ch_start) {
            if pending_space && !result.is_empty() {
                result.push(' ');
            }
            pending_space = false;
            // `trim_url_raw` is `None` only for text with no `(`, and this token has one.
            let trimmed = crate::url::trim_url_raw(raw).unwrap_or(Cow::Borrowed(raw));
            result.push_str(&trimmed);
            step_past(&mut chars, &mut byte_pos, raw);
            continue;
        }

        // Opening paren - skip following whitespace
        if ch == '(' {
            if pending_space && !result.is_empty() {
                result.push(' ');
            }
            pending_space = false;
            paren_depth += 1;
            result.push(ch);
            // Skip whitespace after opening paren (ASCII only — NBSP and other
            // Unicode whitespace are value content, not separators).
            while chars.peek().is_some_and(|&c| is_css_whitespace(c)) {
                chars.next();
                byte_pos += 1;
            }
            continue;
        }

        // Closing paren - remove trailing whitespace (never an escape's payload) when it
        // closes a group; a stray `)` keeps its separator space (see `paren_depth`).
        if ch == ')' {
            if paren_depth > 0 {
                paren_depth -= 1;
                while result.len() > escape_payload_end && result.ends_with(' ') {
                    result.pop();
                }
            } else if pending_space && !result.is_empty() {
                result.push(' ');
            }
            result.push(ch);
            pending_space = false;
            continue;
        }

        // Comma - no space before, single space after (CSS never wants a space
        // before a comma, e.g. a media-query list `projection, tv`) — but the space an
        // escape spells is content, not the separator this arm is normalizing away.
        if ch == ',' {
            while result.len() > escape_payload_end && result.ends_with(' ') {
                result.pop();
            }
            result.push(ch);
            pending_space = true;
            continue;
        }

        // Whitespace - mark pending (collapse consecutive). ASCII-only: NBSP and
        // other Unicode whitespace fall through to the regular-character branch
        // and are preserved verbatim as value content.
        if is_css_whitespace(ch) {
            if !result.is_empty() && !result.ends_with('(') {
                pending_space = true;
            }
            continue;
        }

        // Regular character - add pending space if needed
        if pending_space && !result.is_empty() {
            result.push(' ');
            pending_space = false;
        }
        result.push(ch);
    }

    // The char-loop above already suppresses leading/trailing whitespace in the
    // common case, so the trim is usually a no-op; return the owned buffer as-is
    // rather than cloning it. Only the rare edge cases that preserve verbatim
    // boundary whitespace (e.g. an unterminated string/comment) pay the copy —
    // byte-identical either way. The trim is CSS-whitespace-only and escape-aware,
    // matching the char-loop's ASCII-only whitespace handling above: a boundary
    // non-ASCII space (NBSP, em space) is value content — the char-loop pushes it
    // verbatim, and stripping it here would silently drop it (deleting a string
    // element whose leading/trailing space the value parser deliberately kept). The
    // trailing trim also spares an escape's payload (`50px\ `), which the loop copied
    // verbatim as content.
    let trimmed = crate::escapes::trim_css(&result);
    if trimmed.len() == result.len() {
        Cow::Owned(result)
    } else {
        Cow::Owned(trimmed.to_string())
    }
}

/// Collapse each maximal whitespace run (spaces, tabs, and line breaks alike) to a
/// single space — the whitespace normalization tsv applies at every other
/// selector-argument position, and what prettier applies inside a selector.
/// String- and comment-aware: whitespace inside a `'…'`/`"…"` string (`\` escapes
/// honored) or a `/* … */` comment is content and stays verbatim.
///
/// Used for a dropped forgiving-selector item (`SimpleSelector::Invalid` — e.g.
/// `:is(.a > . > .b)`, whose empty `.` class makes the complex selector invalid):
/// the item is kept in the output verbatim except for this whitespace collapse, so
/// it renders on one line and matches prettier (see conformance_svelte.md §CSS
/// Corrections). Idempotent by construction — the collapsed form is a fixed point.
pub(crate) fn collapse_whitespace_runs(s: &str) -> Cow<'_, str> {
    // Fast path: nothing to collapse — no run needs rewriting when every whitespace
    // byte is already a lone ASCII space with non-whitespace on both sides (the
    // common single-spaced form). Whitespace inside a `'…'`/`/*…*/` interior only
    // trips this into the (correct, interior-preserving) slow path — a harmless
    // extra allocation, never a wrong answer.
    if !needs_whitespace_collapse(s) {
        return Cow::Borrowed(s);
    }

    let mut result = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();
    // The escape arm below needs the byte offset to ask `escape_len` how far the escape
    // reaches (the single definition of that rule).
    let mut byte_pos = 0usize;
    let mut in_string = false;
    let mut string_delim = '\0';

    while let Some(ch) = chars.next() {
        let ch_start = byte_pos;
        byte_pos += ch.len_utf8();
        // String interior: copy verbatim, honoring `\` escapes so an escaped quote
        // (or a `\`-continuation newline) doesn't end the string early.
        if in_string {
            result.push(ch);
            if ch == '\\' {
                if let Some(next) = chars.next() {
                    result.push(next);
                    byte_pos += next.len_utf8();
                }
            } else if ch == string_delim {
                in_string = false;
            }
            continue;
        }
        // A comment is copied verbatim — its whitespace is content, not a run to collapse.
        // Taken whole via `crate::comments::comment_end` (the single definition of a
        // comment's extent) rather than tracked with an `in_comment` flag, so the interior
        // needs no per-char branch at all.
        if crate::comments::is_comment_start(s.as_bytes(), ch_start) {
            let end = crate::comments::comment_end(s.as_bytes(), ch_start);
            emit_opaque(&mut result, &mut chars, &mut byte_pos, &s[ch_start..end]);
            continue;
        }
        // String starts.
        if ch == '\'' || ch == '"' {
            in_string = true;
            string_delim = ch;
            result.push(ch);
            continue;
        }
        // An escape is opaque: copy it whole, so its interior never reaches the
        // whitespace-run branch below. `\<TAB>` is a valid escape whose escaped code
        // point IS that tab (css-syntax-3 §4.3.4/§4.3.7), so collapsing it to a space
        // would silently rename `.b\<TAB>c` to the class `b c` — content corruption, not
        // whitespace normalization. A hex escape's whitespace terminator is part of the
        // escape too (§4.3.7).
        if ch == '\\'
            && let Some(len) = crate::escapes::escape_len(s, ch_start)
        {
            emit_opaque(
                &mut result,
                &mut chars,
                &mut byte_pos,
                &s[ch_start..ch_start + len],
            );
            continue;
        }
        // A whitespace run outside strings/comments: consume it whole and emit one
        // space in its place.
        if is_css_whitespace(ch) {
            while chars.peek().is_some_and(|&next| is_css_whitespace(next)) {
                let next = chars.next().unwrap_or_default();
                byte_pos += next.len_utf8();
            }
            result.push(' ');
            continue;
        }
        result.push(ch);
    }

    Cow::Owned(result)
}

/// Whether `collapse_whitespace_runs` would rewrite `s`: true if any whitespace
/// byte is not a lone ASCII space (a tab/newline/CR/FF, or a space adjacent to
/// more whitespace). A conservative superset — it counts whitespace inside a
/// string/comment interior too, so a true answer there just routes to the slow
/// path (which preserves the interior); it never returns false when a collapse is
/// actually needed.
fn needs_whitespace_collapse(s: &str) -> bool {
    let bytes = s.as_bytes();
    bytes.iter().enumerate().any(|(i, &b)| {
        if b == b' ' {
            bytes.get(i + 1) == Some(&b' ')
        } else {
            b.is_ascii_whitespace()
        }
    })
}

/// Whether `normalize_css_whitespace` leaves byte `b` untouched: an ASCII,
/// non-whitespace byte that is none of the structural bytes the normalizer acts on
/// (`(` `)` `,` `/` and the two quotes). A string composed only of such bytes
/// normalizes to itself, enabling the scan-skip fast path. Non-ASCII bytes (≥0x80)
/// are conservatively treated as "acted on" so they bail to the slow path — where
/// they are preserved (CSS whitespace is ASCII-only, so Unicode whitespace is
/// content). The acted-on ASCII whitespace set (`\t \n \x0C \r` space) is exactly
/// `char::is_ascii_whitespace` / `is_css_whitespace` — note U+000B (VT) is *not*
/// CSS whitespace, so it is a no-op byte the normalizer preserves.
/// The bytes [`normalize_css_whitespace`] acts on besides whitespace — the paren pair, the
/// comma, the comment lead, the two quotes: [`is_normalize_noop_byte`]'s structural half,
/// spelled once so the host-word scan below and the byte predicate cannot disagree (the
/// `host_word_noop_scan_agrees_with_the_byte_predicate` test holds them together).
const NORMALIZE_STRUCTURAL: [u8; 6] = [b'(', b')', b',', b'/', b'\'', b'"'];

/// [`normalize_css_whitespace`]'s scan-skip question asked of the **host**: does `source[span]`
/// hold no byte the normalizer acts on? A `true` is exactly `bytes().all(is_normalize_noop_byte)`
/// and the caller may emit the span verbatim. Answered by
/// [`tsv_lang::swar::next_byte_below_or_of`] over the document, so a value shorter than a word
/// (42% of identifier values on a real corpus) is one word read from the host instead of a
/// scalar class test per byte — the reason this takes a span and not the slice.
///
/// Conservative on the ASCII control bytes the predicate accepts as no-ops (0x00–0x08, `<VT>`,
/// 0x0E–0x1F — none of them CSS whitespace, none acted on): the word's `< 0x21` floor refuses
/// them, and a refused span merely takes [`normalize_css_whitespace`], which hands it back as
/// its own text. Every other byte class is answered identically.
pub(crate) fn normalize_is_noop_in(source: &str, span: Span) -> bool {
    let bytes = source.as_bytes();
    let (from, end) = (span.start as usize, span.end as usize);
    let noop =
        tsv_lang::swar::next_byte_below_or_of(bytes, from, end, 0x21, NORMALIZE_STRUCTURAL) == end;
    debug_assert!(!noop || bytes[from..end].iter().all(|&b| is_normalize_noop_byte(b)));
    noop
}

#[inline]
fn is_normalize_noop_byte(b: u8) -> bool {
    b.is_ascii()
        && !matches!(
            b,
            b'\t' | b'\n' | 0x0C | b'\r' | b' ' | b'(' | b')' | b',' | b'/' | b'\'' | b'"'
        )
}

/// Split by top-level spaces, preserving content inside parentheses, quotes, and comments
///
/// Used for space-separated values like `var(--b) color-mix(...)`.
/// Returns individual values that can be wrapped independently.
pub(crate) fn split_by_space_preserving_parens(content: &str) -> Vec<&str> {
    split_top_level(content, |b| b == b' ' || b == b'\t', true)
}

/// Split function arguments by top-level commas, preserving nested parens, quotes, and comments
///
/// Used when extracting function arguments from source while preserving comments.
/// Handles nested parentheses correctly so `func(a, b)` inside an arg isn't split.
/// Skips over block comments so commas inside `/* a, b */` aren't treated as separators.
/// Skips over quoted strings so commas inside `"a, b"` aren't treated as separators.
pub(crate) fn split_args_by_comma(content: &str) -> Vec<&str> {
    split_top_level(content, |b| b == b',', false)
}

/// Did `split_args_by_comma(content)` consume a **closing** comma — one in final position,
/// which produces no part (see `split_top_level`'s tail)?
///
/// Derived from the split rather than from a second scan of the text: the parts are
/// subslices of `content` separated by exactly one comma byte each, so N parts account for
/// `sum(len) + N - 1` bytes — one short of `content.len()` exactly when a comma was
/// consumed without producing a part. Reading it off the split is what makes it agree with
/// the split by construction, including on the shapes a byte test gets wrong: an *escaped*
/// comma (`x\,`) never separated anything, so it is inside a part and the count is
/// unmoved.
///
/// `content` must have no trailing whitespace, or the space after the comma becomes a
/// (whitespace-only) final part and hides it.
pub(crate) fn has_closing_comma(content: &str, parts: &[&str]) -> bool {
    let consumed: usize = parts.iter().map(|part| part.len()).sum();
    !parts.is_empty() && content.len() == consumed + parts.len()
}

/// Split `content` at top-level bytes matching `is_sep`, preserving content inside
/// parentheses, quotes (`'`/`"`), block comments (`/* */`), and **escapes**.
///
/// The shared scanner behind `split_by_space_preserving_parens` and
/// `split_args_by_comma`: a byte state machine tracking paren depth, quote state,
/// comment state, and escapes, splitting only on a separator byte found at depth 0
/// outside all of them. `is_sep` selects the separator(s); `trim` selects the emit
/// policy via `push_segment` — `true` trims each segment and drops empties
/// (whitespace-value splitting), `false` keeps segments raw including empties
/// (comma-arg splitting).
///
/// **Escapes are stepped over whole** (`crate::escapes::escape_len`). A separator byte
/// *inside* an escape is not structure — it is the escaped character (`x\,y` is one
/// ident containing a comma, `xxxxx\ yyyyy` one ident containing a space) or a hex
/// escape's whitespace terminator (`\41 2px` is one ident). Splitting there tears a
/// token in half: the comma path rejoins with `", "`, inserting a space *inside* the
/// ident; the whitespace path lets the value wrap at that point, and `\` before a
/// newline is not a valid escape (§4.3.4) — so the output no longer re-parses.
fn split_top_level(content: &str, is_sep: impl Fn(u8) -> bool, trim: bool) -> Vec<&str> {
    let mut parts = Vec::new();
    let mut depth: u32 = 0;
    let mut start = 0;
    let mut in_quote = false;
    let mut quote_char = b'\0';
    let bytes = content.as_bytes();

    let mut i = 0;
    while i < content.len() {
        // A comment is opaque: step over it whole, so a separator inside `/* … */` is
        // comment text. `crate::comments::comment_end` is the single definition of how far
        // it reaches (CSS Syntax 3 §4.3.2), shared with the value scanners.
        if !in_quote && crate::comments::is_comment_start(bytes, i) {
            i = crate::comments::comment_end(bytes, i);
            continue;
        }

        // An escape is opaque: step over it whole, so neither a separator nor a
        // quote delimiter *inside* it can be read as structure. (A `\` in a comment
        // is literal, which is why this sits below the comment arms.)
        if bytes[i] == b'\\'
            && let Some(len) = crate::escapes::escape_len(content, i)
        {
            i += len;
            continue;
        }

        // Handle quotes
        if !in_quote && (bytes[i] == b'\'' || bytes[i] == b'"') {
            in_quote = true;
            quote_char = bytes[i];
            i += 1;
            continue;
        }
        if in_quote && bytes[i] == quote_char {
            in_quote = false;
            i += 1;
            continue;
        }
        if in_quote {
            i += 1;
            continue;
        }

        match bytes[i] {
            b'(' => depth += 1,
            b')' => depth = depth.saturating_sub(1),
            b if depth == 0 && is_sep(b) => {
                push_segment(&mut parts, &content[start..i], trim);
                start = i + 1;
            }
            _ => {}
        }
        i += 1;
    }

    // Don't forget the last part. A separator in *final* position produces no further
    // segment, matching CSS Syntax 3 §"parse a comma-separated list of component
    // values": the loop consumes up to the separator, discards it, and stops once the
    // input is empty — so `a,b,` is the two-entry list `[a, b]`, not a three-entry one.
    // A caller that must still *print* that final separator asks for it separately (see
    // `media_query_list`); it is a delimiter, not an entry.
    if start < content.len() {
        push_segment(&mut parts, &content[start..], trim);
    }

    parts
}

/// Emit one segment under the active policy: when `trim`, trim it and skip if empty;
/// otherwise push it verbatim (including empty segments).
///
/// The trailing trim spares an escape's payload (`crate::escapes::trim_end_preserving_escape`):
/// a segment can legitimately end in an escaped space, which is content, not padding.
fn push_segment<'a>(parts: &mut Vec<&'a str>, segment: &'a str, trim: bool) {
    if trim {
        let trimmed = crate::escapes::trim_css(segment);
        if !trimmed.is_empty() {
            parts.push(trimmed);
        }
    } else {
        parts.push(segment);
    }
}

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

    #[test]
    fn test_normalize_css_whitespace_fast_path_equivalence() {
        // Bare ASCII tokens (the fast path) are returned verbatim.
        for s in [
            "red", "1px", "#fff", "flex", "inherit", "-0.5em", "bold!", "a-b_c",
        ] {
            assert_eq!(
                normalize_css_whitespace(s),
                s,
                "bare token {s:?} must pass through"
            );
        }
        assert_eq!(normalize_css_whitespace(""), "");

        // Inputs the normalizer acts on still take the slow path and normalize.
        assert_eq!(normalize_css_whitespace("a  b"), "a b"); // whitespace collapse
        assert_eq!(normalize_css_whitespace("(  a  )"), "(a)"); // paren spacing
        assert_eq!(normalize_css_whitespace("a , b"), "a, b"); // comma spacing
        assert_eq!(normalize_css_whitespace("a,b"), "a, b");
        assert_eq!(normalize_css_whitespace(" red "), "red"); // trim
        assert_eq!(normalize_css_whitespace("a\tb"), "a b"); // tab is whitespace

        // U+000B (VT) is Unicode whitespace but *not* CSS whitespace, so it is
        // preserved as value content rather than collapsed to a space. Prettier
        // agrees (it keeps the VT inside the token), so this is a match, not a
        // divergence.
        assert_eq!(normalize_css_whitespace("u\u{000B}v"), "u\u{000B}v"); // VT preserved
        assert_eq!(
            normalize_css_whitespace("u\u{000B}\u{000B}v"),
            "u\u{000B}\u{000B}v"
        ); // not collapsed

        // Non-ASCII bails to the slow path (conservative), where it is preserved:
        // CSS whitespace is ASCII-only, so NBSP and other Unicode whitespace are
        // value content, not separators to collapse.
        assert_eq!(normalize_css_whitespace("a\u{00A0}b"), "a\u{00A0}b"); // NBSP preserved
        assert_eq!(
            normalize_css_whitespace("a\u{00A0}\u{00A0}b"),
            "a\u{00A0}\u{00A0}b"
        ); // not collapsed
        assert_eq!(normalize_css_whitespace("émotion"), "émotion"); // non-ws non-ASCII verbatim
    }

    /// A literal escape of a whitespace character spells that character (CSS Syntax 3
    /// §4.3.4, "anything else": *return the current input code point*), so its payload is
    /// content and neither delimiter strip may take it. Stranded, the backslash escapes the
    /// delimiter instead: `(1px\)` no longer parses at all, and `(a\, b)` is one argument
    /// where the author wrote two.
    #[test]
    fn a_whitespace_escapes_payload_survives_the_delimiter_strips() {
        assert_eq!(normalize_css_whitespace("(1px\\ )"), "(1px\\ )");
        assert_eq!(normalize_css_whitespace("(a\\ ,b)"), "(a\\ , b)");
        assert_eq!(normalize_css_whitespace("(a\\\t)"), "(a\\\t)"); // tab payload, same rule

        // Only the payload is protected, not the separator run after it: the space the
        // author wrote *outside* the escape is still the strip's business.
        assert_eq!(normalize_css_whitespace("( 1px\\  )"), "(1px\\ )");

        // A HEX escape's trailing whitespace is §4.3.7's optional terminator rather than a
        // payload — it carries nothing, and both formatters drop it — so it is deliberately
        // not protected. Same split `trim_end_preserving_escape` makes at a value's own end.
        assert_eq!(normalize_css_whitespace("(a\\41 )"), "(a\\41)");
        assert_eq!(normalize_css_whitespace("(a\\41 ,b)"), "(a\\41, b)");
    }

    /// An unquoted `url(...)` is one opaque `<url-token>` (§4.3.6): its content is
    /// verbatim — a comma inside it is content, not the separator the comma arm spaces —
    /// and only the padding inside its parens trims. A quoted `url("…")` is a function
    /// with a string argument and takes the ordinary arms.
    #[test]
    fn an_unquoted_url_token_is_opaque() {
        assert_eq!(normalize_css_whitespace("url(x,y)"), "url(x,y)");
        assert_eq!(normalize_css_whitespace("url(x ,y)"), "url(x ,y)");
        assert_eq!(normalize_css_whitespace("URL( x ,y )"), "URL(x ,y)");
        assert_eq!(normalize_css_whitespace("a  url( x,y )  b"), "a url(x,y) b");
        // Nested parens stay whole (`url_nested_reformat`), and an escaped `)` closes
        // nothing (§4.3.6 consumes an escape as url content).
        assert_eq!(normalize_css_whitespace("url(a(b,c))"), "url(a(b,c))");
        assert_eq!(normalize_css_whitespace("url(a\\),b)"), "url(a\\),b)");
        // A quoted argument is a function call, so its list normalizes.
        assert_eq!(normalize_css_whitespace("url( 'x' ,y )"), "url('x', y)");
        // `url` as the tail of a longer ident names a different function.
        assert_eq!(normalize_css_whitespace("fooURL(x,y)"), "fooURL(x, y)");
    }

    /// A `)` with no `(` open is a bare `<)-token>` between two values (§5.4.7): the
    /// separator before it collapses to one space rather than stripping. Only a group's
    /// own padding strips, and a stray `)` opens none — nor does it consume a later
    /// group's balance.
    #[test]
    fn a_stray_close_paren_keeps_its_separator() {
        assert_eq!(
            normalize_css_whitespace("screen  )  print"),
            "screen ) print"
        );
        assert_eq!(normalize_css_whitespace("screen )"), "screen )");
        assert_eq!(normalize_css_whitespace("( a ) ) ( b )"), "(a) ) (b)");
        assert_eq!(normalize_css_whitespace(")a"), ")a");
    }

    #[test]
    fn test_collapse_whitespace_runs() {
        // Every whitespace run — spaces, tabs, newlines, and mixed — collapses to a
        // single space. The newline-free multi-space case is the one a newline-only
        // collapse would leave verbatim, diverging from prettier.
        assert_eq!(collapse_whitespace_runs(".a  >  .  >  .b"), ".a > . > .b");
        assert_eq!(collapse_whitespace_runs(".a\t>\t.b"), ".a > .b");
        assert_eq!(collapse_whitespace_runs(".a >\n> .b"), ".a > > .b");
        assert_eq!(collapse_whitespace_runs(".a \t\n .b"), ".a .b");
        // A lone tab (run of length 1) still becomes a space.
        assert_eq!(collapse_whitespace_runs(".a\t.b"), ".a .b");

        // Already single-spaced: unchanged and borrowed (no allocation).
        let out = collapse_whitespace_runs(".a > .b");
        assert_eq!(out, ".a > .b");
        assert!(matches!(out, Cow::Borrowed(_)));
        assert!(matches!(collapse_whitespace_runs(".a.b"), Cow::Borrowed(_)));

        // String and comment interiors keep their whitespace verbatim.
        assert_eq!(
            collapse_whitespace_runs("[data-x='a  b']  .c"),
            "[data-x='a  b'] .c"
        );
        assert_eq!(
            collapse_whitespace_runs("[data-x=\"a\tb\"]"),
            "[data-x=\"a\tb\"]"
        );
        assert_eq!(
            collapse_whitespace_runs(".a  /* c  d */  .b"),
            ".a /* c  d */ .b"
        );
        // An escaped quote does not end the string early.
        assert_eq!(
            collapse_whitespace_runs(r"[x='a\'  b']  .c"),
            r"[x='a\'  b'] .c"
        );

        // Idempotent: the collapsed form is a fixed point.
        let once = collapse_whitespace_runs(".a  >\n>  .b").into_owned();
        assert_eq!(collapse_whitespace_runs(&once), once);
    }

    #[test]
    fn test_split_args_by_comma() {
        assert_eq!(split_args_by_comma("a, b, c"), vec!["a", " b", " c"]);
        assert_eq!(split_args_by_comma("--a, red"), vec!["--a", " red"]);
        // Nested parens preserved
        assert_eq!(
            split_args_by_comma("--a, calc(1, 2)"),
            vec!["--a", " calc(1, 2)"]
        );
        // Single arg
        assert_eq!(split_args_by_comma("--a"), vec!["--a"]);
        // Empty
        assert_eq!(split_args_by_comma(""), Vec::<&str>::new());
        // Empty segments are KEPT (raw policy, unlike the space splitter): a
        // doubled separator yields an empty segment, a leading separator yields
        // a leading empty, and a lone separator yields one empty segment.
        assert_eq!(split_args_by_comma("a,,b"), vec!["a", "", "b"]);
        assert_eq!(split_args_by_comma(",a"), vec!["", "a"]);
        assert_eq!(split_args_by_comma(","), vec![""]);
        // A trailing separator does NOT produce a trailing empty: CSS Syntax 3
        // §"parse a comma-separated list of component values" discards each separator
        // and stops when the input runs out, so `a,b,` is the two-entry list `[a, b]`
        // (the `start < content.len()` final-segment guard). Printing that final comma
        // is a separate question — see `media_query_list`.
        assert_eq!(split_args_by_comma("a,b,"), vec!["a", "b"]);
        assert_eq!(split_args_by_comma(",,"), vec!["", ""]);
        // Commas inside comments are NOT separators
        assert_eq!(
            split_args_by_comma("--a, /* with, comma */ red"),
            vec!["--a", " /* with, comma */ red"]
        );
        assert_eq!(
            split_args_by_comma("/* a, b */ value"),
            vec!["/* a, b */ value"]
        );
        // Commas inside quotes are NOT separators
        assert_eq!(
            split_args_by_comma(r#"--font, "Font, Name""#),
            vec!["--font", r#" "Font, Name""#]
        );
        assert_eq!(
            split_args_by_comma(r"'a, b', 'c, d'"),
            vec!["'a, b'", " 'c, d'"]
        );
    }

    #[test]
    fn test_split_by_space_preserving_parens() {
        assert_eq!(
            split_by_space_preserving_parens("var(--b) color-mix(in srgb, red, blue)"),
            vec!["var(--b)", "color-mix(in srgb, red, blue)"]
        );
        // Each segment is trimmed and empties are DROPPED (unlike the comma
        // splitter): consecutive/leading/trailing whitespace collapses away.
        assert_eq!(split_by_space_preserving_parens("a  b"), vec!["a", "b"]);
        assert_eq!(split_by_space_preserving_parens("  a  "), vec!["a"]);
        // Tabs are separators too.
        assert_eq!(split_by_space_preserving_parens("a\tb"), vec!["a", "b"]);
        // Single token, and all-whitespace yields nothing.
        assert_eq!(split_by_space_preserving_parens("solid"), vec!["solid"]);
        assert_eq!(split_by_space_preserving_parens("   "), Vec::<&str>::new());
        // Spaces inside parens are NOT separators.
        assert_eq!(
            split_by_space_preserving_parens("calc(1px + 2px) red"),
            vec!["calc(1px + 2px)", "red"]
        );
        // Spaces inside comments are NOT separators (comment stays one atom).
        assert_eq!(
            split_by_space_preserving_parens("a /* x y */ b"),
            vec!["a", "/* x y */", "b"]
        );
        // Spaces inside quotes are NOT separators.
        assert_eq!(
            split_by_space_preserving_parens(r"'Font Name' serif"),
            vec!["'Font Name'", "serif"]
        );
    }

    /// The host-word scan and the byte predicate answer every byte alike, save the documented
    /// conservative class (the control bytes the normalizer keeps); and over every window of a
    /// mixed document a `true` from the scan is exact.
    #[test]
    fn host_word_noop_scan_agrees_with_the_byte_predicate() {
        for b in 0..=255u8 {
            let by_predicate = is_normalize_noop_byte(b);
            let by_word = (0x21..0x80).contains(&b) && !NORMALIZE_STRUCTURAL.contains(&b);
            if b < 0x21 && by_predicate {
                // A control byte the normalizer keeps: the word refuses it (conservative).
                assert!(!by_word, "byte {b:#x}");
            } else {
                assert_eq!(by_predicate, by_word, "byte {b:#x}");
            }
        }
        let doc = "red (1px - 2px) a,b /*c*/ 'q' \u{a0}x\u{b}y\tz\u{feff}";
        let bytes = doc.as_bytes();
        for from in 0..=bytes.len() {
            for end in from..=bytes.len() {
                if !doc.is_char_boundary(from) || !doc.is_char_boundary(end) {
                    continue;
                }
                let noop = normalize_is_noop_in(doc, Span::new(from as u32, end as u32));
                let all = bytes[from..end].iter().all(|&b| is_normalize_noop_byte(b));
                assert!(!noop || all, "a true must be exact: [{from}, {end})");
                if noop != all {
                    assert!(
                        bytes[from..end]
                            .iter()
                            .any(|&b| b < 0x21 && is_normalize_noop_byte(b)),
                        "only a kept control byte may be refused: [{from}, {end})"
                    );
                }
            }
        }
    }
}