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
// CSS at-rule parsing
//
// Dispatch spine for `@`-prefixed at-rules: parse the name, route the prelude to a
// structured parser (`preludes`) or the verbatim/normalized raw parser (`raw`), then
// parse the optional block. Also holds the shared at-rule predicates.

mod preludes;
mod raw;

pub(super) use super::CssParser;

use crate::ast::*;
use crate::lexer::TokenKind;
use tsv_lang::{Error, Span};

use self::preludes::{
    ConditionReader, parse_condition_query, parse_container_prelude, parse_custom_selector_prelude,
    parse_import_prelude, parse_scope_prelude,
};
use self::raw::parse_raw_prelude_content;

/// Check if an at-rule name is a keyframes rule (including vendor-prefixed versions)
/// e.g., "keyframes", "-webkit-keyframes", "-moz-keyframes"
pub(crate) fn is_keyframes_atrule(name: &str) -> bool {
    // At-rule names are ASCII case-insensitive (CSS Syntax 3), so `@KEYFRAMES` and
    // `@-WEBKIT-Keyframes` are keyframes too. Callers pass either the raw name (the
    // helper folds case) or an already-lowercased dispatch name.
    name.eq_ignore_ascii_case("keyframes")
        || name.eq_ignore_ascii_case("-webkit-keyframes")
        || name.eq_ignore_ascii_case("-moz-keyframes")
        || name.eq_ignore_ascii_case("-o-keyframes")
        || name.eq_ignore_ascii_case("-ms-keyframes")
}

/// Whether `ident` is a CSS boolean-operator keyword (`and`/`or`/`not`).
///
/// CSS grammar keywords are ASCII case-insensitive (CSS Syntax 3 §"tokenizing"),
/// so `AND`/`Or`/`NOT` are the same keyword as `and`/`or`/`not`. This drives
/// argument/connector *recognition* only; the printer preserves the keyword's
/// source case (matching prettier — see `ConnectorRun::text` and the boolean-operator
/// case note in `docs/conformance_prettier_css.md`).
pub(super) fn is_boolean_operator_keyword(ident: &str) -> bool {
    ident.eq_ignore_ascii_case("and")
        || ident.eq_ignore_ascii_case("or")
        || ident.eq_ignore_ascii_case("not")
}

/// Check if current token is a CSS boolean operator keyword (and, or, not)
pub(super) fn is_boolean_operator(parser: &CssParser<'_, '_>) -> bool {
    if let TokenKind::Identifier = parser.current_kind() {
        is_boolean_operator_keyword(parser.current_identifier())
    } else {
        false
    }
}

/// Parse a CSS at-rule: `@media (...) { ... }` or `@import "...";`
///
/// `nested_in_rule`: true if this at-rule is nested inside a regular rule's declaration block
pub(crate) fn parse_atrule<'arena>(
    parser: &mut CssParser<'_, 'arena>,
    nested_in_rule: bool,
) -> Result<CssAtrule<'arena>, Error> {
    // two wire levels: the `Atrule` and its `Block` (the depth limit's count)
    parser.nested(2, |p| parse_atrule_body(p, nested_in_rule))
}

/// [`parse_atrule`] inside its depth levels.
fn parse_atrule_body<'arena>(
    parser: &mut CssParser<'_, 'arena>,
    nested_in_rule: bool,
) -> Result<CssAtrule<'arena>, Error> {
    let start = parser.span_pos(parser.current_start());

    // Expect @ symbol
    parser.expect(TokenKind::AtSign)?;

    // Parse at-rule name (identifier after @)
    if !parser.check(TokenKind::Identifier) {
        return Err(parser.error_expected_after("at-rule name", "@"));
    }

    // Internal AST: use decoded value (spec-compliant). The name token's source
    // span is captured too — the printer emits the name from source (escape-
    // preserving), not from the decoded string (which may hold a raw control char).
    let name = parser.current_identifier_in_arena();
    let name_span = Span {
        start: parser.span_pos(parser.current_start()),
        end: parser.span_pos(parser.current_end()),
    };
    parser.advance()?;

    parser.skip_whitespace()?;

    // At-rule names are ASCII case-insensitive (CSS Syntax 3); dispatch on the
    // lowercased name so `@MEDIA`/`@Font-Face` route to the right prelude/block
    // grammar instead of the raw fall-through. The stored `name` keeps its source
    // case (the public AST matches Svelte, which preserves it — `@SUPPORTS` →
    // `"name": "SUPPORTS"`); the printer lowercases it for output.
    let name_lc_owned;
    let name_lc: &str = if name.bytes().any(|b| b.is_ascii_uppercase()) {
        name_lc_owned = name.to_ascii_lowercase();
        &name_lc_owned
    } else {
        name
    };

    // Raw source offset of the first prelude token — for the conditional-at-rule raw
    // fallback after the dispatch. Captured before any prelude parsing consumes tokens.
    let prelude_start_raw = parser.current_start();

    // Parse prelude based on at-rule type
    let prelude = if name_lc == "import" {
        // Parse @import prelude structurally: a `url()`/string head, `layer`/`supports()`
        // calls, then one verbatim tail (the media-query list, or whatever else the author
        // wrote) to the prelude end. Nothing here rejects short of the prelude's own end —
        // parseCss stores any prelude raw — so the fallback below is not needed; only an
        // empty prelude takes the raw path internally.
        parse_import_prelude(parser)?
    } else if name_lc == "scope" {
        // Parse @scope prelude as structured selector lists (`PreludeValue::Selectors`).
        parse_scope_prelude(parser)?
    } else if name_lc == "custom-selector" {
        // `@custom-selector :--name <selector-list>` — the third prelude reader (the
        // selector printer), the one standard-CSS at-rule prettier routes to
        // `parseSelector`. Dispatched on the lowercased name like every at-rule here;
        // prettier's own test is case-sensitive (a cataloged divergence). Falls back to
        // the raw prelude internally when the shape does not match.
        parse_custom_selector_prelude(parser)?
    } else if name_lc == "supports" {
        // Parse @supports prelude as structured conditions (for line-width wrapping)
        let (condition, span) = parse_condition_query(parser, ConditionReader::Value)?;
        PreludeValue::Supports { condition, span }
    } else if name_lc == "container" {
        // Parse @container prelude as structured conditions (for line-width wrapping)
        let (name, condition, span) = parse_container_prelude(parser)?;
        PreludeValue::Container {
            name,
            condition,
            span,
        }
    } else if matches!(name_lc, "media" | "custom-media") {
        // Parse @media as raw string to preserve comments
        // Wrapping is handled in the printer by finding and/or boundaries
        // Fully structuring preludes is a deferred design option — see
        // docs/architecture.md § "Red-Green Trees (Deferred)"
        //
        // `@custom-media` reads through the same media-query parser as `@media` —
        // prettier routes `["media", "custom-media"]` to `parseMediaQuery` — so its
        // prelude takes the media reader's whitespace, feature-name case and number
        // rules rather than falling to the verbatim raw branch below. The `--name` it
        // leads with is an `<extension-name>` (css-extensions-1), preserved as any
        // case-sensitive feature name is. Routing is printer-only: the wire prelude is
        // `strip_css_comments(span.extract(source))` for both this arm and `Raw`, and
        // `parse_raw_prelude_content`'s span is flag-independent.
        let (content, span) = parse_raw_prelude_content(parser, true, false)?;
        PreludeValue::Media { content, span }
    } else {
        // Parse as raw string for other at-rules (@keyframes, @layer, @page, etc.).
        // Most have no `property: value` / media-query grammar, so prettier keeps the
        // prelude verbatim (outer-trimmed; only `url()` inner whitespace is trimmed) —
        // preserve internal whitespace (`@layer a , b` must not become `a, b`).
        // `@namespace` is the exception: prettier re-parses its prelude as a value (see
        // postcss `parser-postcss.js`), normalizing whitespace to single spaces AND string /
        // `url()` quotes to single quotes, so it takes the normalizing path. The public AST
        // stays source-verbatim either way (the printer-facing `content` is what differs);
        // see `convert/mod.rs`.
        let is_namespace = name_lc == "namespace";
        let (content, span) = parse_raw_prelude_content(parser, is_namespace, is_namespace)?;
        PreludeValue::Raw { content, span }
    };

    // Raw fallback for conditional at-rules (`@supports`/`@container`) whose prelude
    // isn't a structurable condition. Per CSS Syntax 3 an at-rule prelude is always
    // consumed as component values; a prelude that isn't a valid
    // `<supports-condition>`/`<container-condition>` makes the rule evaluate false — it
    // is NOT a parse error (parseCss stores it raw, prettier prints it verbatim).
    // `parse_condition_query` breaks off at the first token it can't fold in, leaving
    // those tokens unconsumed; re-emit the whole prelude verbatim instead of erroring at
    // the block boundary below. (`@import` needs none: its reader consumes every prelude to
    // the boundary, structuring what it can and keeping the rest as one verbatim tail.)
    //
    // A prelude the reader found no part in at all is no condition either, and when it holds a
    // boundary run it takes the same path: the run sits among the prelude's words (a container
    // name, an operator with no operand — `@container x <NBSP>;`, `@supports and <NBSP>;`),
    // where only the verbatim reader, whose ends are spelled as gaps, keeps each where it was.
    let partless_with_run = match &prelude {
        PreludeValue::Supports { condition, .. } | PreludeValue::Container { condition, .. } => {
            condition.parts.is_empty()
                && crate::whitespace::holds_boundary_member(
                    parser.source(),
                    prelude_start_raw,
                    parser.current_start(),
                )
        }
        _ => false,
    };
    let prelude = if matches!(name_lc, "supports" | "container")
        && (!parser.at_prelude_end() || partless_with_run)
    {
        reconsume_prelude_as_raw(parser, prelude_start_raw)?
    } else {
        prelude
    };

    // Parse block (if present)
    let (block, end) = if parser.check(TokenKind::LeftBrace) {
        let block = parse_atrule_block(parser, name_lc, nested_in_rule)?;
        let end = block.span.end;
        (Some(block), end)
    } else if parser.check(TokenKind::Semicolon) {
        // Statement at-rule (no block)
        let end = parser.span_pos(parser.current_end());
        parser.advance()?;
        return Ok(CssAtrule {
            name,
            name_span,
            prelude,
            block: None,
            span: Span { start, end },
        });
    } else {
        return Err(parser.error_expected_after("'{' or ';'", "at-rule prelude"));
    };

    Ok(CssAtrule {
        name,
        name_span,
        prelude,
        block,
        span: Span { start, end },
    })
}

/// Re-emit an at-rule prelude that couldn't be structured (a non-condition
/// `@supports`/`@container`, or an `@import` prelude holding nothing but whitespace and
/// comments) as a raw verbatim prelude.
///
/// Consumes the remaining prelude tokens up to the block boundary and rebuilds the
/// **whole** prelude — from `prelude_start_raw`, the raw (base-offset-free) source
/// offset of its first token — verbatim and outer-trimmed, as `PreludeValue::Raw`
/// (whose printer emits it as-is and whose convert extracts it from `span`). This
/// matches parseCss (which stores an unstructurable prelude raw) and prettier (which
/// prints it verbatim); see the call sites for the CSS-Syntax rationale.
pub(super) fn reconsume_prelude_as_raw<'arena>(
    parser: &mut CssParser<'_, 'arena>,
    prelude_start_raw: usize,
) -> Result<PreludeValue<'arena>, Error> {
    // Re-read from the prelude's first token, so its ends are observed over all of it. The
    // registry keeps everything the abandoned reader registered (the truncation is to its own
    // length): only the cursor moves. Every token is taken verbatim, so the output length at a
    // token is its raw offset.
    let comments_len = parser.comments.len();
    parser.rewind_to(prelude_start_raw, comments_len)?;
    let mut ends = raw::PreludeEnds::default();
    while !parser.at_prelude_end() {
        ends.observe(parser, parser.current_start() - prelude_start_raw, 0);
        parser.advance()?;
    }

    // Verbatim prelude text runs from its first token to the boundary token; outer
    // whitespace is trimmed so the public AST (from `span`) and printer `content` agree.
    // Escape-aware, and CSS-whitespace-only: a prelude can end in an escape whose payload
    // is that whitespace (`@layer a\ ;`), and an NBSP is prelude content, not padding.
    // Only the printer-facing `content` takes the ends' respelling (`raw::PreludeEnds`); the
    // span, which the wire extracts from, is the author's.
    let raw = &parser.source()[prelude_start_raw..parser.current_start()];
    let lead = crate::escapes::trim_start_css(raw);
    let trimmed = crate::escapes::trim_end_preserving_escape(lead);
    let content_start_raw = prelude_start_raw + (raw.len() - lead.len());
    let content_end_raw = content_start_raw + trimmed.len();
    let respelled = ends.respell(
        parser.source(),
        prelude_start_raw,
        parser.current_start(),
        raw,
    );
    let content = respelled
        .as_deref()
        .map_or(trimmed, |respelled| crate::escapes::trim_css(respelled));

    Ok(PreludeValue::Raw {
        content: parser.alloc_str_in(content),
        span: Span {
            start: parser.span_pos(content_start_raw),
            end: parser.span_pos(content_end_raw),
        },
    })
}

/// Parse an at-rule block: `{ ... }`
/// Block contents depend on at-rule type and nesting context:
/// - @media, @supports, @layer: contain rules (top-level) or declarations (nested)
/// - @keyframes: contains keyframe blocks (rules with percentage/from/to selectors)
/// - @font-face, @page: contain declarations
///
/// `nested_in_rule`: true if this at-rule is nested inside a regular rule's declaration block
fn parse_atrule_block<'arena>(
    parser: &mut CssParser<'_, 'arena>,
    atrule_name: &str,
    nested_in_rule: bool,
) -> Result<CssAtruleBlock<'arena>, Error> {
    let start = parser.span_pos(parser.current_start());

    // Expect {
    parser.expect(TokenKind::LeftBrace)?;
    // `allow_comment_or_whitespace`, here and after every child below — so the skip is the
    // boundary one, which also steps a JS-`\s` run the lexer read as the head of an
    // identifier (`CssParser::skip_boundary_whitespace`). This loop is the one such juncture
    // that does not route through `skip_html_comment_markers`: legacy `<!--` markers are a
    // stylesheet-body and style-rule-block form, not an at-rule-block one.
    parser.skip_boundary_whitespace()?;

    let mut children = parser.bvec();

    // Determine what content to expect based on at-rule type and nesting context
    // When nested inside a rule, at-rules that normally contain rules should contain declarations instead
    // NB: `@scope` is deliberately absent — its body is a nested-declarations
    // context (CSS Cascade 6 §"Scoped Style Rules"): the selectors are a
    // `<relative-selector-list>` scoped to `:scope` (a leading combinator like
    // `> p` is valid) and bare declarations apply to `:scope`. It therefore flows
    // through the generic fallback below (relative rules via `parse_rule(nested)`
    // + declarations), not this non-relative rule-list path.
    let expect_rules = (matches!(
        atrule_name,
        "media" | "supports" | "layer" | "container" | "starting-style" | "font-feature-values"
    ) || is_keyframes_atrule(atrule_name))
        && !nested_in_rule;
    // Everything that is NOT a top-level rule-list block flows through the generic
    // block-agnostic fallback below (`parse_block_child`), which disambiguates a bare
    // declaration from a nested rule via `is_nested_rule_start()` per CSS Syntax 3
    // §"consume a block's contents". This covers the declaration-context at-rules
    // (`@font-face`, `@page`, `@property`, `@counter-style`, `@color-profile`,
    // `@position-try`, `@font-palette-values`, the `@font-feature-values` sub-rules, and
    // the `@page` margin boxes like `@top-center`) as well as unknown at-rules and
    // `@scope`. Making them agnostic matches parseCss, which is a pure scan-to-terminator
    // classifier: `@page :first { h1 {} }` parses the `h1 {}` as a nested style rule
    // (declarations end at `;`/`}`, a nested rule at `{`), while `margin: 1cm;` stays a
    // declaration. "Invalid in this context" (a style rule inside `@font-face`) is a later
    // validity concern, deferred to diagnostics — the same permissive-parser posture as
    // elsewhere. Conditional group at-rules (@media/@supports/@layer/@container) nested
    // inside a rule likewise fall through here.

    while !parser.check(TokenKind::RightBrace) && !parser.check(TokenKind::Eof) {
        if matches!(parser.current_kind(), TokenKind::Comment) {
            let comment = parser.parse_block_comment()?;
            children.push(CssBlockChild::Comment(comment));
            continue;
        }

        // Handle nested at-rules
        if parser.check(TokenKind::AtSign) {
            // Propagate the nesting context: an at-rule nested inside a conditional
            // group at-rule that is itself inside a style rule is still in a nesting
            // context, so its block holds declarations (per CSS Nesting). Outside a
            // rule, `nested_in_rule` is false and this stays a normal at-rule block.
            let atrule = parse_atrule(parser, nested_in_rule)?;
            children.push(CssBlockChild::Atrule(atrule));
            parser.skip_boundary_whitespace()?;
            continue;
        }

        // @media/@supports/@layer/@container/keyframes/… block — a rule-list context.
        // But CSS block parsing is agnostic (CSS Syntax 3 §"consume a block's contents"):
        // a bare declaration still parses here (invalid-in-context, dropped by a later
        // validity step, but parseCss keeps it — e.g. `@media screen { color: red }`, the
        // shape @function/@mixin bodies use). `parse_block_child` disambiguates; `false`
        // keeps the non-relative selector grammar so keyframe stops (`0%`, `from`) and
        // top-level complex selectors parse unchanged.
        if expect_rules {
            children.push(parse_block_child(parser, false, atrule_name)?);
            parser.skip_boundary_whitespace()?;
            continue;
        }

        // Nested-declarations fallback (declaration-context at-rules like `@page`/
        // `@font-face`, unknown at-rules, `@scope`): declaration-vs-rule disambiguation
        // via `is_nested_rule_start`. `true` allows a leading combinator (a `> .child {}`
        // relative selector in a `@scope` body).
        children.push(parse_block_child(parser, true, atrule_name)?);
        parser.skip_boundary_whitespace()?;
    }

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

    Ok(CssAtruleBlock {
        children: children.into_bump_slice(),
        span: Span { start, end },
    })
}

/// Parse one at-rule block child, disambiguating a nested rule from a bare
/// declaration via `is_nested_rule_start` (CSS block parsing is agnostic — CSS
/// Syntax 3 §"consume a block's contents"). `allow_relative_selectors` picks the
/// selector grammar for the rule case: `false` = non-relative (top-level rule-list
/// blocks — conditional groups, keyframes), `true` = relative (nested-declarations
/// contexts — unknown at-rules, `@scope` — where a leading combinator is valid).
fn parse_block_child<'arena>(
    parser: &mut CssParser<'_, 'arena>,
    allow_relative_selectors: bool,
    atrule_name: &str,
) -> Result<CssBlockChild<'arena>, Error> {
    if super::declarations::is_nested_rule_start(parser)? {
        Ok(CssBlockChild::Rule(super::declarations::parse_rule(
            parser,
            allow_relative_selectors,
        )?))
    } else if parser.check(TokenKind::Identifier) {
        Ok(CssBlockChild::Declaration(
            super::declarations::parse_declaration(parser)?,
        ))
    } else {
        Err(parser.error_unexpected(&format!("token in @{atrule_name} block")))
    }
}