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
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
//! CSS declaration printing and wrapping logic
//!
//! Handles:
//! - Declaration printing (property: value;)
//! - Multiline wrapping decisions
//! - Width-based wrapping for long lists
//! - Doc building for width calculations

use super::values::ValueCtx;
use super::{Printer, value_normalization};
use crate::ast::{self, CssValue};
use tsv_lang::Span;
use tsv_lang::doc::{DocBuf, DocContext, arena::DocId};
use tsv_lang::printing::{format_string_literal, visual_width};
use tsv_lang::{PRINT_WIDTH, TAB_WIDTH};

/// How a declaration's broken comma list lays out, decided once.
///
/// Its existence *is* the break decision: `multiline_plan` returns `Some` exactly when
/// `comma_list_should_break` does. The routing question and the answers the printing needs
/// are one computation, so they cannot disagree — a split between them would reintroduce
/// the glued-vs-spaced 2-cycle — and a comment-bearing declaration lexes its value once
/// rather than at both sites.
struct MultilinePlan<'v> {
    /// The leading comment run to emit on the colon's line — postcss `raws.between`
    /// material (`leading_value_comment_run`).
    hoisted: Option<Span>,
    /// Element 0's own content members once the hoisted run is cut away. `None` when
    /// nothing was hoisted, and then element 0 prints whole.
    first_members: Option<&'v [CssValue<'v>]>,
}

/// How a grid property's multi-row value lays out, decided once — the grid twin of
/// `MultilinePlan`, and like it, its existence *is* the layout decision:
/// `grid_multirow_plan` returns `Some` exactly when the value takes one row per line.
struct GridMultirowPlan<'v> {
    /// The leading comment run to emit on the colon's line — postcss `raws.between`
    /// material (`leading_value_comment_run`), outside the row decision entirely.
    hoisted: Option<Span>,
    /// The members beneath it, at least two: every row string, and every comment run
    /// between them, in source order. Which member opens a row is `grid_row_opens_at`'s
    /// question — the source line read against the value rule's glue — asked again at
    /// emit time.
    members: &'v [CssValue<'v>],
}

/// Is this comma-list element the **empty** one — the nothing between two top-level
/// commas (`transition: a,,b`, `,a`)?
///
/// The value parser emits it as the zero-width empty-identifier sentinel (see
/// `CssValue::Identifier`), which prints as the nothing it is. It is a real list entry —
/// CSS Syntax 3's comma-list algorithm produces a group for it — so it is never dropped;
/// what it *does* need is a layout rule of its own, since every other break trigger is
/// phrased in terms of an element holding more than one node and this one holds none.
pub(super) fn is_empty_element(value: &CssValue<'_>) -> bool {
    matches!(value, CssValue::Identifier { span } if span.start >= span.end)
}

/// Peel the leading comment run off a value's space-split `words` — the `/* … */` items
/// ahead of its first content word — for the colon's line, where postcss holds it
/// (`raws.between`, the same reading `print_decl_multiline` hoists by).
///
/// Peels only when content remains: an all-comment slice stays whole, since hoisting it
/// would leave nothing beneath the colon (a comma list would strand its bare `,`; a grid
/// value's comments are its rows). Returns the peeled run, empty when nothing was.
fn peel_leading_comment_run<'s>(words: &mut Vec<&'s str>) -> Vec<&'s str> {
    let run_len = words
        .iter()
        .take_while(|word| word.starts_with("/*"))
        .count();
    if run_len < words.len() {
        words.drain(..run_len).collect()
    } else {
        Vec::new()
    }
}

/// Did the author write a **closing** comma — a separator with nothing after it — at the
/// end of this comma list?
///
/// `values` are the list's elements and `list_end` the byte offset the list runs to (a
/// declaration value's span end, a function's `)`), so the answer is whatever text sits
/// between the last element and that bound: a comma there terminated no element, and one
/// must be written back. Every case falls out of the one question:
///
/// - `a,` → elements `[a]`, tail `,` — the source's last comma produced no element (CSS
///   Syntax 3 §"parse a comma-separated list of component values" stops once the input is
///   empty), so joining the elements would drop it.
/// - `a,,` → elements `[a, ""]`, tail `,` — the trailing **empty** element is real, and
///   joining N elements writes only N-1 commas, so its spelling still needs one more.
/// - `a, b` → tail empty. `a, b\,` → tail empty too: an escaped comma is content, so it
///   lives *inside* the last element's span and closes nothing.
///
/// Asking the source rather than the elements is what makes those one rule instead of
/// two. Reading it off the elements can only see the empty-element case (`values.last()`
/// empty), which left the plain trailing comma deleted — the parse is unchanged, but that
/// is not the question a declaration asks. css-values-4 §"Component value combinators"
/// requires a comma to be omitted when "all items following the comma have been omitted",
/// so `transition: a,` is invalid where `transition: a` is valid and the deletion turns a
/// dead declaration live; a **custom property** has no grammar to invalidate at all, and
/// its verbatim token sequence (css-variables-1) simply loses a token. Prettier deletes it
/// in every position — see `css/values/lists/comma_closing_prettier_divergence`.
///
/// The `<media-query-list>` is the one construct where the deletion *is* correct, because
/// mediaqueries-4 §Syntax delegates to the split itself; that path has its own reader
/// (`atrules::media_query_list`) and never comes here.
pub(super) fn list_has_closing_comma(
    source: &str,
    values: &[CssValue<'_>],
    list_end: usize,
) -> bool {
    let Some(last) = values.last() else {
        return false;
    };
    source
        .get(last.span().end_usize()..list_end)
        .is_some_and(|tail| crate::escapes::trim_end_preserving_escape(tail).ends_with(','))
}

/// Does this property's value take prettier's `font`-shorthand gap rule?
///
/// `getPropOfDeclNode` reaches for the enclosing declaration's property from inside the
/// value walk; tsv sets it on the printer for the declaration's duration instead
/// (`Printer::value_scope`).
fn property_is_font_shorthand(property: &str) -> bool {
    property == "font" || property.starts_with("--")
}

/// Walk an important declaration's tail — from the value's end to its `;`/`}` — and
/// hand `emit` each piece of its printed form, in order: ` !important` at the bang (the
/// keyword itself skipped, so any spelling or casing prints one way), and a space plus
/// the comment's verbatim text for each comment, wherever it sits relative to the bang.
///
/// The one spelling of the tail, shared by the width measure
/// (`Printer::declaration_tail`) and the emit (`Printer::write_important_tail`).
fn for_each_important_tail_piece(
    source: &str,
    decl: &ast::CssDeclaration<'_>,
    mut emit: impl FnMut(&str),
) {
    let bytes = source.as_bytes();
    let mut i = decl.span.end_usize();
    while i < bytes.len() {
        match bytes[i] {
            b';' | b'}' => break,
            b'/' if crate::comments::is_comment_start(bytes, i) => {
                let end = crate::comments::comment_end(bytes, i);
                emit(" ");
                emit(&source[i..end]);
                i = end;
            }
            b'!' => {
                emit(" !important");
                i += 1;
            }
            c if c.is_ascii_alphabetic() => {
                // the `important` keyword itself — already emitted at the `!`
                while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
                    i += 1;
                }
            }
            _ => i += 1,
        }
    }
}

impl<'a> Printer<'a> {
    /// Build the declaration's trailing text after the value: the `!important`
    /// keyword (normalized to ` !important`) plus any comments trailing it, or
    /// empty when the declaration isn't important.
    ///
    /// The function paths' inline-vs-wrap width decisions measure this string, and
    /// the emit (`write_declaration_end`) writes the same pieces
    /// (`for_each_important_tail_piece`) — so the wrap check counts exactly the bytes
    /// the emit appends. A value carrying `!important` therefore reserves the tail and
    /// wraps when it would overrun the print width, matching prettier.
    fn declaration_tail(&self, decl: &ast::CssDeclaration<'_>) -> String {
        let mut out = String::new();
        if decl.is_important() {
            for_each_important_tail_piece(self.source, decl, |piece| out.push_str(piece));
        }
        out
    }

    /// Write the declaration ending: the optional `!important` tail and the semicolon
    /// with newline.
    ///
    /// The value span ends before the `!important` region, so that region — and any
    /// comments around it (`blue /* a */ !important /* b */;`) — is invisible to the
    /// value printers. It is re-emitted from source here with comments preserved in
    /// place (like prettier) and `!`/`important` normalized to a single ` !important`.
    ///
    /// Almost every declaration ends here and almost none is important, so the common case is
    /// the flag test and the `;\n` write inline; the tail is written piece by piece into
    /// the output from a cold body, with no intermediate `String`.
    #[inline]
    fn write_declaration_end(&mut self, decl: &ast::CssDeclaration<'_>) {
        if decl.is_important() {
            self.write_important_tail(decl);
        }
        self.write(";\n");
    }

    /// [`Self::write_declaration_end`]'s `!important` arm.
    #[cold]
    #[inline(never)]
    fn write_important_tail(&mut self, decl: &ast::CssDeclaration<'_>) {
        let source = self.source;
        for_each_important_tail_piece(source, decl, |piece| self.write(piece));
    }

    /// The `!important` tail of an empty value, written straight after the `: ` separator:
    /// [`Self::write_important_tail`] less the space its first piece leads with, which the
    /// separator already supplies.
    #[cold]
    #[inline(never)]
    fn write_important_tail_after_separator(&mut self, decl: &ast::CssDeclaration<'_>) {
        let source = self.source;
        let mut first = true;
        for_each_important_tail_piece(source, decl, |piece| {
            let piece = if first {
                piece.trim_start_matches(' ')
            } else {
                piece
            };
            first = false;
            self.write(piece);
        });
    }

    /// Emit a format-ignored declaration verbatim from source, through its last token — the
    /// value's, or the `!important` tail's (the keyword or a comment after it), which the
    /// span stops short of — so the tail is the author's bytes too (`a:!important/* c */`),
    /// and only the `;` is regenerated. Shared by the rule and at-rule block loops.
    pub(super) fn write_format_ignore_declaration(&mut self, decl: &ast::CssDeclaration<'_>) {
        let end = match decl.important_end {
            Some(important_end) => {
                // Past the tail's trivia to the terminator, then back over its whitespace.
                let from = important_end as usize;
                let terminator = crate::comments::skip_trivia_forward(
                    self.source.as_bytes(),
                    from,
                    self.source.len(),
                );
                let tail = self.source[from..terminator].trim_end_matches(|c: char| {
                    c.is_ascii() && crate::lexer::is_ascii_css_whitespace(c as u8)
                });
                important_end + tail.len() as u32
            }
            None => decl.span.end,
        };
        self.write_indent();
        self.write_verbatim_span(self.frozen_slice_span(Span::new(decl.span.start, end)));
        self.write(";\n");
    }

    /// Check if any value in a list requires one-per-line formatting
    ///
    /// This matches Prettier's `shouldBreakList` which checks:
    /// `node.groups.some((node) => node.type === "value-comma_group")`
    ///
    /// Returns true if any value is a space-separated list (box-shadow, text-shadow, etc.)
    /// or an **empty** element (`transition: a,,b`) — postcss-value-parser materializes the
    /// nothing between two commas as a group too, so prettier's predicate sees it and
    /// breaks; putting each element on its own line is also the layout that makes an empty
    /// one visible rather than hiding it in a run of commas.
    /// Functions are NOT checked here - they use doc-based wrapping with group/softline.
    fn any_value_needs_own_line(&self, values: &[CssValue<'_>]) -> bool {
        values
            .iter()
            .any(|v| matches!(v, CssValue::List { .. }) || is_empty_element(v))
    }

    /// This declaration's broken-comma-list layout, or `None` when the list does not break
    /// and takes one of the inline paths instead.
    ///
    /// The break is purely structural — prettier's `shouldBreakList`. tsv formerly also
    /// broke a list whose *source* put a newline after the colon, which made the output
    /// authoring-dependent on a shape nothing else in CSS honors (`margin:⏎1px 2px`,
    /// `color:⏎red` and `translate(⏎1px,⏎2px)` all collapse), and which no fixture relied
    /// on. Pinned by `comma_separated`'s `unformatted_authored_break` variant.
    fn multiline_plan<'v>(&self, decl: &'v ast::CssDeclaration<'v>) -> Option<MultilinePlan<'v>> {
        // Only comma-separated values with multiple items.
        let values = match &decl.value {
            CssValue::CommaSeparated { values, .. } if values.len() > 1 => *values,
            _ => return None,
        };

        // Custom properties skip structure-based multiline (one-per-line for List values).
        // They take the self-deciding width path via `print_decl_value_list` instead.
        // See fixture: declaration_long_multiline (continuation indent for space-separated items)
        if decl.property.starts_with("--") {
            return None;
        }

        let hoist = self.hoistable_leading_run(decl, values);
        // Element 0's node count starts where its own content does, past any hoisted run.
        let content_start = hoist.map(|(_, members)| members[0].span().start);
        if !self.comma_list_should_break(decl, values, content_start) {
            return None;
        }

        Some(MultilinePlan {
            hoisted: hoist.map(|(run, _)| run),
            first_members: hoist.map(|(_, members)| members),
        })
    }

    /// Whether this declaration's comma list breaks one element per line — prettier's
    /// `shouldBreakList`: some element holds more than one node.
    ///
    /// The single predicate `multiline_plan` derives the whole layout from — the break, the
    /// hoist, and the members element 0 emits — so they cannot disagree.
    ///
    /// A comment-free value answers from the AST alone: an element holds more than one
    /// node exactly when the value parser built it as a space-separated `List`, so the
    /// common path keeps its O(1) shape and pays nothing for the comment rules. Only a
    /// declaration that actually carries a `/* … */` (the O(1) `has_block_comment` gate)
    /// pays the per-element lex, and there a comment counts as a node — see
    /// `value_node_count` for why that is what makes the glued and spaced authorings
    /// converge. Element 0's own leading comment run is excluded via `content_start`: it
    /// is postcss `raws.between` material, not value content.
    fn comma_list_should_break(
        &self,
        decl: &ast::CssDeclaration<'_>,
        values: &[CssValue<'_>],
        content_start: Option<u32>,
    ) -> bool {
        if !decl.has_block_comment {
            return self.any_value_needs_own_line(values);
        }
        values.iter().enumerate().any(|(i, value)| {
            let from = if i == 0 { content_start } else { None };
            // An empty element holds *zero* nodes, so the count alone would miss it —
            // it is the one element kind whose break reason is its emptiness.
            is_empty_element(value) || self.value_node_count(value.span(), from) > 1
        })
    }

    /// The leading comment run `print_decl_multiline` hoists onto the colon's line, paired
    /// with element 0's own content members — the ones that print beneath it. `None` when
    /// there is nothing to hoist.
    ///
    /// Two things must hold, and both are about **element 0**. It has to be a
    /// space-separated `List` whose members the run can be cut from at a boundary
    /// (`content_members`), and content has to remain on the other side of that cut. A
    /// comment-only element 0 (`font-family: /* c */, b`) fails the second: hoisting would
    /// strand a bare `,` on its own line, which is what prettier does there and tsv
    /// declines — see the `comma_comment_only_element_prettier_divergence` fixture.
    ///
    /// Gated on the O(1) `has_block_comment`, so a comment-free comma list — the common
    /// case — never pays the lex.
    fn hoistable_leading_run<'v>(
        &self,
        decl: &ast::CssDeclaration<'_>,
        values: &'v [CssValue<'v>],
    ) -> Option<(Span, &'v [CssValue<'v>])> {
        let CssValue::List {
            values: members, ..
        } = values.first()?
        else {
            return None;
        };
        self.hoistable_leading_run_of(decl, members)
    }

    /// The leading comment run of this declaration's value, paired with the `members` at
    /// or after it — the hoist's two halves, for a value whose space-separated members
    /// are `members`. `None` when there is nothing to hoist, or when the run's end is not a
    /// member boundary (`content_members`). The comma list asks for element 0's members
    /// (`hoistable_leading_run`), the grid multirow for the whole value's.
    ///
    /// Gated on the O(1) `has_block_comment`, so a comment-free value never pays the lex.
    fn hoistable_leading_run_of<'v>(
        &self,
        decl: &ast::CssDeclaration<'_>,
        members: &'v [CssValue<'v>],
    ) -> Option<(Span, &'v [CssValue<'v>])> {
        if !decl.has_block_comment {
            return None;
        }
        let (run, content_start) = self.leading_value_comment_run(decl.value.span())?;
        Some((run, Self::content_members(members, content_start)?))
    }

    /// Element 0's content members — those at or after `content_start` — or `None` when
    /// `content_start` does not land on a member boundary.
    ///
    /// That boundary is the hoist's precondition, and checking it here is what keeps the
    /// hoist lossless. A member that *contains* `content_start` holds the comment and the
    /// token after it fused into one: there is nothing to cut there, dropping the member
    /// whole would drop its content, and keeping it would print the comment twice. The
    /// value parser splits a leading comment run into its own member precisely so the
    /// boundary exists (`split_top_level`'s `comment_is_element`); this returns `None` for
    /// anything that still arrives fused, and the caller then declines to hoist rather
    /// than losing either the content or the print-once property.
    ///
    /// `None` also covers an all-comment element (no member is at or after
    /// `content_start`), which is the comment-only element 0 case above.
    fn content_members<'v>(
        members: &'v [CssValue<'v>],
        content_start: u32,
    ) -> Option<&'v [CssValue<'v>]> {
        let kept = members
            .iter()
            .position(|member| member.span().start >= content_start)?;
        (members[kept].span().start == content_start).then_some(&members[kept..])
    }

    /// Print a declaration whose comment-free value is a comma- or space-separated
    /// list, as one self-deciding doc: the renderer's own fit check chooses inline
    /// vs. wrapped, so the wrap decision and the emission are a single representation
    /// and cannot drift (the doc-first shape the at-rule prelude and the function /
    /// multiline-continuation paths already use). Replaces the former measure-then-emit
    /// pair (a discarded flat-join measured to decide, then a *different* fill emitted).
    ///
    /// The two list kinds wrap differently, so each builds its own shape:
    /// - **comma** breaks *after* the colon — flat `prop: a, b`, broken
    ///   `prop:\n\ta,\n\tb` — via `group(indent([line, comma_fill]))`. The group's
    ///   flat-fit reserves the `;` through `comma_fill`'s own `trailing_reserve`, while
    ///   the `line` covers the colon-space, so the boundary matches the old width check
    ///   (`indent + property + ": " + ";" + join <= PRINT_WIDTH`) exactly.
    /// - **space** keeps `: ` literal and wraps only the tail — flat `prop: a b c`,
    ///   broken `prop: a b\n\tc` — via `indent(space_fill)`. A `space_fill` that fits
    ///   renders byte-identical to the old flat join; its last-item `trailing_reserve`
    ///   reproduces the old measure pass's wrap decision (and never leaks into the
    ///   nested `var`/`calc`/`color-mix` groups, which render in the fill's forced flat
    ///   mode — the boundary the suffix mechanism would have broken wrongly).
    ///
    /// Value comments aren't in the CSS AST, so a comment-bearing list isn't routed
    /// here (the dispatch guard); it stays on the source-extracting comment path.
    fn print_decl_value_list(&mut self, decl: &ast::CssDeclaration<'_>) {
        debug_assert!(
            matches!(
                decl.value,
                CssValue::CommaSeparated { .. } | CssValue::List { .. }
            ),
            "the declaration dispatch routes only comma and space lists here"
        );
        let ctx = self.value_ctx();
        let doc = match &decl.value {
            CssValue::CommaSeparated { values, span } => {
                let fill = self.build_comma_fill_doc(values, span.end_usize(), ctx);
                self.build_broken_head_value_decl_doc(fill, &[])
            }
            CssValue::List { values, .. } => {
                let parts = self.build_space_fill_parts(values, ctx);
                let fill = self.d().fill(&parts);
                if Self::is_grid_row_property(decl.property) {
                    self.build_broken_head_value_decl_doc(fill, &[])
                } else {
                    self.build_space_value_decl_doc(fill)
                }
            }
            // The dispatch in `print_css_declaration` only routes comma/space lists here
            // (debug-asserted above, and never reached by the fixtures, the injection audits
            // or the corpus); a release build falls back to the plain `: value` form rather
            // than panicking, like the crate's other defensive value guards.
            _ => {
                let value_doc = self.build_css_value_doc(&decl.value);
                let d = self.d();
                d.concat(&[d.text(": "), value_doc])
            }
        };
        self.write_arena_doc(doc);
        self.write_declaration_end(decl);
    }

    /// Format a CSS declaration (property: value;)
    pub(super) fn print_css_declaration(&mut self, decl: &ast::CssDeclaration<'_>) {
        // The `font` / custom-property gap rule is this declaration's, so it is set here
        // and cleared at the end rather than threaded through the value builders. Saved
        // and restored like `in_keyframes`, so a nested context cannot leak it.
        let was_scope = self.value_scope;
        self.value_scope.font_shorthand = property_is_font_shorthand(decl.property);
        // The whole-value `{…}` block keeps its own braces spaced as authored
        // (`ValueScope::whole_value_block`); read off the value's text, since the block's
        // braces are ordinary members once parsed.
        let value_span = decl.value.span();
        self.value_scope.whole_value_block =
            crate::parser::is_whole_value_block(value_span.extract(self.source))
                .then_some(value_span);
        self.print_css_declaration_in(decl);
        self.value_scope = was_scope;
    }

    fn print_css_declaration_in(&mut self, decl: &ast::CssDeclaration<'_>) {
        self.write_indent();

        // The block-child juncture's boundary run (`a {<NBSP>color: red }`,
        // `color: red;<NBSP>top: 0`), which the parser skipped and this rebuilt head would
        // otherwise drop — the declaration spelling of the claim a rule child gets from its
        // selector and an at-rule from its `@`. Flush against the property, past the indent
        // this printer regenerates; the backward scan settles on the first non-whitespace
        // byte, which is the `{` or the previous declaration's `;`.
        self.write_head_boundary_ws(decl.span.start);

        // Extract property name from source to preserve escape sequences, then
        // lowercase it (property names are ASCII case-insensitive; prettier
        // lowercases — custom properties and escaped/comment-bearing names are
        // preserved by `lowercase_property_name`).
        let decl_source = decl.span.extract(self.source);
        let property_normalized = value_normalization::lowercase_property_name(
            value_normalization::extract_property_name(
                decl_source,
                decl.colon_pos(),
                decl.has_block_comment,
            ),
        );
        // The property→colon gap's own runs (`color<NBSP>: red`, `top <NBSP>: 0`,
        // `left<NBSP> /* c */ : 0`) ride inside that text: the gap is an `allow_whitespace()`
        // juncture (`read_declaration` ends the name at JS `\s`), and the head is rebuilt
        // from the trimmed name, so `extract_property_name` puts each run back where it
        // stood — see `boundary_run_spelling`.
        self.write(&property_normalized);

        // A property carrying a block comment (`color /* c */`) takes a space before
        // the colon in tsv's normalized form (`color /* c */ : value`; fixture
        // `css/tokens/comments/in_property_value_before_colon_prettier_divergence`).
        // Emit it here, once, so every value-kind dispatch path below agrees on the
        // separator. Deciding it per-path (only `print_decl_default` did) left the
        // comma/space-list/string/function/value-comment paths emitting `: ` while the
        // single-value path emitted ` : ` — a symmetric-position inconsistency that
        // became a non-idempotency when a leading-comma value (`,ed`) drops its comma
        // across passes and flips CommaSeparated→Identifier, oscillating the separator.
        // See `tests/css_property_comment_colon_idempotent.rs`.
        if self.property_colon_needs_leading_space(decl, &property_normalized) {
            self.write(" ");
        }

        // Dispatch to appropriate handler based on value type and formatting needs
        if let Some((run, content_start)) = self.progid_opaque_value(decl) {
            self.print_decl_progid_opaque(decl, run, content_start);
        } else if let Some(plan) = self.grid_multirow_plan(decl) {
            self.print_decl_grid_multirow(decl, plan);
        } else if let Some(plan) = self.multiline_plan(decl) {
            self.print_decl_multiline(decl, plan);
        } else if matches!(
            &decl.value,
            CssValue::CommaSeparated { .. } | CssValue::List { .. }
        ) && !self.has_value_comments_in_decl(decl)
        {
            self.print_decl_value_list(decl);
        } else if let CssValue::Function {
            name_span,
            args,
            span,
        } = &decl.value
        {
            self.print_decl_function(decl, decl_source, *name_span, args, *span);
        } else if self.has_value_comments_in_decl(decl) {
            self.print_decl_with_comments(decl, decl_source);
        } else if matches!(&decl.value, CssValue::String { .. }) {
            self.print_decl_string(decl, decl_source);
        } else {
            self.print_decl_default(decl);
        }
    }

    /// Is this declaration's value the opaque `progid:` kind — the legacy IE filter syntax
    /// (`filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FF0000', …)`),
    /// which no value grammar describes and which prettier freezes verbatim?
    ///
    /// Returns the leading comment run to keep on the colon's line, if any, and the
    /// absolute offset where the frozen content begins. The trigger is prettier's
    /// (`parser-postcss.js`: `value.startsWith("progid:")` on the value postcss hands it,
    /// which has already moved a leading comment run into `raws.between`), read here
    /// ASCII-case-insensitively: postcss's parser only recognizes the lowercase spelling
    /// ahead of a top-level `:` and throws "Missed semicolon" on any other, so prettier has
    /// no output for `PROGID:` at all, and tsv — whose parser accepts it — freezes it by the
    /// same reasoning rather than normalizing a value it does not understand
    /// (`css/values/progid_opaque_case_prettier_divergence`).
    ///
    /// The comment-free check is a byte compare on the value's first bytes; only a
    /// declaration that both carries a block comment (`has_block_comment`, O(1)) and opens
    /// its value with one pays the run lex.
    fn progid_opaque_value(&self, decl: &ast::CssDeclaration<'_>) -> Option<(Option<Span>, u32)> {
        fn starts_with_progid(text: &str) -> bool {
            text.as_bytes()
                .get(..7)
                .is_some_and(|head| head.eq_ignore_ascii_case(b"progid:"))
        }
        let decl_source = decl.span.extract(self.source);
        let value = &decl_source[decl.colon_pos() + 1..];
        let trimmed = crate::escapes::trim_start_css(value);
        let value_start = decl.colon_offset + 1 + (value.len() - trimmed.len()) as u32;
        if starts_with_progid(trimmed) {
            return Some((None, value_start));
        }
        if !decl.has_block_comment || !trimmed.starts_with("/*") {
            return None;
        }
        let (run, content_start) =
            self.leading_value_comment_run(Span::new(value_start, decl.span.end))?;
        starts_with_progid(&self.source[content_start as usize..])
            .then_some((Some(run), content_start))
    }

    /// Print the opaque `progid:` value (`progid_opaque_value`): `: `, the leading comment
    /// run whitespace-normalized on the colon's line (`raws.between` material, as on every
    /// other value path), then the content **verbatim** from source — numbers, quotes, hex,
    /// unit case, interior whitespace and newlines all as written, never wrapped — and the
    /// `!important` tail through `write_declaration_end` as usual. Prettier's shape, with
    /// one deliberate difference: a comment inside the value is content here and stays,
    /// where prettier prints postcss's comment-stripped `value` string and drops it
    /// (`css/values/progid_opaque_prettier_divergence`). The trailing whitespace before
    /// the `;` is the one thing trimmed, as postcss's `raw()` trims it too.
    fn print_decl_progid_opaque(
        &mut self,
        decl: &ast::CssDeclaration<'_>,
        run: Option<Span>,
        content_start: u32,
    ) {
        self.write(": ");
        if let Some(run) = run {
            self.write_hoisted_run(run);
            self.write(" ");
        }
        let content = &self.source[content_start as usize..decl.span.end_usize()];
        let content = crate::escapes::trim_end_preserving_escape(content);
        self.write_verbatim_span(Span::new(
            content_start,
            content_start + content.len() as u32,
        ));
        self.write_declaration_end(decl);
    }

    /// The head of a declaration whose value breaks beneath its colon: the `:`, the hoisted
    /// leading comment run if any, and the newline — `prop:⏎` or `prop: /* c */⏎`.
    ///
    /// Shared by the broken comma list (`print_decl_multiline`) and the grid multirow
    /// (`print_decl_grid_multirow`), so the two spell the hoist one way. The run is
    /// `raws.between` material: it stays on the colon's line and the value breaks beneath
    /// it. Whitespace normalization only, so a run of comments is joined single-spaced —
    /// deliberately NOT the value seam (`normalize_value_with_comments`) the comment-bearing
    /// paths take, because the run is between-material rather than value content and holds
    /// nothing but comments and the gaps between them. The value's own members print from
    /// the AST beneath, where every value-level rule already runs.
    fn write_broken_value_head(&mut self, hoisted: Option<Span>) {
        self.write(":");
        if let Some(run) = hoisted {
            self.write(" ");
            self.write_hoisted_run(run);
        }
        self.write("\n");
    }

    /// Write a value's leading comment run onto the colon's line — postcss `raws.between`
    /// material, whitespace-normalized so a run of comments is joined single-spaced.
    /// One spelling for the span-based emitters (`write_broken_value_head`,
    /// `print_decl_progid_opaque`); the text-path twins join their already-split words.
    fn write_hoisted_run(&mut self, run: Span) {
        let text = value_normalization::normalize_css_whitespace(run.extract(self.source));
        self.write(&text);
    }

    /// Print declaration with multiline formatting, per the layout `multiline_plan`
    /// already decided.
    fn print_decl_multiline<'v>(
        &mut self,
        decl: &'v ast::CssDeclaration<'v>,
        plan: MultilinePlan<'v>,
    ) {
        self.write_broken_value_head(plan.hoisted);
        self.indent_level += 1;
        let ctx = self.value_ctx();
        self.print_css_value_multiline(&decl.value, plan.first_members, ctx);
        self.indent_level -= 1;
        self.write_declaration_end(decl);
    }

    /// Print declaration with function value.
    ///
    /// A comment-free value renders through the shared `build_value_function_doc`
    /// group: the renderer's own fit check — with the trailing `;` reserved via
    /// `write_arena_doc_reserving` — decides flat-vs-wrapped, so the wrap decision
    /// and the emission are a single doc and cannot drift. A value with comments
    /// stays on the imperative source-extraction path, since CSS value comments
    /// aren't stored in the AST and so can't be expressed as a doc — but it takes the
    /// same value-level rules there (`normalize_value_with_comments`), so a number, a
    /// hex color, a unit's case and a quote answer the same with a comment as without.
    fn print_decl_function(
        &mut self,
        decl: &ast::CssDeclaration<'_>,
        decl_source: &str,
        name_span: Span,
        args: &[CssValue<'_>],
        span: Span,
    ) {
        if self.has_value_comments_in_decl(decl) {
            self.print_decl_function_with_comments(decl, decl_source, name_span, args, span);
        } else {
            self.write(": ");
            let doc = self.build_value_function_doc(name_span, args, span, self.value_ctx());
            // Reserve the trailing `;` plus any ` !important` tail for the OUTERMOST
            // function group's fit decision (the property + `: ` + tail + `;` boundary).
            // Counting the tail makes an `!important` function wrap when the keyword
            // would push it past the print width, instead of overrunning — matching
            // prettier. The tail comes from `declaration_tail`, spelled by the same
            // `for_each_important_tail_piece` walk the emit writes from, so measure and
            // emit can't drift.
            let tail_width = visual_width(&self.declaration_tail(decl), TAB_WIDTH);
            self.write_arena_doc_reserving(doc, 1 + tail_width);
        }
        self.write_declaration_end(decl);
    }

    /// Print a function-valued declaration whose value contains comments.
    ///
    /// CSS value comments aren't stored in the AST, so the value is reconstructed
    /// from source text: a wrapped function splits its args from source (preserving
    /// the comments in place), an inline one re-emits the whole value. Both go through
    /// `normalize_value_with_comments`, so "reconstructed from source" means the source's
    /// comments in the source's positions — never its number, hex, unit or quote spellings.
    fn print_decl_function_with_comments(
        &mut self,
        decl: &ast::CssDeclaration<'_>,
        decl_source: &str,
        name_span: Span,
        args: &[CssValue<'_>],
        span: Span,
    ) {
        // Width check uses the source the emit will produce, comments included, since the
        // comments aren't in the doc and the value must round-trip verbatim. It is the
        // NORMALIZED text and not the author's, because normalizing shortens
        // (`1.50px` → `1.5px`): measuring the raw form wraps a value that fits, which is
        // what prettier's own group does not do — it measures the doc it printed.
        let func_source = span.extract(self.source);
        // Named for its slice: this is the FUNCTION's own text, while the inline arm below
        // emits the whole post-colon value. The two coincide here — `extract_function_parts`
        // accepts a value as a function only when the matching `)` is its last byte, so a
        // `CssValue::Function` value has no tail — but they are different slices of different
        // strings, so they keep different names rather than shadowing.
        let func_normalized = value_normalization::normalize_value_with_comments(func_source);
        // Visual width (not byte length) of `property: value !important;`. The multibyte
        // comment's byte inflation is excluded by `visual_width`; the ` !important` tail is
        // counted via `declaration_tail` (the same pieces the emit writes) so an important
        // value wraps rather than overrunning the print width. `: ` is 2 cols, `;` is 1.
        let inline_len = visual_width(decl.property, TAB_WIDTH)
            + 2
            + visual_width(&func_normalized, TAB_WIDTH)
            + visual_width(&self.declaration_tail(decl), TAB_WIDTH)
            + 1;
        let needs_wrap = self.indent_width() + inline_len > PRINT_WIDTH;

        self.write(": ");
        if needs_wrap {
            // Wrapped: func(\n\targ1,\n\targ2\n)
            self.write(name_span.extract(self.source));
            self.write("(\n");
            self.indent_level += 1;
            self.print_function_args_from_source(span, args);
            self.indent_level -= 1;
            self.write("\n");
            self.write_indent();
            self.write(")");
        } else {
            let value_normalized =
                value_normalization::extract_value_with_comments(decl_source, decl.colon_pos());
            self.write(&value_normalized);
        }
    }

    /// Print a non-function declaration value that holds comments.
    ///
    /// CSS value comments aren't in the AST, so the value is re-emitted from its normalized
    /// source text (`extract_value_with_comments`) — but as a **fill over its space-separated
    /// parts**, not one write, so it wraps at the print width the way its comment-free twin
    /// does (`build_space_value_decl_doc`: `: ` + `indent(fill)`, the `;` reserved). Every
    /// part is a fill item, a comment included: a trailing comment wraps onto the continuation
    /// by itself, and a **leading** run (`prop: /* c */ word`) parts from the first word when
    /// the head does not fit — where prettier, which holds that run in postcss `raws.between`
    /// glued to the word, overruns (`space_separated_leading_comment_long_prettier_divergence`).
    /// Rendering the doc from the real current column is what makes a long property move the
    /// boundary with it; a width test measured from the indent alone would not.
    ///
    /// A **comma** list that reaches here (one `multiline_plan` declined to break: a leading
    /// run before single-node elements, a comment inside a function element, a custom
    /// property's list) takes the comma twin's shape instead — `build_comma_fill_doc`'s
    /// break-after-the-colon group over a greedy element fill — via
    /// `build_comma_fill_doc_from_text`. The two are told apart by the AST's value kind, not
    /// the text: a comma inside a function's parens is not a list.
    fn print_decl_with_comments(&mut self, decl: &ast::CssDeclaration<'_>, decl_source: &str) {
        let normalized =
            value_normalization::extract_value_with_comments(decl_source, decl.colon_pos());
        let is_comma_list =
            matches!(&decl.value, CssValue::CommaSeparated { values, .. } if values.len() > 1);
        if is_comma_list {
            let doc = self.build_comma_fill_doc_from_text(&normalized);
            self.write_arena_doc(doc);
        } else {
            let mut parts = value_normalization::split_by_space_preserving_parens(&normalized);
            let doc = if Self::is_grid_row_property(decl.property) {
                // The leading run is the colon's, as on the comma shape; an all-comment
                // value is not peeled, so its comments stay the rows they are.
                let leading_run = peel_leading_comment_run(&mut parts);
                let fill = self.build_fill_doc_from_strings(&parts);
                self.build_broken_head_value_decl_doc(fill, &leading_run)
            } else {
                let fill = self.build_fill_doc_from_strings(&parts);
                self.build_space_value_decl_doc(fill)
            };
            self.write_arena_doc(doc);
        }
        self.write_declaration_end(decl);
    }

    /// The comment-bearing comma list's doc — `build_comma_fill_doc`'s shape built from the
    /// normalized value TEXT, since the comments live in the text and not in the AST.
    ///
    /// Returns the whole `: value` doc, the colon included, because a **leading comment run**
    /// belongs to the colon's line and not to the list: it is postcss `raws.between` material
    /// (the same reading `print_decl_multiline` hoists by), so it prints as `: /* c */` ahead
    /// of the group whose `line` breaks the elements beneath it — `font-family: /* c */⏎\tf0,
    /// f1;`, the comment-free twin's `font-family:⏎\tf0, f1;` with the run in the gap. A
    /// comment-only element 0 (`/* c */, b`) is not peeled: hoisting it would strand a bare
    /// `,` on the continuation, the same decline `hoistable_leading_run` makes.
    ///
    /// Each element splits at its top-level spaces into its own `group(indent(fill))`, the
    /// per-element continuation the comment-free twin gives a space-separated item, and the
    /// closing comma is spelled back onto the last element (`has_closing_comma`) — the same
    /// reasons as there.
    fn build_comma_fill_doc_from_text(&self, value: &str) -> DocId {
        let d = self.d();
        let content = crate::escapes::trim_end_preserving_escape(value);
        let elements = value_normalization::split_args_by_comma(content);
        let closing_comma = value_normalization::has_closing_comma(content, &elements);

        let mut leading_run: Vec<&str> = Vec::new();
        let mut parts = DocBuf::with_capacity(elements.len() * 2);
        for (i, element) in elements.iter().enumerate() {
            let element =
                crate::escapes::trim_start_css(crate::escapes::trim_end_preserving_escape(element));
            let mut words = value_normalization::split_by_space_preserving_parens(element);
            if i == 0 {
                leading_run = peel_leading_comment_run(&mut words);
            }
            let element_doc = match words.as_slice() {
                [] => d.empty(),
                [word] => d.text_pooled(word),
                words => {
                    let fill = self.build_fill_doc_from_strings(words);
                    d.group(d.indent(fill))
                }
            };
            parts.push(element_doc);
            if i < elements.len() - 1 {
                parts.push(d.concat(&[d.text(","), d.line()]));
            }
        }
        if closing_comma && let Some(last) = parts.pop() {
            parts.push(d.concat(&[last, d.text(",")]));
        }

        self.build_broken_head_value_decl_doc(d.fill(&parts), &leading_run)
    }

    /// Print declaration with string value
    fn print_decl_string(&mut self, decl: &ast::CssDeclaration<'_>, decl_source: &str) {
        // The original quote is the first byte of the string value's span (recovered
        // from source, not stored).
        let quote = self.source.as_bytes()[decl.value.span().start_usize()] as char;
        self.write(": ");
        if let Some(formatted) =
            value_normalization::extract_string_value(decl_source, decl.colon_pos(), quote)
        {
            self.write(&formatted);
        } else {
            let formatted = format_string_literal("", quote);
            self.write(&formatted);
        }
        self.write_declaration_end(decl);
    }

    /// Whether the property→colon gap takes a space before the colon.
    ///
    /// tsv's normalized form puts one after a property that carries a block comment
    /// (`color /* c */ : value`) — the parser-recorded `has_block_comment` is false
    /// iff the declaration holds no `/* … */` anywhere, so a false value proves the
    /// property text has none and the substring scan is skipped. Consulted once in
    /// `print_css_declaration`, before the value-kind dispatch, so every path agrees;
    /// it is the single predicate that keeps the separator from oscillating across a
    /// value-kind flip (see `tests/css_property_comment_colon_idempotent.rs`).
    fn property_colon_needs_leading_space(
        &self,
        decl: &ast::CssDeclaration<'_>,
        property: &str,
    ) -> bool {
        decl.has_block_comment && property.contains("/*")
    }

    /// Print declaration with default formatting
    fn print_decl_default(&mut self, decl: &ast::CssDeclaration<'_>) {
        // The property→colon separator's leading space (for a comment-bearing property)
        // is emitted once in `print_css_declaration`; every path writes the bare `: `.
        self.write(": ");
        // Empty custom-property value carrying !important (`--a: !important;`): the `: `
        // separator already supplies the single space, so emit `!important` without the
        // extra leading space `write_declaration_end` adds — avoids `--a:  !important;`.
        // "Empty" is a CSS-whitespace question (CSS Syntax 3 §4.2): a value that is one
        // non-ASCII space (`--a: <NBSP>!important;`) is content, printed like any other.
        // The rest of the tail is the shared walk's, so a comment around the bang
        // (`--a: !important /* c */;`) prints where the author wrote it.
        if decl.is_important()
            && matches!(&decl.value, CssValue::Identifier { span } if crate::escapes::trim_start_css(span.extract(self.source)).is_empty())
        {
            self.write_important_tail_after_separator(decl);
            self.write(";\n");
            return;
        }
        self.print_css_value(&decl.value);
        self.write_declaration_end(decl);
    }

    /// This declaration's one-row-per-line grid layout, or `None` when the value prints
    /// inline like any other.
    ///
    /// Prettier's source-position-dependent grid formatting
    /// (`comma-separated-value-group.js` §"Formatting `grid` property"): on `grid` and
    /// `grid-template*`, two consecutive value nodes whose source **start lines** differ are
    /// separated by a hardline, otherwise by a space — the one place CSS formatting reads
    /// the author's line breaks. A comment is a node there, so it takes part in the
    /// decision like a row string and rides the row its line puts it on; the run that
    /// *opens* the value is `raws.between` material instead, hoisted to the colon's line
    /// and outside the decision (`grid-template-areas: /* c */⏎'a' 'b'` stays inline).
    ///
    /// tsv takes the rule for every space-separated value of those properties — the row
    /// strings of `grid-template-areas`, and the track lists of `grid-template-columns` /
    /// `-rows` (line names, sizes, `repeat()` / `minmax()` / `fit-content()`, `subgrid`),
    /// and the `grid` / `grid-template` shorthands' mix of both with `/`. A member's kind
    /// plays no part beyond a string's interior not counting; which line a member starts
    /// on is the question, read against the value rule's glue (`grid_row_opens_at`).
    ///
    /// The comment members come from the value parser: the whitespace split makes a
    /// top-level comment run its own `Identifier` member (`split_top_level`'s
    /// `comment_is_element`), so the rows are readable from the AST plus source
    /// positions, and the emitter prints them through the same leaf printers the
    /// comment-free value uses — no text path, and nothing to keep in step with one.
    fn grid_multirow_plan<'v>(
        &self,
        decl: &'v ast::CssDeclaration<'v>,
    ) -> Option<GridMultirowPlan<'v>> {
        if !Self::is_grid_row_property(decl.property) {
            return None;
        }
        let CssValue::List { values, .. } = &decl.value else {
            return None;
        };
        // An all-comment value has no content to hoist away from and declines the hoist
        // (`leading_value_comment_run`), so its comments stay members.
        let hoist = self.hoistable_leading_run_of(decl, values);
        let members = hoist.map_or(*values, |(_, members)| members);
        if members.len() < 2 {
            return None;
        }
        // A value of nothing but comments qualifies too: postcss leaves the run in the
        // value when no word follows it (it is `raws.between` only ahead of one), so
        // prettier's grid rule reads those comments as nodes and breaks them one per line
        // like rows.
        let ctx = self.value_ctx();
        (1..members.len())
            .any(|i| self.grid_row_opens_at(members, i, ctx))
            .then_some(GridMultirowPlan {
                hoisted: hoist.map(|(run, _)| run),
                members,
            })
    }

    /// Does `next` open a new grid row — do the two members' source lines differ?
    ///
    /// Prettier compares the nodes' `source.start.line`, and the line its value tokenizer
    /// assigns is what decides: the count advances through a **comment**'s interior and a
    /// **function**'s (`repeat(2,⏎1fr) auto` breaks before `auto`; a comment after the
    /// `)` opens a row of its own and the word after the comment stays beside it), but not
    /// through a **string**'s (an escaped-newline continuation, `'a\⏎b'`), so the question
    /// is whether a newline sits anywhere from `prev`'s start to `next`'s — or from
    /// `prev`'s end when `prev` is a string — `'a' /* c⏎d */ 'b'` breaks before `'b'`,
    /// `'a\⏎b' 'c'` stays inline.
    ///
    /// A string nested inside a function member is the one shape this reading misreads: its
    /// escaped newline does not advance prettier's count either, but the scan here does not
    /// step over it. That cell is already a different divergence — tsv expands a function
    /// holding such a string on every property — so the row read is not what decides it.
    fn grid_row_breaks_before(&self, prev: &CssValue<'_>, next: &CssValue<'_>) -> bool {
        let from = if matches!(prev, CssValue::String { .. }) {
            prev.span().end_usize()
        } else {
            prev.span().start_usize()
        };
        let to = next.span().start_usize();
        from <= to && self.source.as_bytes()[from..to].contains(&b'\n')
    }

    /// Does member `i` open a grid row?
    ///
    /// `grid_row_breaks_before` is the line read; the value rule's glue is what it is read
    /// against, since prettier's grid arm sits behind every operator arm. A newline the
    /// author wrote IN the gap is a row break whatever the rule would glue there (a head
    /// `/`, an operator ahead of its group) — authored glue never spans a newline, so only
    /// glue the rule would introduce is declined. A line difference that comes from the
    /// previous member's own interior (a function broken across lines) opens no row at a
    /// glued gap: `fn(⏎…⏎)-a` is one run. A comment member is the exception, and tsv's
    /// own: its interior newline ends the row whatever is glued behind it, where
    /// prettier's operator arm keeps the run on the comment's last line
    /// (`/* c⏎d */-[y]`).
    fn grid_row_opens_at(&self, members: &[CssValue<'_>], i: usize, ctx: ValueCtx) -> bool {
        let (prev, next) = (&members[i - 1], &members[i]);
        if !self.grid_row_breaks_before(prev, next) {
            return false;
        }
        let (from, to) = (prev.span().end_usize(), next.span().start_usize());
        // A comment run is an `Identifier` member, so its text is the one thing that
        // tells it from a word.
        (from <= to && self.source.as_bytes()[from..to].contains(&b'\n'))
            || prev.span().extract(self.source).starts_with("/*")
            || !self.value_gap_is_glued(members, i - 1, ctx)
    }

    /// Print a grid property's value one row per line, per the layout `grid_multirow_plan`
    /// already decided.
    ///
    /// Format: `property:⏎\trow1⏎\trow2;` — a hoisted comment run stays on the colon's line
    /// (`property: /* c */⏎\trow1…`, the shape `print_decl_multiline` gives a comma list). A
    /// row is a line, not a fill: its members are joined by plain spaces and never wrap,
    /// whatever its width — prettier's rows do not either. A gap the value rule glues
    /// (`Printer::value_gap_is_glued` — `1fr/auto`, a sign on its group) takes no space
    /// here either, and the run it joins is written as the one doc the fill makes of it
    /// (`Printer::glued_run_doc`), so a row the one-line fill produced reads back as the
    /// members it was built from and lays out the same. Which member opens a row is
    /// `grid_row_opens_at`'s, the question the plan asked.
    fn print_decl_grid_multirow<'v>(
        &mut self,
        decl: &'v ast::CssDeclaration<'v>,
        plan: GridMultirowPlan<'v>,
    ) {
        self.write_broken_value_head(plan.hoisted);
        self.indent_level += 1;
        let ctx = self.value_ctx();
        let members = plan.members;
        let mut i = 0;
        // Whether member `i` opens a row, answered where the run before it ended.
        let mut opens_row = false;
        while i < members.len() {
            if i == 0 {
                self.write_indent();
            } else if opens_row {
                self.write("\n");
                self.write_indent();
            } else {
                self.write(" ");
            }
            // The glued run opening at `i`: one doc, as it is one fill item.
            let mut end = i + 1;
            while end < members.len() {
                opens_row = self.grid_row_opens_at(members, end, ctx);
                if opens_row || !self.value_gap_is_glued(members, end - 1, ctx) {
                    break;
                }
                end += 1;
            }
            if end - i == 1 {
                self.print_css_value(&members[i]);
            } else {
                let mut docs = DocBuf::with_capacity(end - i);
                for member in &members[i..end] {
                    docs.push(self.build_css_value_doc_in(member, ctx));
                }
                let run = self.glued_run_doc(&docs);
                self.write_arena_doc(run);
            }
            i = end;
        }
        self.indent_level -= 1;
        self.write_declaration_end(decl);
    }

    /// Emit a broken comma list, one element per line.
    ///
    /// Reached only through `multiline_plan`, which returns `Some` exactly when
    /// `comma_list_should_break` does — so there is one arm, not a choice between
    /// one-per-line and greedy packing. A list that does *not* break is not routed here at
    /// all: it takes `print_decl_value_list`'s self-deciding doc (or, with comments,
    /// `print_decl_with_comments`), which does its own width-based wrapping.
    fn print_css_value_multiline<'v>(
        &mut self,
        value: &'v CssValue<'v>,
        first_members: Option<&'v [CssValue<'v>]>,
        ctx: ValueCtx,
    ) {
        debug_assert!(
            matches!(value, CssValue::CommaSeparated { .. }),
            "`multiline_plan` routes only a comma list here"
        );
        let CssValue::CommaSeparated { values, span } = value else {
            // Unreachable via `multiline_plan` (which matches on `CommaSeparated`; asserted
            // above); a release build falls back rather than panicking, like the crate's
            // other defensive value guards.
            self.print_nested_value(value);
            return;
        };

        let closing_comma = list_has_closing_comma(self.source, values, span.end_usize());
        for (i, val) in values.iter().enumerate() {
            self.write_indent();
            // Every item reserves what its own line ends with, so a wrappable item breaks
            // that many columns early rather than letting the terminator push the line to
            // 101 (matching prettier and tsv's hard-print-width stance). That is the `,` a
            // non-final item takes, or the `;` a final one does — except when a closing
            // comma is coming, and the last line ends `,;`, two columns. A space-separated
            // List value self-wraps via `build_space_fill_value_doc`'s
            // `group(indent(fill))`; a non-List value (e.g. a gradient function) wraps via
            // its own value group.
            let reserve = if closing_comma && i == values.len() - 1 {
                2
            } else {
                1
            };
            let doc = if let CssValue::List {
                values: list_values,
                ..
            } = val
            {
                // Element 0 prints only its own content: the leading comment run
                // `print_decl_multiline` hoisted onto the colon's line is already
                // gone from `first_members`, so the run prints exactly once.
                let members = match first_members {
                    Some(members) if i == 0 => members,
                    _ => list_values,
                };
                self.build_space_fill_value_doc(members, ctx)
            } else {
                self.build_css_value_doc(val)
            };
            self.write_arena_doc_reserving(doc, reserve);
            if i < values.len() - 1 {
                self.write(",\n");
            }
        }
        if closing_comma {
            self.write(",");
        }
    }

    /// Build a fill doc for comma-separated values
    ///
    /// Creates a doc that packs values greedily:
    /// - In flat mode: `item1, item2, item3`
    /// - When broken: `item1, item2,\n  item3, item4,\n  item5`
    ///
    /// For space-separated items (CssValue::List), each item is wrapped as
    /// `group(indent(fill([sub1, line, sub2, ...])))` so fill can break within
    /// items with continuation indent. This matches prettier's
    /// `printCommaSeparatedValueGroup` which returns `group(indent(fill(parts)))`.
    ///
    /// `list_end` bounds the list in the source so an authored **closing** comma is spelled
    /// back (`list_has_closing_comma`). It rides on the last element's part rather than
    /// becoming a part of its own: `fill` reads the parts as alternating content and
    /// separators, so a bare `,` appended after the final content lands in separator
    /// position and could be left stranded on its own line when the fill breaks.
    ///
    /// The bare fill: the `;` reserve and the break-after-the-colon group are the caller's
    /// (`build_broken_head_value_decl_doc`).
    fn build_comma_fill_doc(
        &self,
        values: &[CssValue<'_>],
        list_end: usize,
        ctx: ValueCtx,
    ) -> DocId {
        let d = self.d();
        let mut parts = DocBuf::new();
        for (i, val) in values.iter().enumerate() {
            if let CssValue::List {
                values: list_values,
                ..
            } = val
            {
                // Space-separated values: build as group(indent(fill([sub1, line, sub2])))
                // so fill can break within items with continuation indent
                let sub_parts = self.build_space_fill_parts(list_values, ctx);
                let sub_fill = d.fill(&sub_parts);
                let sub_indented = d.indent(sub_fill);
                parts.push(d.group(sub_indented));
            } else {
                parts.push(self.build_css_value_doc_in(val, ctx));
            }
            if i < values.len() - 1 {
                // Separator: ", " in flat mode, ",\n" when broken
                let comma = d.text(",");
                let line = d.line();
                parts.push(d.concat(&[comma, line]));
            }
        }
        if list_has_closing_comma(self.source, values, list_end)
            && let Some(last) = parts.pop()
        {
            parts.push(d.concat(&[last, d.text(",")]));
        }

        d.fill(&parts)
    }

    /// The declaration doc of a space-separated value given its fill: `: ` + `indent(fill)`.
    ///
    /// One shape for the AST fill (`print_decl_value_list`) and the text fill the
    /// comment-bearing value takes (`print_decl_with_comments`), so the two cannot drift;
    /// a grid property's value takes `build_broken_head_value_decl_doc` from both instead.
    /// The fill packs greedily — flat `item1 item2 item3`, broken `item1 item2⏎\titem3` —
    /// and the whole list is the declaration's value, so what follows it is the `;`: one
    /// column, reserved on the fill's last item so it breaks rather than letting the
    /// terminator push the line to 101. The `: ` is **inside** the doc: when the fill drops
    /// an over-wide first item to a fresh line (`prop:⏎\titem`) the renderer trims it there,
    /// where a `: ` written ahead of the doc survived as a trailing space.
    fn build_space_value_decl_doc(&self, fill: DocId) -> DocId {
        let d = self.d();
        let fill = d.with_context(fill, DocContext::reserving(1));
        d.concat(&[d.text(": "), d.indent(fill)])
    }

    /// Is this the `grid` shorthand or a `grid-template*` property — the properties whose
    /// value prettier lays out by the author's source lines (`grid_multirow_plan`)?
    fn is_grid_row_property(property: &str) -> bool {
        property == "grid" || property.starts_with("grid-template")
    }

    /// The declaration doc of a value that breaks **after the colon** given its fill: `:` +
    /// `group(indent([line, fill]))`, the `;` reserved on the fill's last item, a leading
    /// comment run kept on the colon's line ahead of the group (`: /* c */`).
    ///
    /// One shape for three values: the comma list (`build_comma_fill_doc`, and its text twin
    /// `build_comma_fill_doc_from_text` for the comment-bearing list), and a **grid**
    /// property's space-separated value on both the AST and the text path — the row-shaped
    /// twin of `build_space_value_decl_doc`. The group breaks exactly when the fill would,
    /// and then the fill packs greedily beneath the colon.
    ///
    /// A grid value takes it because prettier's grid rule reads the OUTPUT's line breaks as
    /// rows on the next pass (`grid_multirow_plan` takes the same rule): each wrapped line
    /// is a row, and a first row left on the colon's line is re-rowed beneath it — by
    /// prettier and by tsv alike. So the plain fill shape (`prop: item1 item2⏎\titem3`) was
    /// never a fixed point for a grid property; it converged only on the second pass.
    /// Breaking the head with the value puts the wrap straight onto the row layout that pass
    /// would produce, the leading run staying where the row read leaves it (postcss
    /// `raws.between`, outside the row decision). Prettier itself never wraps a grid value —
    /// it overruns — so the shape is tsv's print-width stance applied to prettier's own row
    /// rule (`grid_template_wrap_long_prettier_divergence`).
    fn build_broken_head_value_decl_doc(&self, fill: DocId, leading_run: &[&str]) -> DocId {
        let d = self.d();
        let fill = d.with_context(fill, DocContext::reserving(1));
        let body = d.group(d.indent(d.concat(&[d.line(), fill])));
        if leading_run.is_empty() {
            d.concat(&[d.text(":"), body])
        } else {
            let run = leading_run.join(" ");
            d.concat(&[d.text(": "), d.text_pooled(&run), body])
        }
    }

    /// Print function arguments from source, preserving comments
    ///
    /// Used when a function has comments in its arguments and needs wrapping.
    /// Extracts each argument from the source string to preserve comments.
    fn print_function_args_from_source(&mut self, span: Span, args: &[CssValue<'_>]) {
        // Extract function args content from the function's OWN span, or fall back to
        // semantic printing — lossy, so asserted unreachable: a parsed function's span is in
        // bounds and holds its `(`, and neither the fixtures, the injection audits nor the
        // corpus has ever reached the fallback.
        let args_content = self.function_args_source(span);
        debug_assert!(
            args_content.is_some(),
            "a parsed function's span holds its `(`"
        );
        let Some(args_content) = args_content else {
            self.print_function_args_semantic(args);
            return;
        };

        // Split by top-level commas and print each normalized arg. The trailing trim is
        // what lets `has_closing_comma` see a closing comma the author padded (`…, )`) —
        // untrimmed, that space becomes a whitespace-only final part, which both hides the
        // comma and prints as a blank argument line. It is the escape-aware trim, so an
        // argument legitimately ending in an escaped space (`calc(1px\ )`) keeps it.
        let args_content = crate::escapes::trim_end_preserving_escape(args_content);
        let arg_strs = value_normalization::split_args_by_comma(args_content);
        for (i, arg_str) in arg_strs.iter().enumerate() {
            self.write_indent();
            let normalized = value_normalization::normalize_value_with_comments(arg_str);

            // Check if this arg has space-separated values that would exceed width
            // Split by top-level spaces (not inside parens) to get individual values
            let space_parts = value_normalization::split_by_space_preserving_parens(&normalized);
            if space_parts.len() > 1 && self.arg_string_exceeds_width(&normalized) {
                // Use fill wrapping with continuation indent
                self.print_space_separated_with_fill(&space_parts);
            } else {
                self.write(&normalized);
            }

            if i < arg_strs.len() - 1 {
                self.write(",\n");
            }
        }
        // A comma in final position produced no part, so it can only be written back here
        // — the same closing comma the doc paths spell via `list_has_closing_comma`. It
        // joins the last argument's line rather than taking one of its own.
        if value_normalization::has_closing_comma(args_content, &arg_strs) {
            self.write(",");
        }
    }

    /// The text between a function value's parentheses, sliced from the function's own
    /// span.
    ///
    /// The span is the exact bound: `extract_function_parts` accepts a value as a function
    /// only when the matching close paren is its **last byte**, and a function name cannot
    /// contain a `(`, so the first one in the span opens the arguments. Locating the
    /// function by *name* instead — searching the whole declaration text for `name(` — is
    /// what this replaces: the name is routinely also part of the property
    /// (`--linear-gradient: linear-gradient(…)`), and the search then measures from the
    /// wrong occurrence, which lands on the right paren only because nothing between them
    /// can be a `(` — until a comment in the property→colon gap is (`--linear-gradient
    /// /* ( */: linear-gradient(…)`), and the whole extraction fails to the semantic
    /// fallback, dropping every argument comment and the closing comma.
    ///
    /// `None` only if the span is out of bounds or holds no `(` — neither reachable for a
    /// parsed function, so the caller's fallback is defensive, like the crate's other
    /// value guards.
    fn function_args_source(&self, span: Span) -> Option<&'a str> {
        if span.end_usize() > self.source.len() {
            return None;
        }
        let raw = span.extract(self.source);
        let open = raw.as_bytes().iter().position(|&b| b == b'(')?;
        raw.get(open + 1..raw.len().checked_sub(1)?)
    }

    /// Check if an arg string would exceed width when printed at current position
    ///
    /// Width is the arg's **visual** width (`visual_width`), not its byte length —
    /// a multibyte arg (e.g. an accented comment) is narrower than `str::len()`
    /// reports, and byte math would wrongly route it to the wrapping path.
    fn arg_string_exceeds_width(&self, arg: &str) -> bool {
        self.indent_width() + visual_width(arg, TAB_WIDTH) > PRINT_WIDTH
    }

    /// Print space-separated values with fill wrapping
    ///
    /// Uses continuation indent for wrapped lines. When the first part is a comment
    /// that fills the line, the comment is printed separately at base indent, then
    /// the value parts use continuation indent.
    fn print_space_separated_with_fill(&mut self, parts: &[&str]) {
        if parts.len() < 2 {
            if let Some(part) = parts.first() {
                self.write(part);
            }
            return;
        }

        // Check if first part is a comment that fills the line. Widths are visual
        // (`visual_width`), not byte length: a multibyte first part (e.g. an accented
        // comment) is narrower than `str::len()`, and byte math would wrongly split it
        // onto its own line where it actually fits inline alongside the next value.
        let first_is_comment = parts[0].trim().starts_with("/*");
        let first_len = visual_width(parts[0], TAB_WIDTH);
        let second_len = visual_width(parts[1], TAB_WIDTH);
        let first_fills_line = self.indent_width() + first_len + 1 + second_len > PRINT_WIDTH;

        // When comment fills line: print it separately, then handle values with continuation
        let (value_parts, use_continuation) =
            if first_is_comment && first_fills_line && parts.len() > 2 {
                self.write(parts[0]);
                self.write("\n");
                self.write_indent();

                // Check if value parts need continuation indent (visual width)
                let val1_len = visual_width(parts[1], TAB_WIDTH);
                let val2_len = visual_width(parts[2], TAB_WIDTH);
                let needs_wrap = self.indent_width() + val1_len + 1 + val2_len > PRINT_WIDTH;
                (&parts[1..], needs_wrap)
            } else {
                // Normal case: check if first two items fit together
                let both_fit = self.indent_width() + first_len + 1 + second_len <= PRINT_WIDTH;
                (parts, both_fit)
            };

        // Build and write fill doc
        let fill_doc = self.build_fill_doc_from_strings(value_parts);
        if use_continuation {
            self.indent_level += 1;
        }
        self.write_arena_doc(fill_doc);
        if use_continuation {
            self.indent_level -= 1;
        }
    }

    /// A fill over string parts — `[part1, line, part2, …]` — the text-path twin of
    /// `build_space_fill_parts`, for the comment-bearing values whose parts are slices of the
    /// normalized source rather than AST nodes.
    fn build_fill_doc_from_strings(&self, parts: &[&str]) -> DocId {
        let d = self.d();
        let mut doc_parts = DocBuf::with_capacity(parts.len() * 2);
        for (i, part) in parts.iter().enumerate() {
            doc_parts.push(d.text_pooled(part));
            if i < parts.len() - 1 {
                doc_parts.push(d.line());
            }
        }
        d.fill(&doc_parts)
    }

    /// Print function arguments semantically — the defensive arm for when
    /// `function_args_source` can't slice the arguments out of the function's span.
    ///
    /// ⚠️ Lossy by construction, unlike the crate's other defensive guards: it prints from
    /// the AST, which holds neither the closing comma nor any argument comment that isn't
    /// glued into an argument's own span. That is tolerable only because it is
    /// **unreachable** for a parsed function — the span is in bounds and a function value
    /// always holds its `(`. It exists so an impossible state degrades instead of
    /// panicking; if a shape ever *does* reach it, the fix is to make the slice total, not
    /// to improve this.
    fn print_function_args_semantic(&mut self, args: &[CssValue<'_>]) {
        for (i, arg) in args.iter().enumerate() {
            self.write_indent();
            self.print_nested_value(arg);
            if i < args.len() - 1 {
                self.write(",\n");
            }
        }
    }
}