Skip to main content

tabnas_css/
grammar.rs

1/* Copyright (c) 2025 Richard Rodger, MIT License */
2
3//! The grammar: the embedded `css-grammar.jsonic` text, and the reader that
4//! turns it into the rule table [`machine`](crate::machine) runs.
5//!
6//! `css-grammar.jsonic` at the repository root is the SINGLE SOURCE OF TRUTH
7//! for the rules. `ts/embed-grammar.js` copies it verbatim into `ts/src/css.ts`,
8//! `go/css.go` and the `GRAMMAR_TEXT` literal below. Never hand-edit between
9//! the `BEGIN/END EMBEDDED` markers — edit the `.jsonic` file and re-run
10//! `npm run embed` from `ts/`.
11//!
12//! The TypeScript and Go ports read that text with a jsonic engine, which they
13//! already depend on. This port has no engine under it, so it carries the
14//! small reader below: enough of the jsonic dialect to read THIS document —
15//! `#` comments, `{}` maps with bare or quoted keys, `[]` lists, bare and
16//! quoted scalars, and newline-separated entries with commas optional. It is
17//! deliberately not a jsonic implementation; `tests/grammar.rs` pins what it
18//! accepts.
19
20use std::collections::HashMap;
21
22// --- BEGIN EMBEDDED css-grammar.jsonic ---
23const GRAMMAR_TEXT: &str = r##"
24# CSS Grammar Definition (AST)
25# Parses CSS into a reworkcss-style abstract syntax tree: ordered, typed
26# nodes that preserve declaration order, duplicate properties, rule types
27# and comments.
28#
29#   { type: 'stylesheet', rules: [ Node, ... ] }
30#   rule        { type:'rule',        selectors:[string], declarations:[Node] }
31#   declaration { type:'declaration', property:string, value:string }
32#   comment     { type:'comment',     comment:string }
33#   at-rules (media/supports/import/keyframes/font-face/...) — see below
34#
35# Example:
36#   a { color: red; color: blue } /* note */
37# parses to:
38#   { type:'stylesheet', rules: [
39#     { type:'rule', selectors:['a'], declarations:[
40#       { type:'declaration', property:'color', value:'red' },
41#       { type:'declaration', property:'color', value:'blue' } ] },
42#     { type:'comment', comment:' note ' } ] }
43#
44# The cssToken lex matcher emits: #TX (one selector or a property name),
45# #GC (a top-level selector-group comma), #VL (a declaration value),
46# #CC (a comment, at a statement position), and the at-rule tokens
47# #ATR/#ATD/#ATK/#ATS (carrying the keyword in val and the prelude in
48# use). Fixed "{" "}" ":" lex as #OB #CB #CL; ";" is remapped to #CA.
49#
50# Every alt is tagged with the 'css' group via { rule: { alt: { g: 'css' } } }.
51
52{
53  rule: {
54    # The top-level stylesheet node. "items" fills its rules[].
55    stylesheet: {
56      open: [
57        { a: '@cssSheet' p: items g: 'css,sheet' }
58      ]
59      close: [
60        { s: '#ZZ' a: '@cssEnd' g: 'css,sheet,end' }
61      ]
62    }
63
64    # A statement list (the stylesheet body or an at-rule's rules body). Reads
65    # rule / at-rule / comment nodes into the enclosing node's rules[]. Each
66    # iteration pushes one "statement" child, then @cssPushRule appends it.
67    items: {
68      open: [
69        { s: '#ZZ' b: 1 g: 'css,items,end' }
70        { s: '#CB' b: 1 g: 'css,items,endblock' }
71        { p: statement g: 'css,items' }
72      ]
73      close: [
74        { s: '#ZZ' b: 1 a: '@cssPushRule' g: 'css,items,end' }
75        { s: '#CB' b: 1 a: '@cssPushRule' g: 'css,items,endblock' }
76        { a: '@cssPushRule' r: items g: 'css,items,next' }
77      ]
78    }
79
80    # Builds ONE statement node (resetting from the inherited list node).
81    statement: {
82      open: [
83        # A comment node.
84        { s: '#CC' a: '@cssComment' g: 'css,comment' }
85        # A block at-rule whose body is rules (e.g. @media): push items.
86        { s: '#ATR' a: '@cssAtRules' p: rulesbody g: 'css,atrules' }
87        # A block at-rule whose body is declarations (e.g. @font-face).
88        { s: '#ATD' a: '@cssAtDecls' p: declbody g: 'css,atdecls' }
89        # @keyframes: a body of keyframe blocks.
90        { s: '#ATK' a: '@cssKeyframes' p: kfbody g: 'css,keyframes' }
91        # A statement at-rule (e.g. @import "x"): a leaf node.
92        { s: '#ATS' a: '@cssAtStmt' g: 'css,atstmt' }
93        # A style rule: selectors + declarations.
94        { s: '#TX' b: 1 a: '@cssRule' p: sel g: 'css,rule' }
95      ]
96      close: [
97        { b: 1 g: 'css,statement,end' }
98      ]
99    }
100
101    # Reads a selector group into the (rule/page) node's selectors[], then the
102    # declaration block. Each selector is one #TX; #GC separates a group.
103    sel: {
104      open: [
105        { s: '#TX #GC' a: '@cssSelector' r: sel g: 'css,sel,group' }
106        { s: '#TX #OB' b: 1 a: '@cssSelector' p: declbody g: 'css,sel,last' }
107      ]
108      close: [
109        { b: 1 g: 'css,sel,end' }
110      ]
111    }
112
113    # The "{ ... }" wrapper around a declaration list; fills the parent node's
114    # declarations[].
115    declbody: {
116      open: [
117        { s: '#OB #CB' b: 1 g: 'css,declbody,empty' }
118        { s: '#OB' p: decls g: 'css,declbody' }
119      ]
120      close: [
121        { s: '#CB' a: '@cssEnd' g: 'css,declbody,end' }
122      ]
123    }
124
125    # A declaration list: declaration / comment nodes, ";"-separated.
126    decls: {
127      open: [
128        { s: '#CB' b: 1 g: 'css,decls,empty' }
129        { p: decl g: 'css,decls' }
130      ]
131      close: [
132        { s: '#CA #CB' b: 1 a: '@cssPushDecl' g: 'css,decls,trailing' }
133        { s: '#CB' b: 1 a: '@cssPushDecl' g: 'css,decls,end' }
134        { s: '#CA' a: '@cssPushDecl' r: decls g: 'css,decls,next' }
135        { a: '@cssPushDecl' r: decls g: 'css,decls,comment' }
136      ]
137    }
138
139    # Builds ONE block member: a declaration, a comment, or — for CSS
140    # Nesting — a nested style rule or at-rule. Disambiguated by the token
141    # after the key: #TX #CL is a declaration; #TX followed by "{"/"," is a
142    # nested rule; #ATR/#ATD/#ATK/#ATS is a nested at-rule.
143    decl: {
144      open: [
145        { s: '#CC' a: '@cssComment' g: 'css,comment' }
146        { s: '#TX #CL' a: '@cssDecl' p: declval g: 'css,decl' }
147        # Nested at-rules (CSS Nesting).
148        { s: '#ATR' a: '@cssAtRules' p: rulesbody g: 'css,nest,atrules' }
149        { s: '#ATD' a: '@cssAtDecls' p: declbody g: 'css,nest,atdecls' }
150        { s: '#ATK' a: '@cssKeyframes' p: kfbody g: 'css,nest,keyframes' }
151        { s: '#ATS' a: '@cssAtStmt' g: 'css,nest,atstmt' }
152        # A nested style rule (selector first, no ":").
153        { s: '#TX' b: 1 a: '@cssRule' p: sel g: 'css,nest,rule' }
154      ]
155      close: [
156        { b: 1 g: 'css,decl,end' }
157      ]
158    }
159
160    # The value of a declaration (a single #VL run).
161    declval: {
162      open: [
163        { s: '#VL' a: '@cssDeclVal' g: 'css,declval' }
164        { b: 1 g: 'css,declval,empty' }
165      ]
166      close: [
167        { b: 1 g: 'css,declval,end' }
168      ]
169    }
170
171    # The "{ ... }" rules body of a block at-rule (@media/@supports/...).
172    rulesbody: {
173      open: [
174        { s: '#OB #CB' b: 1 g: 'css,rulesbody,empty' }
175        { s: '#OB' p: items g: 'css,rulesbody' }
176      ]
177      close: [
178        { s: '#CB' a: '@cssEnd' g: 'css,rulesbody,end' }
179      ]
180    }
181
182    # The "{ ... }" body of @keyframes: a list of keyframe blocks.
183    kfbody: {
184      open: [
185        { s: '#OB #CB' b: 1 g: 'css,kfbody,empty' }
186        { s: '#OB' p: kfitems g: 'css,kfbody' }
187      ]
188      close: [
189        { s: '#CB' a: '@cssEnd' g: 'css,kfbody,end' }
190      ]
191    }
192
193    # A list of keyframe blocks (and comments) -> the keyframes node's
194    # keyframes[].
195    kfitems: {
196      open: [
197        { s: '#CB' b: 1 g: 'css,kfitems,empty' }
198        { p: keyframe g: 'css,kfitems' }
199      ]
200      close: [
201        { s: '#CB' b: 1 a: '@cssPushKf' g: 'css,kfitems,end' }
202        { a: '@cssPushKf' r: kfitems g: 'css,kfitems,next' }
203      ]
204    }
205
206    # One keyframe block: values (0%, 50%, from, to) + declarations. Mirrors
207    # "statement"+"sel" but builds a 'keyframe' node with values[].
208    keyframe: {
209      open: [
210        { s: '#CC' a: '@cssComment' g: 'css,comment' }
211        { s: '#TX' b: 1 a: '@cssKeyframe' p: kfsel g: 'css,keyframe' }
212      ]
213      close: [
214        { b: 1 g: 'css,keyframe,end' }
215      ]
216    }
217
218    kfsel: {
219      open: [
220        { s: '#TX #GC' a: '@cssKfValue' r: kfsel g: 'css,kfsel,group' }
221        { s: '#TX #OB' b: 1 a: '@cssKfValue' p: declbody g: 'css,kfsel,last' }
222      ]
223      close: [
224        { b: 1 g: 'css,kfsel,end' }
225      ]
226    }
227  }
228}
229"##;
230// --- END EMBEDDED css-grammar.jsonic ---
231
232/// One alternative of a rule's `open` or `close` list.
233///
234/// The fields are the jsonic alt fields this grammar uses:
235/// `s` (the token sequence to match), `b` (how many of those matched tokens to
236/// push back rather than consume), `p` (push a child rule), `r` (replace this
237/// rule), `a` (actions to run) and `g` (the alt's tag groups).
238#[derive(Clone, Debug, Default)]
239pub struct Alt {
240    /// Token names this alt matches, in order. Empty matches unconditionally.
241    pub s: Vec<String>,
242    /// Tokens to push back — matched, recorded, but left for the next rule.
243    pub b: usize,
244    /// The child rule to push after this alt's actions run.
245    pub p: Option<String>,
246    /// The rule to replace this one with after this alt's actions run.
247    pub r: Option<String>,
248    /// Action references (`@cssXxx`), run in order.
249    pub a: Vec<String>,
250    /// Comma-separated tag groups, for diagnostics.
251    pub g: String,
252}
253
254/// One grammar rule: the alts tried when it opens, and when it closes.
255#[derive(Clone, Debug, Default)]
256pub struct RuleDef {
257    /// Alts tried, in order, when the rule opens.
258    pub open: Vec<Alt>,
259    /// Alts tried, in order, when the rule closes.
260    pub close: Vec<Alt>,
261}
262
263/// The rule table read out of the embedded grammar text (see
264/// [`grammar_text`]).
265#[derive(Clone, Debug, Default)]
266pub struct Grammar {
267    rules: HashMap<String, RuleDef>,
268}
269
270impl Grammar {
271    /// Read the embedded `css-grammar.jsonic`.
272    ///
273    /// # Panics
274    ///
275    /// If the embedded text is malformed. That is a build-time defect in this
276    /// crate, not anything a caller can provoke, and `tests/grammar.rs` fails
277    /// the build before it can ship.
278    pub fn load() -> Grammar {
279        Grammar::parse(GRAMMAR_TEXT).expect("css: embedded grammar is malformed")
280    }
281
282    /// Read a grammar document in the jsonic subset described above.
283    ///
284    /// A key this reader does not implement is an ERROR, at every level.
285    /// The canonical ports hand the whole document to an engine that
286    /// understands the full jsonic grammar surface; this port implements the
287    /// part `css-grammar.jsonic` uses, and the two can only stay in step if
288    /// the grammar growing a field the machine does not run stops the build.
289    /// Ignoring it would leave the Rust port parsing a DIFFERENT grammar from
290    /// the one the other two runtimes run, with every suite green.
291    pub fn parse(text: &str) -> Result<Grammar, String> {
292        let root = Reader::new(text).document()?;
293        unknown(&root, &["rule"], "the grammar document")?;
294        let mut rules = HashMap::new();
295        if let Some(GVal::Map(rule_map)) = root.get("rule") {
296            for (name, def) in rule_map {
297                let GVal::Map(def) = def else { continue };
298                unknown(def, &["open", "close"], &format!("rule {name:?}"))?;
299                rules.insert(
300                    name.clone(),
301                    RuleDef {
302                        open: build_alts(def.get("open"), name, "open")?,
303                        close: build_alts(def.get("close"), name, "close")?,
304                    },
305                );
306            }
307        }
308        Ok(Grammar { rules })
309    }
310
311    /// The rule named `name`, if the grammar defines one.
312    pub fn rule(&self, name: &str) -> Option<&RuleDef> {
313        self.rules.get(name)
314    }
315
316    /// How many rules the grammar defines.
317    pub fn len(&self) -> usize {
318        self.rules.len()
319    }
320
321    /// Whether the grammar defines no rules at all.
322    pub fn is_empty(&self) -> bool {
323        self.rules.is_empty()
324    }
325
326    /// The rule names, unordered.
327    pub fn names(&self) -> impl Iterator<Item = &str> {
328        self.rules.keys().map(String::as_str)
329    }
330}
331
332/// The verbatim `css-grammar.jsonic` text this crate was built from. Exposed
333/// so a test can compare it against the file on disk and catch an embed that
334/// was never re-run.
335pub fn grammar_text() -> &'static str {
336    GRAMMAR_TEXT
337}
338
339/// The alt fields this port implements. A grammar using any other one is
340/// rejected rather than read with that field dropped.
341const ALT_FIELDS: [&str; 6] = ["s", "b", "p", "r", "a", "g"];
342
343fn build_alts(def: Option<&GVal>, rule: &str, phase: &str) -> Result<Vec<Alt>, String> {
344    let Some(GVal::List(items)) = def else {
345        return Ok(Vec::new());
346    };
347    let mut alts = Vec::with_capacity(items.len());
348    for (i, item) in items.iter().enumerate() {
349        let GVal::Map(m) = item else {
350            alts.push(Alt::default());
351            continue;
352        };
353        unknown(m, &ALT_FIELDS, &format!("rule {rule:?} {phase} alt {i}"))?;
354        alts.push(Alt {
355            s: string_list(m.get("s")),
356            // A non-integer `b` is not something this grammar writes; read
357            // it as "push nothing back" rather than failing the build.
358            b: m.get("b").and_then(GVal::as_num).unwrap_or(0.0).max(0.0) as usize,
359            p: m.get("p").and_then(GVal::as_str).map(str::to_string),
360            r: m.get("r").and_then(GVal::as_str).map(str::to_string),
361            a: string_list(m.get("a")),
362            g: m.get("g")
363                .and_then(GVal::as_str)
364                .unwrap_or_default()
365                .to_string(),
366        });
367    }
368    Ok(alts)
369}
370
371/// Reject a key this reader does not implement, naming where it was found.
372fn unknown(map: &GMap, known: &[&str], where_: &str) -> Result<(), String> {
373    for (key, _) in map {
374        if !known.contains(&key.as_str()) {
375            return Err(format!(
376                "{where_}: unknown field {key:?}. This port implements {known:?}; \
377                 a grammar field the rule machine does not run must fail the \
378                 build rather than be read as absent."
379            ));
380        }
381    }
382    Ok(())
383}
384
385/// Read a field that is either one string or a list of them.
386///
387/// `s: '#TX #OB'` is a space-separated sequence, and `a:` may be a single
388/// action or an array of them (`['@reset$' '@cssX']`) — the Go port's
389/// `buildGrammarAlts` handles the same two shapes.
390fn string_list(def: Option<&GVal>) -> Vec<String> {
391    match def {
392        Some(GVal::Str(s)) => s.split_whitespace().map(str::to_string).collect(),
393        Some(GVal::List(items)) => items
394            .iter()
395            .filter_map(GVal::as_str)
396            .map(str::to_string)
397            .collect(),
398        _ => Vec::new(),
399    }
400}
401
402// --- The jsonic-subset reader ---------------------------------------------
403
404/// A value in a grammar document. An insertion-ordered map keeps the alt lists
405/// in source order, which is the order they are tried in.
406#[derive(Clone, Debug, PartialEq)]
407enum GVal {
408    Str(String),
409    Num(f64),
410    Bool(bool),
411    Map(GMap),
412    List(Vec<GVal>),
413}
414
415impl GVal {
416    fn as_str(&self) -> Option<&str> {
417        match self {
418            GVal::Str(s) => Some(s),
419            _ => None,
420        }
421    }
422    fn as_num(&self) -> Option<f64> {
423        match self {
424            GVal::Num(n) => Some(*n),
425            _ => None,
426        }
427    }
428}
429
430/// An insertion-ordered string map.
431#[derive(Clone, Debug, Default, PartialEq)]
432struct GMap(Vec<(String, GVal)>);
433
434impl GMap {
435    fn get(&self, key: &str) -> Option<&GVal> {
436        self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
437    }
438}
439
440impl<'a> IntoIterator for &'a GMap {
441    type Item = (&'a String, &'a GVal);
442    type IntoIter = std::iter::Map<
443        std::slice::Iter<'a, (String, GVal)>,
444        fn(&'a (String, GVal)) -> (&'a String, &'a GVal),
445    >;
446    fn into_iter(self) -> Self::IntoIter {
447        self.0.iter().map(|(k, v)| (k, v))
448    }
449}
450
451struct Reader<'a> {
452    src: &'a [u8],
453    i: usize,
454}
455
456impl<'a> Reader<'a> {
457    fn new(text: &'a str) -> Reader<'a> {
458        Reader {
459            src: text.as_bytes(),
460            i: 0,
461        }
462    }
463
464    /// The whole document: one value, then end of input.
465    fn document(mut self) -> Result<GMap, String> {
466        self.skip_space();
467        let v = self.value()?;
468        self.skip_space();
469        if self.i < self.src.len() {
470            return Err(format!("trailing content at byte {}", self.i));
471        }
472        match v {
473            GVal::Map(m) => Ok(m),
474            other => Err(format!("grammar is not a map: {other:?}")),
475        }
476    }
477
478    /// Skip whitespace, `,` separators (optional in this dialect) and `#`
479    /// line comments.
480    fn skip_space(&mut self) {
481        while self.i < self.src.len() {
482            match self.src[self.i] {
483                b' ' | b'\t' | b'\r' | b'\n' | b',' => self.i += 1,
484                b'#' => {
485                    while self.i < self.src.len() && self.src[self.i] != b'\n' {
486                        self.i += 1;
487                    }
488                }
489                _ => return,
490            }
491        }
492    }
493
494    fn value(&mut self) -> Result<GVal, String> {
495        self.skip_space();
496        match self.src.get(self.i) {
497            None => Err("unexpected end of grammar".to_string()),
498            Some(b'{') => self.map(),
499            Some(b'[') => self.list(),
500            Some(b'\'') | Some(b'"') => Ok(GVal::Str(self.quoted()?)),
501            Some(_) => Ok(scalar(self.bare())),
502        }
503    }
504
505    fn map(&mut self) -> Result<GVal, String> {
506        self.i += 1; // '{'
507        let mut out = GMap::default();
508        loop {
509            self.skip_space();
510            match self.src.get(self.i) {
511                None => return Err("unclosed '{' in grammar".to_string()),
512                Some(b'}') => {
513                    self.i += 1;
514                    return Ok(GVal::Map(out));
515                }
516                _ => {}
517            }
518            let key = match self.src[self.i] {
519                b'\'' | b'"' => self.quoted()?,
520                _ => self.bare(),
521            };
522            self.skip_space();
523            if self.src.get(self.i) != Some(&b':') {
524                return Err(format!("expected ':' after key '{key}' in grammar"));
525            }
526            self.i += 1;
527            let value = self.value()?;
528            out.0.push((key, value));
529        }
530    }
531
532    fn list(&mut self) -> Result<GVal, String> {
533        self.i += 1; // '['
534        let mut out = Vec::new();
535        loop {
536            self.skip_space();
537            match self.src.get(self.i) {
538                None => return Err("unclosed '[' in grammar".to_string()),
539                Some(b']') => {
540                    self.i += 1;
541                    return Ok(GVal::List(out));
542                }
543                _ => out.push(self.value()?),
544            }
545        }
546    }
547
548    /// A `'...'` or `"..."` string. A backslash escapes the next character.
549    fn quoted(&mut self) -> Result<String, String> {
550        let quote = self.src[self.i];
551        self.i += 1;
552        let mut out = String::new();
553        while self.i < self.src.len() {
554            let c = self.src[self.i];
555            if c == quote {
556                self.i += 1;
557                return Ok(out);
558            }
559            if c == b'\\' && self.i + 1 < self.src.len() {
560                self.i += 1;
561            }
562            let next = next_char_end(self.src, self.i);
563            out.push_str(&lossy(&self.src[self.i..next]));
564            self.i = next;
565        }
566        Err("unclosed string in grammar".to_string())
567    }
568
569    /// An unquoted token, up to the next structural character.
570    fn bare(&mut self) -> String {
571        let start = self.i;
572        while self.i < self.src.len() {
573            match self.src[self.i] {
574                b' ' | b'\t' | b'\r' | b'\n' | b',' | b':' | b'{' | b'}' | b'[' | b']' | b'#' => {
575                    break
576                }
577                _ => self.i += 1,
578            }
579        }
580        lossy(&self.src[start..self.i])
581    }
582}
583
584/// Classify a bare token: `true`/`false`, a number, or a string.
585fn scalar(token: String) -> GVal {
586    match token.as_str() {
587        "true" => return GVal::Bool(true),
588        "false" => return GVal::Bool(false),
589        _ => {}
590    }
591    match token.parse::<f64>() {
592        Ok(n) => GVal::Num(n),
593        Err(_) => GVal::Str(token),
594    }
595}
596
597/// The end of the UTF-8 character starting at `i`.
598fn next_char_end(src: &[u8], i: usize) -> usize {
599    let mut e = i + 1;
600    while e < src.len() && (src[e] & 0xC0) == 0x80 {
601        e += 1;
602    }
603    e
604}
605
606fn lossy(bytes: &[u8]) -> String {
607    String::from_utf8_lossy(bytes).into_owned()
608}