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 option
4//! overrides the plugin installs with it.
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//! All three ports read that text the same way: with jsonic, on the engine,
13//! into the rule set the engine then runs. The options beside it are
14//! `grammarDef.options` in `ts/src/css.ts`, written out as JSON.
15
16use std::sync::OnceLock;
17
18use tabnas::GrammarSpec;
19
20use crate::value::Value;
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/// The embedded `css-grammar.jsonic` text, exactly as the file on disk holds
233/// it. `tests/grammar.rs` checks the two are the same.
234pub fn grammar_text() -> &'static str {
235    GRAMMAR_TEXT
236}
237
238/// The option overrides the grammar installs with: `grammarDef.options` in
239/// `ts/src/css.ts`, as a grammar document.
240///
241/// - jsonic's own rules are excluded (implicit maps and lists, top-level
242///   commas, path dives) and `stylesheet` is the start rule.
243/// - `;` is the member-separator token `#CA`, and `[` `]` are not tokens:
244///   they only appear inside selectors and values, which the `cssToken`
245///   matcher reads as text.
246/// - `KEY` names `#TX`, as the canonical options do. The option merge
247///   replaces an array by index, so the live set keeps its other entries;
248///   no alternate here reads it.
249/// - The string, number, text and keyword-value matchers are off: the
250///   `cssToken` matcher owns all non-fixed text.
251/// - Only `/* */` comments exist, and the builtin matcher skips one only
252///   where `cssToken` declines it (away from a list position).
253/// - An exactly empty source is an empty stylesheet, as in reworkcss: the
254///   engine returns `emptyResult` for `""` before any rule runs.
255/// - `cssToken` runs at order 1e5, ahead of every builtin matcher, and
256///   `@css-prepare` clears the plugin's per-parse state before each parse.
257/// - `rule.history` is 1, which the canonical options do not set: a rule
258///   keeps a link to the one it replaced, and to no further back. Unbounded,
259///   the engine's default, every item of a list stays reachable until the
260///   list closes, which held a flat stylesheet of 100,000 rules at 927 MiB
261///   where the bound holds it at 210 MiB (`tests/memory.rs`). No alternate
262///   here reads `prev`, so no result changes.
263///
264/// The raw-string delimiter is `##` because `"#CA"` would end `r#"…"#`.
265pub(crate) const OPTIONS_DOC: &str = r##"{"options": {
266  "rule": {"exclude": "jsonic,imp", "start": "stylesheet", "history": 1},
267  "fixed": {"token": {"#CA": ";", "#OS": null, "#CS": null}},
268  "tokenSet": {"KEY": ["#TX"]},
269  "string": {"chars": ""},
270  "number": {"lex": false},
271  "text": {"lex": false},
272  "value": {"lex": false},
273  "comment": {"lex": true, "def": {
274    "hash": {"lex": false},
275    "slash": {"lex": false},
276    "multi": {"line": false, "start": "/*", "end": "*/", "lex": true}}},
277  "lex": {
278    "emptyResult": {"type": "stylesheet", "rules": []},
279    "match": {"cssToken": {"order": 100000, "make": "@css-token"}}
280  },
281  "parse": {"prepare": {"css": "@css-prepare"}}
282}}"##;
283
284/// The rules `css-grammar.jsonic` defines, in the order it defines them.
285/// A second install of the plugin removes these before installing them
286/// again, since installing a rule that exists puts the new alternates in
287/// front of the old ones rather than replacing them.
288pub(crate) const RULES: [&str; 13] = [
289    "stylesheet",
290    "items",
291    "statement",
292    "sel",
293    "declbody",
294    "decls",
295    "decl",
296    "declval",
297    "rulesbody",
298    "kfbody",
299    "kfitems",
300    "keyframe",
301    "kfsel",
302];
303
304/// The two grammar documents the plugin installs, read once per process:
305/// the options, then the rules.
306pub(crate) struct Specs {
307    pub(crate) options: GrammarSpec,
308    pub(crate) rules: GrammarSpec,
309}
310
311/// Read the grammar text with jsonic, as `new Tabnas().use(jsonic)` does in
312/// the canonical port, and hand the engine the result as JSON.
313///
314/// The JSON is written by this crate's own writer rather than the engine's:
315/// jsonic reads every number as an `f64`, the engine's writer prints `1` as
316/// `1.0`, and the loader wants `b: 1`, an integer. The embedded text is this
317/// crate's own and shallow, so reading it is not a depth risk.
318pub(crate) fn specs() -> Result<&'static Specs, String> {
319    static SPECS: OnceLock<Result<Specs, String>> = OnceLock::new();
320    SPECS
321        .get_or_init(|| {
322            let options = GrammarSpec::from_json(OPTIONS_DOC).map_err(|e| e.0)?;
323            let parsed = tabnas_jsonic::make()
324                .parse(GRAMMAR_TEXT)
325                .map_err(|e| format!("css-grammar.jsonic does not read as jsonic: {e}"))?;
326            let rules =
327                GrammarSpec::from_json(&Value::from_engine(&parsed).to_json()).map_err(|e| e.0)?;
328            Ok(Specs { options, rules })
329        })
330        .as_ref()
331        .map_err(Clone::clone)
332}