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
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
// ValueParser - Same-source recursive parser for CSS values
//
// Maintains ONE source string throughout entire parse tree to avoid position drift.
// Tracks (start, end) ranges within that source instead of creating substrings.
use crate::ast::CssValue;
use crate::parser::value::cursor::ValueCursor;
use crate::parser::value::lists::{ValueSeparator, classify_separators};
use crate::parser::value::scan::{
comment_run_end, is_comment_start, is_value_separator, quoted_run_end, value_skip_table,
};
use crate::whitespace::is_css_whitespace;
use bumpalo::Bump;
use bumpalo::collections::Vec as BumpVec;
use tsv_lang::Span;
/// [`ValueParser::fast_scan`]'s skip table. Beyond the structural set — which already
/// carries the comment introducer (`/`) — it must see both separators it classifies on,
/// [`is_value_separator`]'s comma and ASCII whitespace.
///
/// The exhaustiveness argument, which is what makes the skip byte-identical: a skipped
/// byte reaches no arm of the loop. It is not `/` (comment probe), not `\` (escape
/// probe), not a paren (nesting), not a quote (the string hop), not `,` and not
/// whitespace (the separators).
const FAST_SCAN_SKIP: [bool; 256] = value_skip_table!(|b| is_value_separator(b));
/// Skip table for the comma-list split's delimiter, `|c| c == ','`.
const COMMA_SKIP: [bool; 256] = value_skip_table!(|b| b == b',');
/// Skip table for the whitespace-list split's delimiter, [`is_css_whitespace`] — which
/// is `char::is_ascii_whitespace`, so the table is total over it. Note this deliberately
/// does **not** mirror `char::is_whitespace`, whose ASCII half also includes U+000B
/// (vertical tab).
const CSS_WS_SKIP: [bool; 256] = value_skip_table!(|b| b.is_ascii_whitespace());
/// Which separator a `split_top_level` walk splits on.
///
/// The two arms differ in more than the delimiter byte, and the differences are
/// *correlated* — a pair of bools could be set to a combination neither grammar has —
/// so they ride one tag that each is derived from.
#[derive(Clone, Copy, PartialEq, Eq)]
enum SplitKind {
/// Top-level commas. A comma is a **list separator**, so the (possibly empty)
/// stretch between two of them is an element; a comment is ordinary element
/// content.
Comma,
/// Top-level whitespace runs. Whitespace is a **gap**, so an empty stretch is
/// nothing at all; a comment run instead opens its own element (`comment_is_element`).
Whitespace,
}
/// Position-tracking parser for CSS values
///
/// Maintains ONE source string throughout entire parse tree.
/// Tracks (start, end) ranges within that source.
/// All spans calculated relative to SAME source → no position drift.
///
/// # Example
/// ```ignore
/// // Internal API - not publicly accessible
/// use tsv_css::parser::value::parser::ValueParser;
/// use tsv_lang::Span;
///
/// let source = "red 01%, blue 02%";
/// let span = Span { start: 100, end: 117 };
/// let parser = ValueParser::new(source, span);
/// let value = parser.parse();
/// // All nested values have accurate spans pointing to correct bytes in source
/// ```
#[derive(Debug)]
pub(crate) struct ValueParser<'a> {
/// Original value string (NEVER changes through recursion)
source: &'a str,
/// Parse range start in source (current level's slice start)
start: usize,
/// Parse range end in source (current level's slice end)
end: usize,
/// Base offset in full CSS document (for absolute span calculation)
base_offset: u32,
/// Whether a `:` can stand as a value operator in this range —
/// [`super::operators::split_value_run`]'s `colon_is_operator`: inside a group, and at
/// the top level of a **custom property's** value. ⚠️ Not a synonym for the
/// constructor's `in_group`, which also decides the head weld: a custom property's
/// value welds its head `+` (`--x: +a(2.5)`) exactly as a plain property's does.
colon_is_operator: bool,
/// Whether a `+` opening this range's first run welds onto the word after it —
/// [`super::operators::split_value_run`]'s `head_welds`. True for a declaration's
/// whole value; false inside a group (its `(` is a node to postcss, so a `+` there is
/// an operator) and for every comma element but the first. A whitespace element
/// straight after an operator member welds whatever this says.
head_welds: bool,
}
/// Outcome of `ValueParser::fast_scan` — the single-pass classification of a value.
enum FastScan<'arena> {
/// A top-level comma was found; the comma list is already split and parsed.
Comma(&'arena [CssValue<'arena>]),
/// No comma but a top-level whitespace run — a whitespace list.
Whitespace,
/// Neither a comma nor top-level whitespace — a single leaf value.
Leaf,
/// A `/* */` comment was found; take the comment-aware two-pass fallback.
Comment,
}
/// ASCII bytes that `str::trim` (via `char::is_whitespace`) strips: the CSS
/// whitespace set plus U+000B (vertical tab), which `char::is_whitespace`
/// includes and `is_css_whitespace` does not. A conservative superset of what the
/// boundary trims themselves strip (CSS whitespace only, via
/// [`crate::escapes::trim_start_css`] / [`crate::escapes::trim_end_preserving_escape`],
/// which exclude U+000B). Used only to check — from a value range's boundary bytes —
/// whether it is already trimmed, so the fast path can skip a redundant trim; any
/// non-ASCII boundary byte conservatively takes the trimming path instead (where a
/// non-ASCII space is preserved, not dropped).
const fn is_trim_boundary_ws(b: u8) -> bool {
matches!(b, b'\t' | b'\n' | 0x0B | 0x0C | b'\r' | b' ')
}
impl<'a> ValueParser<'a> {
/// Create parser from value string and its span in full CSS
///
/// # Arguments
/// * `source` - The value string to parse
/// * `base_span` - The span of this value in the full CSS document
///
/// # Example
/// ```ignore
/// let parser = ValueParser::new("red, blue", Span { start: 50, end: 59 });
/// ```
pub(crate) fn new(source: &'a str, base_span: Span) -> Self {
Self::new_within(source, base_span, false)
}
/// [`Self::new`], saying whether the range is inside a function's argument list or a
/// parenthesized group — set by [`super::functions::parse_function_arguments`], the `(`
/// being a node to postcss. It is the source of both per-range flags and is not kept:
/// a group's interior is where a `:` stands as an operator, and is where a head `+`
/// does NOT weld.
pub(crate) fn new_within(source: &'a str, base_span: Span, in_group: bool) -> Self {
Self {
source,
start: 0,
end: source.len(),
base_offset: base_span.start,
head_welds: !in_group,
colon_is_operator: in_group,
}
}
/// [`Self::new`] for a **custom property's** whole value, the other range where a `:`
/// is a value operator (see [`Self::colon_is_operator`]). Its head welds and its `(`
/// is no node, so it is a top-level value in every other respect.
pub(crate) fn new_custom_property(source: &'a str, base_span: Span) -> Self {
Self {
colon_is_operator: true,
..Self::new(source, base_span)
}
}
/// Get current parse text (zero-cost slice)
///
/// Returns a view into the source string for the current parse range.
/// No allocation - just returns a slice reference.
fn text(&self) -> &'a str {
&self.source[self.start..self.end]
}
/// Get absolute span for current range
///
/// Calculates the span in the full CSS document by adding base_offset
/// to the current range positions.
fn absolute_span(&self) -> Span {
Span {
start: self.base_offset + self.start as u32,
end: self.base_offset + self.end as u32,
}
}
/// Create sub-parser for a range (same source, different start/end)
///
/// This is the key to avoiding position drift: we create a new parser
/// that tracks a different range in the SAME source string, rather than
/// creating a substring.
///
/// # Arguments
/// * `range_start` - Start offset relative to current start
/// * `range_end` - End offset relative to current start
///
/// # Returns
/// New parser with same source but adjusted range
fn sub_parser(&self, range_start: usize, range_end: usize) -> ValueParser<'a> {
ValueParser {
source: self.source, // ✅ SAME source!
start: self.start + range_start, // Offset into same source
end: self.start + range_end,
base_offset: self.base_offset, // Same base offset
// Both per-range flags are inherited: a group's interior stays a group's
head_welds: self.head_welds,
colon_is_operator: self.colon_is_operator,
}
}
/// [`Self::sub_parser`] for one element of a list: the element's head welds a `+`
/// only when this range's own head does and nothing precedes it, or when the member
/// just before it is an operator (`1.5 / +a` welds across the authored gap).
fn element_parser(
&self,
range_start: usize,
range_end: usize,
values: &[CssValue<'_>],
) -> ValueParser<'a> {
let mut sub = self.sub_parser(range_start, range_end);
sub.head_welds = (values.is_empty() && self.head_welds)
|| values.last().is_some_and(|v| self.member_is_operator(v));
sub
}
/// Is this member an operator to postcss — the node kind its `operator()` reads as
/// "the last node is an operator" before welding a `+`?
///
/// A [`CssValue::Operator`] is one unless it is the `:` (a `colon` node there:
/// `f(a: +b)` → `f(a: + b)`). A whitespace-separated operator stands ALONE in its run,
/// and the splitter mints that one-token run as an operator member too
/// ([`super::operators::split_value_run`]), so the node kind answers here as well
/// (`a - +b` → `a - +b`, `a / +b` → `a / +b`) and nothing is read back off the bytes.
fn member_is_operator(&self, value: &CssValue<'_>) -> bool {
// Spans are absolute; the member is inside this range, so both offsets clear
// `base_offset` (a debug underflow here would be a span from another document).
let text = |span: Span| {
&self.source
[(span.start - self.base_offset) as usize..(span.end - self.base_offset) as usize]
};
match value {
CssValue::Operator { span } => text(*span) != ":",
_ => false,
}
}
/// Calculate trimmed end position for a text range
///
/// Removes trailing whitespace from a range and returns the adjusted end position —
/// except whitespace a CSS escape owns (`var(--a, 50px\ )`), which stays part of the
/// element (see `super::trim_end_preserving_escape`).
///
/// # Arguments
/// * `text` - The full text being parsed
/// * `start` - Start position in text
/// * `end` - End position in text (may include trailing whitespace)
///
/// # Returns
/// End position after removing trailing whitespace
fn trimmed_end(&self, text: &str, start: usize, end: usize) -> usize {
let slice = &text[start..end];
start + crate::escapes::trim_end_preserving_escape(slice).len()
}
/// Main parse entry point
///
/// Determines the value's structure and delegates. The parse range is trimmed
/// at every level (both `ValueParser::new` entry points pass a trimmed string,
/// and every `sub_parser` slice trims its bounds), so a value whose boundary
/// bytes are ASCII and non-whitespace takes a single fused pass (`fast_scan`):
/// a comma list is split and its elements built inline (no second `ValueCursor`
/// walk), a whitespace list or single leaf dispatches to the same builders as
/// before, and — because `text` is already trimmed — the leaf is built without
/// re-trimming. A value with a `/* */` comment falls back to the two-pass path:
/// the fused pass has no comment state of its own, while `classify_separators`
/// and the `ValueCursor` split both step over a comment whole, so the fallback is
/// the only path that reads a comment's interior as interior. A whitespace or
/// non-ASCII boundary byte (rare, since the range is trimmed) also takes the
/// two-pass path, which trims first —
/// CSS-whitespace-only (`trim_start_css` / `trim_end_preserving_escape`), so an
/// ASCII-whitespace boundary comes off (identical to what the fast path would
/// produce) while a non-ASCII space (NBSP, em space) is kept as leaf content,
/// never dropped.
pub(crate) fn parse<'arena>(&self, arena: &'arena Bump) -> CssValue<'arena> {
self.parse_classified(arena, None)
}
/// [`Self::parse`], with the value's separator class supplied instead of derived.
///
/// A declaration's value is walked for its **extent** before it is parsed at all — the
/// boundary scan in `crate::parser::decl_scan` runs the `;`/`}` terminator, the
/// `!important` roll-back and the comment flag out of one byte pass. That pass crosses
/// the same bytes [`Self::fast_scan`] does and tracks the same paren nesting, so it
/// records the two facts the classification turns on ([`is_value_separator`]) as it
/// goes, and the fused pass here is skipped for the value it already answered — the
/// third walk of one value's text becoming the second. `None` means the scan could not
/// say (it declined, the value holds a comment, or a url-token hid bytes from it), never
/// that there is no separator.
///
/// Only the two comma-free outcomes take the shortcut. A comma list still runs
/// `fast_scan`, whose comma arm splits the elements *inline* — knowing the class in
/// advance would save that walk only by handing the split back to the two-pass path the
/// fused one replaced, so the class is derived for all three and spent on two.
///
/// The other callers classify an element they have already split:
/// [`Self::split_top_level`], whose whitespace elements are separator-free by
/// construction, and [`Self::push_comma_segment`], which records its segment's class
/// during the fused pass. [`Self::unclassified_path_builds_leaf`] is the claim a
/// `Some(None)` from any of them is held to.
pub(crate) fn parse_classified<'arena>(
&self,
arena: &'arena Bump,
class: Option<ValueSeparator>,
) -> CssValue<'arena> {
self.parse_scanned(arena, class, false)
}
/// [`Self::parse_classified`] for the declaration scan, which states one more thing
/// about a value it classed a leaf: `group_closes_text`, that the text's first
/// parenthesized group closes on its last byte (`ValueFacts::group_closes_value`).
/// It rides to the function leaf's paren scan, which it answers
/// (`plain_function_parts`); `false` is "not known" and is what every other caller
/// passes. Read only beside a leaf class — the claim is about the whole text.
pub(crate) fn parse_scanned<'arena>(
&self,
arena: &'arena Bump,
class: Option<ValueSeparator>,
group_closes_text: bool,
) -> CssValue<'arena> {
let text = self.text();
let bytes = text.as_bytes();
// Fast path: confirm `text` is already trimmed straight from its boundary
// bytes (ASCII and non-whitespace ends ⇒ the ASCII trim is a no-op) and skip
// the redundant trim the two-pass path runs. An ASCII-whitespace boundary
// (`text` is trimmed in practice, so this is rare) or a non-ASCII boundary
// byte falls through to the trimming path below: an ASCII-whitespace end is
// trimmed there (same value), while a non-ASCII space (NBSP, em space) is
// kept — it is CSS value content, not whitespace.
if let (Some(&first), Some(&last)) = (bytes.first(), bytes.last())
&& first < 0x80
&& last < 0x80
&& !is_trim_boundary_ws(first)
&& !is_trim_boundary_ws(last)
{
// The caller's answer, where it has one. `debug_assert` grades it against the
// pass it replaces — the strongest oracle available, since it is the very
// path whose answer is being skipped, and every CSS fixture re-proves it.
// Neither arm recurses, so the check costs a debug build a walk or two of
// `text`.
match class {
Some(ValueSeparator::None) => {
debug_assert!(
self.unclassified_path_builds_leaf(text, arena),
"caller called {text:?} separator-free, the unclassified path disagreed"
);
return self.build_scanned_leaf(text, group_closes_text, arena);
}
Some(ValueSeparator::Whitespace) => {
debug_assert!(
matches!(self.fast_scan(text, arena), FastScan::Whitespace),
"caller called {text:?} whitespace-separated, fast_scan disagreed"
);
return self.parse_space_separated(arena);
}
Some(ValueSeparator::Comma) | None => {}
}
match self.fast_scan(text, arena) {
FastScan::Comma(values) => {
return CssValue::CommaSeparated {
values,
span: self.absolute_span(),
};
}
FastScan::Whitespace => return self.parse_space_separated(arena),
FastScan::Leaf => return self.build_leaf(text, arena),
// A `/* */` comment was found — defer to the comment-aware path.
FastScan::Comment => {}
}
}
// Fallback (comment present, a non-ASCII/whitespace boundary, or a
// non-trimmed range): trim, then classify and split, both comment-aware.
let trimmed = crate::escapes::trim_css(text);
if trimmed.is_empty() {
return CssValue::Identifier {
span: self.absolute_span(),
};
}
match classify_separators(trimmed) {
ValueSeparator::Comma => self.parse_comma_separated(arena),
ValueSeparator::Whitespace => self.parse_space_separated(arena),
ValueSeparator::None => self.parse_single(arena),
}
}
/// Would the unclassified path build `text` as one leaf? The claim a `Some(None)`
/// class makes to [`Self::parse_classified`], stated as the path it skips: `text` is
/// the range's own text, its boundary bytes ASCII and not whitespace.
///
/// `fast_scan` answers `Leaf`, or `Comment` — it bails on a comment ANYWHERE outside
/// quotes, a parenthesized one included, where the two-pass path then trims (a no-op
/// on those boundary bytes) and asks `classify_separators`, whose `None` reaches
/// `parse_single` and so `build_leaf` over the same text. A whitespace list's element
/// takes that second arm whenever a comment sits inside its parens
/// (`f(a /* c */ b)`), which the declaration's boundary scan never reports (it
/// withholds the class on any comment). The debug-assertion oracle for every caller.
fn unclassified_path_builds_leaf(&self, text: &str, arena: &Bump) -> bool {
match self.fast_scan(text, arena) {
FastScan::Leaf => true,
FastScan::Comment => {
crate::escapes::trim_css(text).len() == text.len()
&& classify_separators(text) == ValueSeparator::None
}
FastScan::Comma(_) | FastScan::Whitespace => false,
}
}
/// One fused pass over the (already-trimmed) value `text`, doing the work of
/// `classify_separators` and the comma arm of `split_top_level` at once:
///
/// - a top-level comma commits the value to a comma list, whose elements are
/// split and parsed inline here — skipping the second `ValueCursor` walk the
/// old comma path took;
/// - otherwise a top-level whitespace run makes it a whitespace list and a bare
/// run makes it a single leaf, both handled by the existing builders, so the
/// fast pass only reports which;
/// - a `/* */` comment outside quotes is reported so the caller takes the
/// comment-aware two-pass path (the comment-blind split here would diverge).
///
/// Paren nesting is tracked exactly as `classify_separators` and `ValueCursor`
/// track it, and a quoted string — which those two walk under a quote flag — is
/// hopped whole to the same end (`quoted_run_end`), so for a comment-free value the
/// comma split is byte-for-byte identical to the two-pass result.
fn fast_scan<'arena>(&self, text: &str, arena: &'arena Bump) -> FastScan<'arena> {
let bytes = text.as_bytes();
let mut in_parens: u32 = 0;
let mut ws_seen = false;
let mut values: BumpVec<'arena, CssValue<'arena>> = BumpVec::new_in(arena);
let mut seg_start = 0usize; // start of the current comma segment
// The segment's separator class, recorded in passing so its element need not
// re-derive it: `lead_end` is where a top-level whitespace byte would still
// extend the segment's leading run (which the element trims off), and
// `seg_ws` is the first top-level whitespace byte past that run — inside the
// element when it falls before the element's trimmed end, trailing otherwise.
let mut lead_end = 0usize;
let mut seg_ws = usize::MAX;
let mut any_comma = false;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
// Most of a value's bytes are inert content, which no arm below can act
// on (see `FAST_SCAN_SKIP`). One L1 load retires them ahead of the whole
// comment/escape/nesting/separator branch chain.
//
// ⛔ The table is the right rung HERE, and the word-at-a-time hop the lexers'
// token-body scans took (`tsv_lang::swar::next_byte_of`'s shape) was built two
// ways and refused on `instructions:u`, with a ±0.000% control: as a complete
// hop with its own scalar tail it costs **+1.05%** of the CSS wire run (+0.77%
// format), and hopping only while a whole word still fits in the value, handing
// the remainder back to this table, **+0.45%** (+0.33%). The inert runs are the
// wrong shape for it. Its class is seven needles plus a whitespace range — ~31
// instructions a word even vectorized, plus ~13 per hop — so it breaks even near
// thirteen inert bytes, while 78% of these runs are under eight (36% are empty)
// and a value averages under four words, so every value's last word is a tail.
// Half the BYTES do sit in runs of 13+ (mean 25), which bounds what any tuning
// could recover here at a few tenths of a percent.
if FAST_SCAN_SKIP[b as usize] {
i += 1;
continue;
}
// A block comment outside quotes: *this loop's* inline split is comment-blind
// (it has no comment state), so hand the whole value to the two-pass fallback,
// whose `classify_separators` + `ValueCursor` both step over a comment whole.
// Comment-bearing values are rare enough that teaching the fused pass to skip
// them would buy nothing the fallback doesn't already get right.
if is_comment_start(bytes, i) {
return FastScan::Comment;
}
// Delimiter tests use the nesting level as of *before* this byte, then
// the byte updates the nesting — the same order `ValueCursor` uses.
//
// An escape is OPAQUE: step over it whole (`crate::escapes::escape_len`), so
// nothing inside it reads as structure. Every byte it can hide is one this
// scanner would otherwise act on — an escaped paren (`url( a\)b )` would drop
// the depth to 0 and expose the interior whitespace as a false top-level
// separator, mis-splitting the opaque url-token); an escaped comma (`x\,y`)
// would split one ident in two, which the comma path then rejoins with `", "`,
// inserting a space *inside* the ident; an escaped space (`xxxxx\ yyyyy`) would
// become a wrap point, and `\` before a newline is not a valid escape (§4.3.4),
// so the output stops re-parsing. A hex escape's whitespace terminator is part
// of the escape too (`\41 2px` is ONE ident, §4.3.7).
//
// Kept identical in the twin `ValueCursor` / `classify_separators` trackers
// (the fused-pass invariant).
if b == b'\\'
&& let Some(len) = crate::escapes::escape_len(text, i)
{
i += len;
continue;
}
let top = in_parens == 0;
match b {
// A string is opaque, so it is hopped whole rather than walked under a
// quote flag: no arm of this loop can act inside one, and its interior
// is where a value's longest runs are (a data URI). The twin trackers
// keep the flag; `quoted_run_end` holds the two to one string model.
b'\'' | b'"' => {
i = quoted_run_end(text, i);
continue;
}
b'(' => in_parens += 1,
b')' => in_parens = in_parens.saturating_sub(1),
b',' if top => {
self.push_comma_segment(&mut values, text, seg_start, i, seg_ws, arena);
any_comma = true;
seg_start = i + 1;
lead_end = i + 1;
seg_ws = usize::MAX;
}
_ if top && b.is_ascii_whitespace() => {
ws_seen = true;
if i == lead_end {
lead_end = i + 1;
} else {
seg_ws = seg_ws.min(i);
}
}
_ => {}
}
i += 1;
}
if any_comma {
// Final segment runs to EOF (`ve_raw == text.len()`). Every comma-terminated
// segment pushed an element (an empty one the sentinel), so `values` is
// non-empty here and the leaf guard cannot arm.
self.push_comma_segment(&mut values, text, seg_start, bytes.len(), seg_ws, arena);
FastScan::Comma(values.into_bump_slice())
} else if ws_seen {
FastScan::Whitespace
} else {
FastScan::Leaf
}
}
/// Emit one comma-list element for the raw segment `text[seg_start..seg_end]`,
/// reproducing `split_top_level`'s per-element handling: skip leading and trim
/// trailing whitespace, emit an empty element as the empty-identifier sentinel,
/// and parse the first element that runs to EOF as a single leaf (the progress
/// guard). `seg_end` is the `ve_raw` position — a comma index, or `text.len()` for
/// the final segment. `seg_ws` is the segment's first top-level whitespace byte past
/// its leading run (`usize::MAX` for none), which classifies the element: the segment
/// holds no comment (`fast_scan` returns at the first comment, before the segment
/// holding it is pushed) and no top-level comma, so a top-level whitespace byte inside
/// the element makes it a whitespace list and none makes it a single leaf.
fn push_comma_segment<'arena>(
&self,
values: &mut BumpVec<'arena, CssValue<'arena>>,
text: &str,
seg_start: usize,
seg_end: usize,
seg_ws: usize,
arena: &'arena Bump,
) {
let seg = &text[seg_start..seg_end];
// Match `split_top_level`'s asymmetric trimming exactly: leading whitespace is
// skipped ASCII-only (like `ValueCursor::skip_whitespace`), trailing is trimmed
// CSS-whitespace-only and escape-aware (like `trimmed_end`'s
// `trim_end_preserving_escape`). A non-ASCII space (e.g. NBSP) on either end
// therefore stays part of the element — it is CSS value content, not a
// separator — rather than being dropped (which, for a string element, would
// delete the whole element).
let after_lead = seg.trim_start_matches(|c: char| c.is_ascii_whitespace());
let core = crate::escapes::trim_end_preserving_escape(after_lead);
let value_start = seg_start + (seg.len() - after_lead.len());
if core.is_empty() {
// An empty element is still an element — but only when a comma terminated
// it. CSS Syntax 3 §"parse a comma-separated list of component values"
// consumes up to the separator, discards it, and stops once the input is
// empty, so the stretch after a *final* comma produces no group at all
// (`a,` is the one-element list `[a]`). Everything else — a leading comma,
// two adjacent ones — is a real element that a `<media-query>` reads as
// `not all` and a declaration value reads as invalid; dropping it shortens
// the authored list, and in a declaration that turns a dead declaration
// (`linear-gradient(red,,blue)`, which the UA drops) into a live one.
if seg_end < text.len() {
values.push(CssValue::Identifier {
span: self.sub_parser(value_start, value_start).absolute_span(),
});
}
return;
}
let value_end = value_start + core.len();
let sub = self.element_parser(value_start, value_end, values);
// Same guard as `split_top_level`: a first non-empty element whose raw end
// reaches EOF is parsed as a single leaf (the classify/cursor disagreement
// safety, reachable only via leading delimiters — `fast_scan` never runs on a
// comment-bearing value).
let node = if values.is_empty() && seg_end == text.len() {
sub.parse_single(arena)
} else {
let class = if seg_ws < value_end {
ValueSeparator::Whitespace
} else {
ValueSeparator::None
};
sub.parse_classified(arena, Some(class))
};
values.push(node);
}
/// Parse comma-separated values: "a, b, c"
fn parse_comma_separated<'arena>(&self, arena: &'arena Bump) -> CssValue<'arena> {
CssValue::CommaSeparated {
values: self.split_top_level(arena, |c| c == ',', &COMMA_SKIP, SplitKind::Comma),
span: self.absolute_span(),
}
}
/// Parse space-separated values: "a b c"
fn parse_space_separated<'arena>(&self, arena: &'arena Bump) -> CssValue<'arena> {
CssValue::List {
values: self.split_top_level(
arena,
is_css_whitespace,
&CSS_WS_SKIP,
SplitKind::Whitespace,
),
span: self.absolute_span(),
}
}
/// Split the current range into top-level values at `is_delimiter`, parsing
/// each recursively.
///
/// Uses same-source recursion — every parsed value points to a range in the
/// SAME source string, avoiding position drift. Leading/trailing whitespace
/// around each element is trimmed and empty elements are dropped, so both
/// the comma form (`a, b, c`) and the whitespace form (`a b c`, where runs
/// collapse) fall out of the same loop. The delimiter is consumed after each
/// element; for whitespace the next iteration's `skip_whitespace` absorbs the
/// rest of the run.
///
/// `comment_is_element` — set for the whitespace split only — makes **a top-level
/// comment boundary an element boundary on both sides**, so a comment run is always
/// its own element even when nothing separates it from the tokens around it
/// (`0.3s/* c */z` is three elements). A comment is its own node — the unit prettier's
/// comma-list layout counts, see the printer's `value_node_count` — so this is the AST
/// agreeing with the model the printer already reads.
///
/// Two things depend on it. The declaration printer's `raws.between` hoist cuts
/// element 0 at exactly that member boundary, and a leading run left fused to the next
/// token gives it nothing to cut: dropping the fused member drops its content, keeping
/// it prints the comment twice. And each side of a glued comment is re-classified on
/// its own, so it reaches the leaf printer that normalizes it — a fused
/// `0.3s/* c */` parsed as one `Dimension` kept the glue (every other leaf kind fell
/// through to `Identifier`, whose whitespace normalizer inserts the space), and a fused
/// `"s"/* c */` lost its quote normalization.
///
/// The comma split does **not** take this rule — `/* c */ a, b` is two comma elements,
/// and the run belongs to the first.
fn split_top_level<'arena, F>(
&self,
arena: &'arena Bump,
is_delimiter: F,
skip: &[bool; 256],
kind: SplitKind,
) -> &'arena [CssValue<'arena>]
where
F: Fn(char) -> bool,
{
let comment_is_element = kind == SplitKind::Whitespace;
// A whitespace element holds no top-level separator by construction: the walk
// below cuts it at every top-level whitespace run and comment boundary, and a
// whitespace list holds no top-level comma. So it is parsed with that class
// named, and `parse_classified` skips re-deriving it with `fast_scan` — a second
// walk of the element's bytes that could only answer `Leaf` (or bail on a
// parenthesized comment toward the same leaf).
let element_class = comment_is_element.then_some(ValueSeparator::None);
let text = self.text();
let bytes = text.as_bytes();
let mut cursor = ValueCursor::new(text);
let mut values = BumpVec::new_in(arena);
loop {
let pos = cursor.skip_whitespace();
if cursor.is_eof() {
break;
}
// A comment run opening this element is emitted as its own element (see
// `comment_is_element` above). `Identifier` is the representation a
// whitespace-separated comment already had — the printer emits it verbatim
// from its span, so the interior is never re-tokenized.
if comment_is_element && let Some(run_end) = comment_run_end(bytes, pos) {
values.push(CssValue::Identifier {
span: self.sub_parser(pos, run_end).absolute_span(),
});
cursor.set_position(run_end);
continue;
}
let (value_start, value_end_raw) =
cursor.consume_until(&is_delimiter, skip, comment_is_element);
let value_end = self.trimmed_end(text, value_start, value_end_raw);
if value_end == value_start && kind == SplitKind::Comma {
// An empty element is still an element (the twin of
// `push_comma_segment`'s arm — see it for the spec basis). Only a
// comma-terminated stretch reaches here: the loop breaks on EOF above,
// so the nothing after a *final* comma never becomes one.
values.push(CssValue::Identifier {
span: self.sub_parser(value_start, value_start).absolute_span(),
});
} else if value_end > value_start {
// Non-empty value
let sub_parser = self.element_parser(value_start, value_end, &values);
// Progress guard: the cursor reached EOF without finding a
// delimiter (`value_end_raw == text.len()`) and this is the only
// element, so the whole range is a single value — `classify_separators`
// and the cursor disagreed (a value opening with a delimiter, e.g.
// `,a b`). Re-`parse()`ing the identical range would re-classify it the
// same way and recurse forever, so parse it as a leaf instead.
let parsed = if values.is_empty() && value_end_raw == text.len() {
sub_parser.parse_single(arena)
} else {
// Recursive, but same source!
sub_parser.parse_classified(arena, element_class)
};
// An operator run is FLATTENED into the list around it, because the
// separator rule reads across the element boundary: `1.5 /2.5` is a
// number, an operator and a number to prettier exactly as `1.5/2.5` is,
// and only the authored gap between them differs. A whitespace element
// holds no top-level whitespace or comma by construction, so the only
// thing that can come back as a `List` here is such a run
// (`super::parse_single_value`'s first arm).
match parsed {
CssValue::List { values: run, .. } if kind == SplitKind::Whitespace => {
values.extend_from_slice(run);
}
v => values.push(v),
}
}
cursor.set_position(value_end_raw);
// Consume the delimiter that stopped the scan (a comma, or the first
// char of a whitespace run); EOF leaves nothing to consume.
if let Some(delimiter) = cursor.peek()
&& is_delimiter(delimiter)
{
cursor.advance(delimiter);
}
}
values.into_bump_slice()
}
/// Build a leaf (single value) from already-trimmed `text`.
///
/// The fast path passes `self.text()` directly (the range is trimmed), skipping
/// the redundant `str::trim` that `parse_single` runs for the two-pass path.
/// Identifier text is recovered from `span` at print time, so the fallback
/// stores no copied string.
#[inline]
fn build_leaf<'arena>(&self, text: &'a str, arena: &'arena Bump) -> CssValue<'arena> {
self.build_scanned_leaf(text, false, arena)
}
/// [`Self::build_leaf`] carrying [`Self::parse_scanned`]'s `group_closes_text`.
///
/// The one out-of-line body every leaf is built in, with the leaf classifier
/// (`parse_single_value`) inlined into it: a function leaf's arguments re-enter the
/// value parser from here, so a second frame between the two is a frame on every
/// level of a nested value.
#[inline(never)]
fn build_scanned_leaf<'arena>(
&self,
text: &'a str,
group_closes_text: bool,
arena: &'arena Bump,
) -> CssValue<'arena> {
let span = self.absolute_span();
super::parse_single_value(
text,
span,
self.colon_is_operator,
self.head_welds,
group_closes_text,
arena,
)
}
/// Parse single value (leaf node), trimming first.
///
/// Used by the two-pass fallback, where the range may carry surrounding
/// whitespace; the fast path calls `build_leaf` directly. The trim is
/// CSS-whitespace-only and escape-aware (`trim_start_css` /
/// `trim_end_preserving_escape`): a non-ASCII space (NBSP, em space) at either end
/// is leaf content and stays, so the text handed to `build_leaf` still matches
/// `span` (trimming it would desync the two and drop a string element — see the
/// helper).
fn parse_single<'arena>(&self, arena: &'arena Bump) -> CssValue<'arena> {
let text = self.text();
self.build_leaf(crate::escapes::trim_css(text), arena)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The **fused-pass invariant**: the three top-level trackers — `fast_scan`,
/// `ValueCursor::consume_until`, and `classify_separators` — must agree on what is
/// structure and what is the interior of an escape / quote / comment / paren.
///
/// Each carries a comment asserting they are "kept identical"; nothing enforced it, and
/// they had already drifted once (all three skipped only `\(` / `\)`, so an escaped
/// comma or space read as a separator and tore an ident in half).
///
/// This is a **regression net, not a proof**: it pins the invariant the three now hold,
/// so the next edit to one of them fails here instead of silently in a corpus. It does
/// not demonstrate the original bug — that was fixed before this test existed.
#[test]
fn twin_trackers_agree_on_top_level_structure() {
// Each case pairs a shape with what the top level really contains, so a tracker
// that mis-reads an escape's interior disagrees with the other two.
let cases = [
// (input, has_top_level_comma, has_top_level_whitespace)
("red", false, false),
("red blue", false, true),
("red, blue", true, true),
("rgba(1, 2, 3)", false, false),
("calc(1px + 2px)", false, false),
// Escaped separators are CONTENT, not structure.
(r"x\,y", false, false),
(r"x\ y", false, false),
(r"a\+b", false, false),
(r"a\(b", false, false),
(r"a\)b", false, false),
// A hex escape swallows one whitespace terminator — still one token.
(r"\41 2px", false, false),
(r"\41 2px 3px", false, true),
// An escaped backslash does NOT escape what follows it (even run).
(r"a\\ b", false, true),
(r"a\\,b", true, false),
// Escapes next to real structure.
(r"x\,y, z", true, true),
(r"x\ y z", false, true),
// Quotes, comments and parens still shield their interiors.
("'a, b'", false, false),
("url(a b)", false, false),
// Inside a string `/*` is content, not a comment start — it must not swallow
// the closing quote and hide the comma after it.
(r#""/*", a"#, true, true),
// An escape at end-of-input has no payload to consume — it must not run past
// the end or swallow a separator that is not there.
(r"a\", false, false),
(r"a\ ", false, false),
// A `\r` terminator on a hex escape (CSS whitespace, unlike an escape payload).
("\\41\r2px", false, false),
("\\41\r2px 3px", false, true),
];
for (text, want_comma, want_ws) in cases {
let arena = Bump::new();
let span = Span {
start: 0,
end: text.len() as u32,
};
let parser = ValueParser::new(text, span);
// 1. `classify_separators` — the standalone classifier.
let classified = classify_separators(text);
let classify_comma = classified == ValueSeparator::Comma;
let classify_ws = classified == ValueSeparator::Whitespace;
assert_eq!(
classify_comma, want_comma,
"classify_separators comma verdict for {text:?}"
);
assert_eq!(
classify_ws,
want_ws && !want_comma,
"classify_separators whitespace verdict for {text:?}"
);
// 2. `fast_scan` — the fused single pass. Its verdict must match.
let fast_comma = matches!(parser.fast_scan(text, &arena), FastScan::Comma(_));
let fast_ws = matches!(parser.fast_scan(text, &arena), FastScan::Whitespace);
assert_eq!(
fast_comma, classify_comma,
"fast_scan disagrees with classify_separators on a comma in {text:?}"
);
assert_eq!(
fast_ws, classify_ws,
"fast_scan disagrees with classify_separators on whitespace in {text:?}"
);
// 3. `ValueCursor::consume_until` — the splitter's scanner. Stopping before
// EOF means it found a top-level delimiter.
let mut cursor = ValueCursor::new(text);
let (_, comma_end) = cursor.consume_until(|c| c == ',', &COMMA_SKIP, false);
assert_eq!(
comma_end < text.len(),
want_comma,
"ValueCursor disagrees on a top-level comma in {text:?}"
);
let mut cursor = ValueCursor::new(text);
let (_, ws_end) = cursor.consume_until(is_css_whitespace, &CSS_WS_SKIP, false);
assert_eq!(
ws_end < text.len(),
want_ws,
"ValueCursor disagrees on top-level whitespace in {text:?}"
);
}
// A comment-bearing value is parsed by `classify_separators` and `ValueCursor`
// together, so they must agree — this is the pairing that had drifted. Each is
// graded in the mode its production caller uses: the comma split takes
// `stop_at_comment: false` (a comment belongs to the element around it), the
// whitespace split takes `true` (there a comment is its own element).
//
// `want_sep` is therefore "does a top-level SEPARATOR exist", and a top-level
// comment is one — its boundary separates elements exactly as whitespace does, so
// `a/* c */b` and `a /* c */ b` are the same three nodes. That is what routes a
// value whose ONLY separator is a glued comment (`0.3s/* c */5px`) to the split
// instead of fusing it into one leaf.
let comment_cases = [
// (input, has_top_level_comma, has_top_level_separator)
// A separator inside `/* … */` is comment text — but the comment itself is a
// boundary, so these are separated values, just not COMMA-separated ones.
("a/* , */b", false, true),
("a/* */b", false, true),
("a /* , */ b", false, true),
("red /* x */, blue", true, true),
// A paren or quote inside a comment opens no nesting, so the real delimiter
// after it stays reachable.
("/* ( ' */ a, b", true, true),
("/* ) */ a b", false, true),
// An unterminated comment reaches end-of-input (§4.3.2) and swallows the rest,
// so nothing after it is structure — but its own opening is still a boundary.
("a /* , b", false, true),
("a/* , b", false, true),
];
for (text, want_comma, want_sep) in comment_cases {
let classified = classify_separators(text);
assert_eq!(
classified == ValueSeparator::Comma,
want_comma,
"classify_separators comma verdict for {text:?}"
);
assert_eq!(
classified == ValueSeparator::Whitespace,
want_sep && !want_comma,
"classify_separators separator verdict for {text:?}"
);
let mut cursor = ValueCursor::new(text);
let (_, comma_end) = cursor.consume_until(|c| c == ',', &COMMA_SKIP, false);
assert_eq!(
comma_end < text.len(),
want_comma,
"ValueCursor disagrees on a top-level comma in {text:?}"
);
let mut cursor = ValueCursor::new(text);
let (_, ws_end) = cursor.consume_until(is_css_whitespace, &CSS_WS_SKIP, true);
assert_eq!(
ws_end < text.len(),
want_sep,
"ValueCursor disagrees on a top-level separator in {text:?}"
);
// `fast_scan` has no comment state of its own — it recognizes the comment and
// hands the value to the two-pass path graded above. Pin the bail, since the
// fused pass's own verdict is never consulted for such a value.
let arena = Bump::new();
let span = Span {
start: 0,
end: text.len() as u32,
};
let parser = ValueParser::new(text, span);
assert!(
matches!(parser.fast_scan(text, &arena), FastScan::Comment),
"fast_scan must bail to the two-pass path for {text:?}"
);
}
}
#[test]
fn comment_is_its_own_list_member() {
// A top-level comment boundary is an element boundary on BOTH sides, so gluing
// never fuses a comment to a neighbour. Two things rest on it: the declaration
// printer's `raws.between` hoist cuts element 0 at that boundary (fused, it would
// either drop the token's content or print the comment twice), and each side is
// re-classified on its own so it reaches the leaf printer that normalizes it.
for (source, want) in [
("/* c */color 0.3s", vec!["/* c */", "color", "0.3s"]),
("/* c */ color 0.3s", vec!["/* c */", "color", "0.3s"]),
// Consecutive comments are one run (§4.3.2 loops), so they split as one member
// and the printer's whitespace normalizer spaces them on emit.
("/* c *//* d */color", vec!["/* c *//* d */", "color"]),
("/* c */ /* d */ color", vec!["/* c */", "/* d */", "color"]),
// A run mid-list splits the same way, at every element start.
("a /* c */b", vec!["a", "/* c */", "b"]),
// Glued to the token BEFORE it, a comment still ends that element. Fused, the
// `0.3s/* c */` case below parsed as one `Dimension` and kept the glue —
// `build_dimension_doc` emits from its span and never runs the whitespace
// normalizer, unlike the `Identifier` every other leaf kind falls through to.
("a/* c */ b", vec!["a", "/* c */", "b"]),
("a 0.3s/* c */", vec!["a", "0.3s", "/* c */"]),
("a 0.3s/* c */z", vec!["a", "0.3s", "/* c */", "z"]),
// Inside parens the comment is interior text: the function is one element and
// its argument list is never split here.
("a f(x/* c */y)", vec!["a", "f(x/* c */y)"]),
] {
let arena = Bump::new();
let span = Span {
start: 0,
end: source.len() as u32,
};
let value = ValueParser::new(source, span).parse(&arena);
let CssValue::List { values, .. } = value else {
panic!("expected a space-separated list for {source:?}, got {value:?}");
};
let got: Vec<&str> = values.iter().map(|v| v.span().extract(source)).collect();
assert_eq!(got, want, "member split for {source:?}");
}
// A value of nothing but comments is a one-member list holding the whole run —
// consecutive comments fold (§4.3.2 loops), and the member's own leaf printer
// spaces their interior, so folding costs nothing at emit.
for source in ["/* c */", "/* c *//* d */"] {
let arena = Bump::new();
let span = Span {
start: 0,
end: source.len() as u32,
};
let value = ValueParser::new(source, span).parse(&arena);
let CssValue::List { values, .. } = value else {
panic!("expected a list for the comment-only {source:?}, got {value:?}");
};
assert_eq!(values.len(), 1);
assert_eq!(values[0].span().extract(source), source);
}
}
#[test]
fn test_new_parser() {
let source = "red, blue";
let span = Span {
start: 100,
end: 109,
};
let parser = ValueParser::new(source, span);
assert_eq!(parser.source, "red, blue");
assert_eq!(parser.start, 0);
assert_eq!(parser.end, 9);
assert_eq!(parser.base_offset, 100);
}
#[test]
fn test_text() {
let source = "red, blue";
let span = Span {
start: 100,
end: 109,
};
let parser = ValueParser::new(source, span);
assert_eq!(parser.text(), "red, blue");
}
#[test]
fn test_text_with_range() {
let source = "red, blue";
let parser = ValueParser {
source,
start: 5,
end: 9,
base_offset: 100,
head_welds: true,
colon_is_operator: false,
};
assert_eq!(parser.text(), "blue");
}
#[test]
fn test_absolute_span() {
let source = "red, blue";
let span = Span {
start: 100,
end: 109,
};
let parser = ValueParser::new(source, span);
let abs_span = parser.absolute_span();
assert_eq!(abs_span.start, 100);
assert_eq!(abs_span.end, 109);
}
#[test]
fn test_absolute_span_with_range() {
let source = "red, blue";
let parser = ValueParser {
source,
start: 5, // "blue" starts at byte 5
end: 9, // "blue" ends at byte 9
base_offset: 100,
head_welds: true,
colon_is_operator: false,
};
let abs_span = parser.absolute_span();
assert_eq!(abs_span.start, 105); // 100 + 5
assert_eq!(abs_span.end, 109); // 100 + 9
}
#[test]
fn test_sub_parser() {
let source = "red, blue, green";
let span = Span {
start: 100,
end: 116,
};
let parser = ValueParser::new(source, span);
// Create sub-parser for "blue" (bytes 5-9 in source)
let sub = parser.sub_parser(5, 9);
assert_eq!(sub.source, "red, blue, green"); // Same source
assert_eq!(sub.start, 5);
assert_eq!(sub.end, 9);
assert_eq!(sub.base_offset, 100); // Same base
assert_eq!(sub.text(), "blue");
let sub_span = sub.absolute_span();
assert_eq!(sub_span.start, 105); // 100 + 5
assert_eq!(sub_span.end, 109); // 100 + 9
}
#[test]
fn test_nested_sub_parsers() {
let source = "a, b, c";
let span = Span { start: 0, end: 7 };
let parser = ValueParser::new(source, span);
// First level: "b, c" (bytes 3-7)
let sub1 = parser.sub_parser(3, 7);
assert_eq!(sub1.text(), "b, c");
assert_eq!(sub1.absolute_span().start, 3);
assert_eq!(sub1.absolute_span().end, 7);
// Second level: "c" (bytes 6-7, but relative to sub1's start)
// In sub1's coordinates: "c" is at position 3 (because sub1.start = 3)
// So we want bytes 6-7 in original, which is 6-3=3 to 7-3=4 relative to sub1
let sub2 = sub1.sub_parser(3, 4);
assert_eq!(sub2.source, "a, b, c"); // Still same source!
assert_eq!(sub2.text(), "c");
assert_eq!(sub2.absolute_span().start, 6); // 0 + 3 + 3
assert_eq!(sub2.absolute_span().end, 7); // 0 + 3 + 4
}
#[test]
fn test_multiline_source() {
let source = "val1,\n val2";
let span = Span {
start: 100,
end: 114,
};
let parser = ValueParser::new(source, span);
assert_eq!(parser.text(), "val1,\n val2");
assert_eq!(parser.source, "val1,\n val2"); // Preserves whitespace
}
#[test]
fn test_empty_range() {
let source = "test";
let parser = ValueParser {
source,
start: 2,
end: 2, // Empty range
base_offset: 0,
head_welds: true,
colon_is_operator: false,
};
assert_eq!(parser.text(), "");
let abs_span = parser.absolute_span();
assert_eq!(abs_span.start, 2);
assert_eq!(abs_span.end, 2);
}
// Tests: Parsing Methods
#[test]
fn test_parse_single_identifier() {
let source = "auto";
let span = Span { start: 0, end: 4 };
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::Identifier { .. }));
if let CssValue::Identifier { span } = value {
assert_eq!(span.extract(source), "auto");
}
}
#[test]
fn test_parse_comma_separated_simple() {
let source = "red, blue, green";
let span = Span {
start: 100,
end: 116,
};
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::CommaSeparated { .. }));
if let CssValue::CommaSeparated { values, .. } = value {
assert_eq!(values.len(), 3);
}
}
#[test]
fn test_parse_space_separated_simple() {
let source = "10px 20px 30px";
let span = Span { start: 0, end: 14 };
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::List { .. }));
if let CssValue::List { values, .. } = value {
assert_eq!(values.len(), 3);
}
}
#[test]
fn test_parse_multiline_comma_separated() {
// Simulate box-shadow with multiline formatting
let source = "0 1px rgba(0, 0, 0, 0.1),\n 0 2px rgba(0, 0, 0, 0.2)";
let span = Span {
start: 100,
end: 154,
};
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::CommaSeparated { .. }));
if let CssValue::CommaSeparated { values, .. } = value {
assert_eq!(values.len(), 2);
// Verify both values parsed correctly
assert!(matches!(values[0], CssValue::List { .. }));
assert!(matches!(values[1], CssValue::List { .. }));
}
}
#[test]
fn test_parse_nested_function_in_list() {
// box-shadow value with rgba function
let source = "0 2px 4px rgba(0, 0, 0, 0.1)";
let span = Span { start: 0, end: 28 };
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::List { .. }));
if let CssValue::List { values, .. } = value {
assert_eq!(values.len(), 4); // "0", "2px", "4px", "rgba(...)"
// Last value should be a color (rgba is recognized as color function)
assert!(matches!(values[3], CssValue::Color { .. }));
}
}
#[test]
fn test_parse_preserves_leading_zeros_in_function() {
// This is the key test - leading zeros should be preserved
let source = "linear-gradient(90deg, red 01%, blue 02%)";
let span = Span { start: 0, end: 42 };
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::Function { .. }));
if let CssValue::Function {
name_span, args, ..
} = value
{
assert_eq!(name_span.extract(source), "linear-gradient");
// Arguments should have accurate spans pointing to original source
assert_eq!(args.len(), 3); // "90deg", "red 01%", "blue 02%"
}
}
#[test]
fn test_parse_handles_empty_values() {
// Edge case: double comma (empty value between). CSS Syntax 3 §"parse a
// comma-separated list of component values" produces a group for the stretch
// between the two commas, so the list has THREE elements, the middle one the
// zero-width empty-identifier sentinel. Dropping it would shorten the authored
// list — and in a declaration that turns an invalid (dropped) declaration into a
// valid one, a render change.
let source = "a,,c";
let span = Span { start: 0, end: 4 };
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::CommaSeparated { .. }));
if let CssValue::CommaSeparated { values, .. } = value {
assert_eq!(values.len(), 3);
assert!(
matches!(values[1], CssValue::Identifier { span } if span.start == span.end),
"the middle element is the empty sentinel"
);
}
// A *closing* comma produces no further group (the algorithm stops once the
// input is empty), so `a,` is the one-element list `[a]`.
let trailing = ValueParser::new("a,", Span { start: 0, end: 2 }).parse(&arena);
let CssValue::CommaSeparated { values, .. } = trailing else {
panic!("expected a comma list");
};
assert_eq!(values.len(), 1);
}
#[test]
fn test_parse_handles_trailing_whitespace() {
let source = "red , blue ";
let span = Span { start: 0, end: 14 };
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::CommaSeparated { .. }));
if let CssValue::CommaSeparated { values, .. } = value {
assert_eq!(values.len(), 2);
// Spans should exclude trailing whitespace
}
}
#[test]
fn test_parse_single_value_with_whitespace() {
let source = " auto ";
let span = Span { start: 0, end: 8 };
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::Identifier { .. }));
if let CssValue::Identifier { span } = value {
assert_eq!(span.extract(source).trim(), "auto");
}
}
#[test]
fn test_trimmed_end_helper() {
let source = "red , blue";
let span = Span { start: 0, end: 12 };
let parser = ValueParser::new(source, span);
// Test trimming "red " (positions 0-5, trimmed should be 0-3)
let trimmed = parser.trimmed_end(source, 0, 5);
assert_eq!(trimmed, 3);
// Test trimming " blue" (no trailing space)
let trimmed = parser.trimmed_end(source, 8, 12);
assert_eq!(trimmed, 12);
}
#[test]
fn test_nested_sub_parsers_with_parse() {
// Test same-source recursion through actual parsing
let source = "a, b, c";
let span = Span { start: 0, end: 7 };
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::CommaSeparated { .. }));
if let CssValue::CommaSeparated { values, .. } = value {
assert_eq!(values.len(), 3);
// Each value should have correct span
for (i, val) in values.iter().enumerate() {
let val_span = val.span();
// Verify spans are in correct order and non-overlapping
if i > 0 {
let prev_span = values[i - 1].span();
assert!(val_span.start > prev_span.end);
}
}
}
}
#[test]
fn test_multibyte_non_ascii_value_is_single_leaf() {
// U+4E20 encodes a 0xA0 byte that a per-byte `as char` cast would alias
// to NBSP, classifying the lone token as a whitespace list and recursing
// on the identical range forever. It must parse as a single leaf.
let source = "丠";
let span = Span {
start: 0,
end: source.len() as u32,
};
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::Identifier { .. }));
}
#[test]
fn test_comment_unbalanced_paren_does_not_open_nesting() {
// A `(` inside a comment is comment text. Stepping over the comment whole
// (`scan::comment_end`) leaves the paren depth at 0, so the real top-level comma
// still splits and `classify_separators` and the cursor agree.
//
// While the cursor was comment-blind this `(` opened a paren it never closed, the
// comma became unreachable, and the progress guard collapsed the whole range to a
// single leaf.
let source = "/* ( */ a, b";
let span = Span {
start: 0,
end: source.len() as u32,
};
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
let CssValue::CommaSeparated { values, .. } = value else {
panic!("expected a comma list, got {value:?}");
};
assert_eq!(values.len(), 2);
// Element 0 is `/* ( */ a` — the comment is ONE member, so its interior is
// never re-tokenized.
let CssValue::List {
values: members, ..
} = values[0]
else {
panic!(
"expected a space-separated first element, got {:?}",
values[0]
);
};
assert_eq!(members.len(), 2);
assert_eq!(members[0].span().extract(source), "/* ( */");
assert!(matches!(values[1], CssValue::Identifier { .. }));
}
#[test]
fn whitespace_element_with_a_parenthesized_comment_is_one_leaf() {
// A whitespace list's element reaches `parse_classified` already classified
// separator-free, while `fast_scan` bails on this element's comment though it
// sits inside the parens — the case `unclassified_path_builds_leaf` grades (its
// `debug_assert` runs here). The element stays one member, its comment inside it.
let source = "f(a /* c */ b) x";
let span = Span {
start: 0,
end: source.len() as u32,
};
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
let CssValue::List { values, .. } = value else {
panic!("expected a space-separated list, got {value:?}");
};
assert_eq!(values.len(), 2);
assert_eq!(values[0].span().extract(source), "f(a /* c */ b)");
assert!(parser.unclassified_path_builds_leaf("f(a /* c */ b)", &arena));
assert_eq!(values[1].span().extract(source), "x");
}
#[test]
fn test_comment_interior_comma_is_not_a_separator() {
// The corruption this opacity exists to prevent: the comma inside the comment
// would split the element in two, and the halves would come back re-tokenized —
// interior whitespace collapsed, the body broken at its own comma, `#FFF` and
// `.5px` normalized *inside* the comment.
let source = "color 0.3s /* keep this, #FFF .5px */, background 0.3s";
let span = Span {
start: 0,
end: source.len() as u32,
};
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
let CssValue::CommaSeparated { values, .. } = value else {
panic!("expected a comma list, got {value:?}");
};
assert_eq!(values.len(), 2);
assert_eq!(
values[0].span().extract(source),
"color 0.3s /* keep this, #FFF .5px */"
);
assert_eq!(values[1].span().extract(source), "background 0.3s");
}
#[test]
fn test_complex_multiline_box_shadow() {
// Real-world test case from fixture
let source = "0 2px 4px rgba(0, 0, 0, 0.1),\n 0 4px 8px rgba(0, 0, 0, 0.2)";
let span = Span { start: 0, end: 63 };
let parser = ValueParser::new(source, span);
let arena = Bump::new();
let value = parser.parse(&arena);
assert!(matches!(value, CssValue::CommaSeparated { .. }));
if let CssValue::CommaSeparated { values, .. } = value {
assert_eq!(values.len(), 2);
// First value: "0 2px 4px rgba(0, 0, 0, 0.1)"
assert!(matches!(values[0], CssValue::List { .. }));
if let CssValue::List { values: parts, .. } = &values[0] {
assert_eq!(parts.len(), 4);
// Last part should be a color (rgba recognized as color)
assert!(matches!(parts[3], CssValue::Color { .. }));
}
// Second value: "0 4px 8px rgba(0, 0, 0, 0.2)"
assert!(matches!(values[1], CssValue::List { .. }));
}
}
}