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
use super::CssParser;
use super::decl_scan::{DeclarationHead, ScannedDeclaration};
use crate::ast::*;
use crate::lexer::TokenKind;
use tsv_lang::{Error, Span};

/// Check if we're looking at the start of a nested rule (selector) rather than a declaration.
///
/// Nested rules can start with:
/// - `&` (nesting selector)
/// - `.` (class selector)
/// - `#` (ID selector)
/// - `*` (universal selector)
/// - `:` (pseudo-class/element)
/// - `[` (attribute selector)
/// - `>`, `+`, `~` (leading combinator - CSS Nesting relative selectors)
/// - Identifier (type selector - but could also be a property name)
///
/// For identifiers, we need to look ahead: if next non-whitespace/comment token is `:`, it's a declaration.
/// Custom-property identifiers (`--*`) are always declarations and bypass the lookahead.
pub(crate) fn is_nested_rule_start(parser: &CssParser<'_, '_>) -> Result<bool, Error> {
    match parser.current_kind() {
        // Unambiguous selector start tokens
        TokenKind::Ampersand
        | TokenKind::Dot
        | TokenKind::Hash
        | TokenKind::Asterisk
        | TokenKind::Colon
        | TokenKind::LeftBracket
        // Keyframe selector stop (`0%`, `50%`): a `<percentage>`-led block child is
        // always a keyframe rule, never a declaration (a property name is an
        // identifier or `--custom`), so it starts a rule wherever this is consulted.
        | TokenKind::Percentage
        // Leading combinators (CSS Nesting relative selectors: `> .child {}`, `+ .sibling {}`)
        | TokenKind::GreaterThan
        | TokenKind::Plus
        | TokenKind::Tilde => Ok(true),

        // Ambiguous: identifier could be type selector (nested rule) or property name (declaration)
        // Look ahead to check if next non-whitespace/comment token is `:` (declaration) or not (nested rule)
        TokenKind::Identifier => {
            // CSS Custom Properties (`--foo`) are always declarations. CSS Variables
            // Module Level 1 §2.1 defines their value as `<declaration-value>`, which
            // permits any token sequence with balanced `()` / `[]` / `{}` — including
            // a top-level `{...}` block. Without this short-circuit, `--foo: { ... }`
            // would misclassify as a type-selector + pseudo-class.
            if parser.current_identifier_is_custom_property() {
                return Ok(false);
            }
            // Look ahead past whitespace and comments: a `:` there may still be a declaration
            // (`color: red`) or a nested rule with a pseudo-class (`span:hover { }`), told apart
            // by a deeper scan; anything else (`div {`, `a.b`, `a#b`, `a[b]`) opens a rule.
            super::decl_scan::identifier_child_is_rule(parser)
        }

        _ => Ok(false),
    }
}

/// Parse a CSS rule: `selector { property: value; }`
///
/// When `nested` is true, the selector list allows leading combinators (CSS Nesting relative selectors).
/// For example: `> .child {}`, `+ .sibling {}`, `~ .general {}`
pub(crate) fn parse_rule<'arena>(
    parser: &mut CssParser<'_, 'arena>,
    nested: bool,
) -> Result<CssRule<'arena>, Error> {
    // two wire levels: the `Rule` and its `Block` (the depth limit's count)
    parser.nested(2, |p| parse_rule_body(p, nested))
}

/// [`parse_rule`] inside its depth levels.
fn parse_rule_body<'arena>(
    parser: &mut CssParser<'_, 'arena>,
    nested: bool,
) -> Result<CssRule<'arena>, Error> {
    // Ahead of the capture: this offset becomes the rule's AND its prelude's `start`, both
    // of which sit past a boundary whitespace run in `parseCss` (see
    // `skip_boundary_whitespace`). Two of the three callers reach here having already
    // stepped one — the stylesheet body and a style rule's block, through
    // `skip_html_comment_markers` — but an at-rule block's `parse_block_child` asks
    // `is_nested_rule_start` first and arrives with the run still standing, so this is a
    // load-bearing skip and not only a guard on the span capture. The selector list skips
    // again on its own: the two captures are separate and neither derivable from the other.
    parser.skip_boundary_whitespace()?;
    let start = parser.span_pos(parser.current_start());

    // Nested rules use relative selectors (can start with combinators like `> .child`)
    // Top-level rules use complex selectors (cannot start with combinators)
    let selector = if nested {
        super::selectors::parse_relative_selector_list(parser)?
    } else {
        super::selectors::parse_complex_selector_list(parser)?
    };

    // Capture any comments after selector (before {)
    let mut declarations = parser.bvec();
    parser.skip_whitespace()?;
    while matches!(parser.current_kind(), TokenKind::Comment) {
        let comment = parser.parse_block_comment()?;
        declarations.push(CssBlockChild::Comment(comment));
        parser.skip_whitespace()?;
    }

    // Expect { and capture its start
    let block_start = parser.span_pos(parser.current_start());
    parser.expect(TokenKind::LeftBrace)?;
    parser.skip_whitespace()?;

    // Parse declarations, comments, and nested rules
    while !parser.check(TokenKind::RightBrace) && !parser.check(TokenKind::Eof) {
        // `read_block` boundary: discard legacy `<!-- ... -->` markers between items
        // (matches parseCss's `allow_comment_or_whitespace`); a marker may sit right
        // before the closing `}`, so re-check the terminators after skipping.
        parser.skip_html_comment_markers()?;
        if parser.check(TokenKind::RightBrace) || parser.check(TokenKind::Eof) {
            break;
        }

        if matches!(parser.current_kind(), TokenKind::Comment) {
            let comment = parser.parse_block_comment()?;
            declarations.push(CssBlockChild::Comment(comment));
            continue;
        }

        // Check for nested at-rule (CSS Nesting Module)
        if parser.check(TokenKind::AtSign) {
            // Parse nested at-rule (e.g., @media inside a rule)
            // Pass true for nested_in_rule since we're inside a regular rule's declaration block
            let nested_atrule = super::atrules::parse_atrule(parser, true)?;
            declarations.push(CssBlockChild::Atrule(nested_atrule));
            parser.skip_whitespace()?;
            continue;
        }

        // Check if we're looking at a nested rule (CSS Nesting Module)
        if is_nested_rule_start(parser)? {
            // Parse nested rule recursively (nested rules allow leading combinators)
            let nested_rule = parse_rule(parser, true)?;
            declarations.push(CssBlockChild::Rule(nested_rule));
            parser.skip_whitespace()?;
            continue;
        }

        // Otherwise, parse as declaration
        if parser.check(TokenKind::Identifier) {
            let decl = parse_declaration(parser)?;
            declarations.push(CssBlockChild::Declaration(decl));
        } else {
            // Skip unexpected tokens
            parser.advance()?;
        }
        parser.skip_whitespace()?;
    }

    // Expect } and capture its end position
    if !parser.check(TokenKind::RightBrace) {
        return Err(parser.error_expected("'}'"));
    }
    let block_end = parser.span_pos(parser.current_end());
    parser.advance()?; // consume }

    Ok(CssRule {
        selector,
        block_span: Span {
            start: block_start,
            end: block_end,
        },
        declarations: declarations.into_bump_slice(),
        span: Span {
            start,
            end: block_end,
        },
    })
}

/// The declaration head as a token walk: from the property identifier across the
/// property→colon gap, the `:` and the whitespace after it, to the value's first token.
///
/// The reference `decl_scan`'s byte head reproduces, and the path a declaration takes when
/// that byte head declines. Leaves the parser on the value's first token, which
/// `parse_declaration` then re-seats past (the value is scanned, never tokenized), so
/// nothing lexed here survives except the head's three facts.
fn lex_declaration_head(parser: &mut CssParser<'_, '_>) -> Result<DeclarationHead, Error> {
    parser.advance()?;

    // The property→colon gap is an `allow_whitespace()` juncture: `read_declaration` ends
    // the property at the first JS-`\s` code point (`read_until(/[\s:]/)`) and skips to the
    // colon, so a boundary run standing here (`color <NBSP>: red`) is the gap's, not a name.
    let gap_comment = parser.skip_boundary_whitespace_and_comments()?;

    // The parser sits on the colon here — whitespace and comments already skipped — and
    // `expect` below guarantees it is one.
    let colon = parser.current_start();
    parser.expect(TokenKind::Colon)?;
    // Only skip whitespace, NOT comments - comments in values need to be preserved
    parser.skip_whitespace()?;

    Ok(DeclarationHead {
        colon,
        gap_comment,
        value_start: parser.current_start(),
    })
}

/// The debug oracle for a head settled from the bytes: the token head must reach the same
/// colon, gap-comment answer and value start. It moves the parser, which the terminator
/// seat that follows every head undoes.
#[cfg(debug_assertions)]
fn assert_token_head_agrees(
    parser: &mut CssParser<'_, '_>,
    property_end: usize,
    head: DeclarationHead,
) {
    let lexed = lex_declaration_head(parser);
    assert!(
        lexed.as_ref().is_ok_and(|lexed| *lexed == head),
        "byte-scanned declaration head disagreed with the token head at {property_end}: \
         scanned {head:?}, lexed {lexed:?}"
    );
}

/// Parse a CSS declaration: `property: value;`
pub(crate) fn parse_declaration<'arena>(
    parser: &mut CssParser<'_, 'arena>,
) -> Result<CssDeclaration<'arena>, Error> {
    let start = parser.base_offset() + parser.current_start();

    // Parse property
    if !parser.check(TokenKind::Identifier) {
        return Err(parser.error_expected_at("property name", start));
    }
    // Internal AST: use decoded value (spec-compliant)
    // Svelte quirk (raw value) will be applied in conversion layer
    let property = parser.current_identifier_in_arena();
    let property_end = parser.current_end();

    // Locate the value and scan it to its terminator (`;` / `}` at depth zero, or EOF),
    // collecting the few facts the declaration node needs: where its span ends, whether it
    // holds a comment, whether it is empty, and where a trailing `!important` sits. The scan
    // walks bytes rather than tokens — a value's text is re-parsed from source below
    // (`parse_value_from_source`) anyway, so tokenizing it here only to find a `;` was
    // paying the lexer twice for the same bytes. See `decl_scan`.
    //
    // For a non-custom declaration the rule/declaration disambiguation already walked this
    // head and value (to decide it was a declaration, not a nested rule) and stashed what it
    // found, so take that rather than lex the head and walk the value a second time. The
    // stash is absent for a custom property (which bypasses the disambiguation) or when the
    // byte scan declined; then locate the head (from the bytes where they settle it) and scan
    // now.
    let ScannedDeclaration {
        head,
        facts,
        class: value_class,
    } = match parser.take_declaration(property_end) {
        Some(scanned) => {
            #[cfg(debug_assertions)]
            {
                // The stashed head must be the token head's, and the stashed facts a fresh
                // scan's at that value start — proving the disambiguation located the colon
                // and the value identically to the parser's own positioning. The separator
                // class rides the same comparison, so the fused walk and the plain one are
                // held to one answer.
                assert_token_head_agrees(parser, property_end, scanned.head);
                let value_start = scanned.head.value_start;
                let fresh = super::decl_scan::scan_value(parser, value_start);
                assert!(
                    fresh.as_ref().is_ok_and(
                        |(facts, class)| *facts == scanned.facts && *class == scanned.class
                    ),
                    "reused value facts disagreed with a fresh scan at {value_start}: \
                     reused {scanned:?}, fresh {fresh:?}"
                );
            }
            scanned
        }
        None => {
            let byte_head = super::decl_scan::scan_declaration_head_bytes(
                parser.source().as_bytes(),
                property_end,
            );
            let head = match byte_head {
                Some(head) => head,
                None => lex_declaration_head(parser)?,
            };
            let (facts, class) = super::decl_scan::scan_value(parser, head.value_start)?;
            // A head located from the bytes is held to the token head once the value has
            // scanned, not before: the token head lexes the value's first token, and a value
            // that token fails in is the value scan's error to raise — at the same position.
            #[cfg(debug_assertions)]
            if byte_head.is_some() {
                assert_token_head_agrees(parser, property_end, head);
            }
            ScannedDeclaration { head, facts, class }
        }
    };
    let value_start_raw = head.value_start;
    let property_gap_comment = head.gap_comment;
    // The real `property : value` colon offset (host coordinates, like the declaration
    // span), so the writer splits property/value without a re-scan.
    let colon_offset = parser.span_pos(head.colon);

    // Land on the terminator the scan found — the lexer never tokenized the value, and it
    // does not tokenize the terminator either: the scan stopped *on* it, so it knows which
    // token it is.
    parser.seat_at_terminator(facts.terminator, facts.terminator_kind);

    let has_value_comment = facts.has_comment;
    let important_end = facts.important_end.map(|end| parser.span_pos(end));

    // Allow empty value if we have comments (e.g., `color: /* comment */;`)
    // Svelte treats the comment as the value in this case.
    // Also allow an empty custom-property value (`--a:;`): css-variables-1 makes the
    // value optional (`<declaration-value>?`), and css-syntax-3 trims leading/trailing
    // whitespace, so the value is empty regardless of spacing. The empty value parses to
    // an empty identifier and prints as a single space (`--a: ;`), the form
    // css-variables-1 mandates for serialization. An empty value carrying `!important`
    // (`a: !important;`) parses too: `parseCss` accepts it, its wire value being the
    // `!important` text alone, and the value holds the same empty identifier as the custom
    // one. A bare empty non-custom value (`a:;`, `a: ;`) stays an error, as `parseCss`'s
    // "Declaration cannot be empty".
    let is_custom_property = property.starts_with("--");
    if facts.is_empty && !has_value_comment && !is_custom_property && important_end.is_none() {
        return Err(parser.error_msg_at("Empty CSS value", start));
    }

    // Create span for the value (from first token to last token, excluding comments)
    let value_span = Span {
        start: parser.span_pos(value_start_raw),
        end: parser.span_pos(facts.value_end),
    };

    // Parse the value directly from source for accurate span tracking
    // Use parse_value_from_source instead of parse_value_string to avoid
    // span drift from whitespace differences between source and reconstructed tokens
    //
    // The scan's offsets are already source-relative; the span above is the
    // `base_offset`-shifted (host) form of the same range.
    let base = parser.base_offset() as u32;
    let source_relative_span = Span {
        start: value_start_raw as u32,
        end: facts.value_end as u32,
    };

    // Custom properties (--*) with unusual values (e.g., leading comma) preserve raw value
    // Normal custom property values are still parsed for proper formatting. Only a custom
    // property reads its raw text here, so only it pays the extract; a leading-comma test
    // needs the leading trim alone.
    let value = if is_custom_property
        && source_relative_span
            .extract(parser.source())
            .trim_start()
            .starts_with(',')
    {
        // Leading comma is unusual syntax - preserve as raw identifier
        // (text recovered verbatim from `span` at print time)
        CssValue::Identifier { span: value_span }
    } else {
        super::value::parse_value_from_source(
            parser.source(),
            source_relative_span,
            base,
            value_class,
            facts.group_closes_value,
            // A top-level `:` is a value operator in a custom property's value and
            // nowhere else: css-syntax-3 admits whatever the text holds there, and
            // prettier's parser throws on a plain property's (`a { b: c:d }`), so there
            // is no oracle for splitting one. This is where the `--` is known.
            is_custom_property,
            // the value parser's own recursion, a level a function, counted as it parses
            parser.nesting_room(),
            parser.arena,
        )
        .map_err(|open| parser.too_deep(open as usize))?
    };

    // Optionally consume semicolon (but don't include it in the declaration span)
    if parser.check(TokenKind::Semicolon) {
        parser.advance()?;
    }

    // Declaration ends after the value, NOT including the semicolon
    let decl_span = Span {
        start: start as u32,
        end: value_span.end,
    };

    // A block comment anywhere in the declaration extent (property→colon gap or the
    // value/`!important`/trailing region, tracked by `has_value_comment`) routes the
    // writer to the comment-aware split/strip; false takes its zero-scan fast path.
    let has_block_comment = property_gap_comment || has_value_comment;

    // Span covers the entire declaration (property + value, not including semicolon)
    // The source value will be extracted on-demand during conversion using this span
    Ok(CssDeclaration {
        property,
        value,
        important_end,
        span: decl_span,
        colon_offset,
        has_block_comment,
    })
}