Skip to main content

tabnas_css/
lib.rs

1/* Copyright (c) 2025 Richard Rodger, MIT License */
2
3//! Parse CSS into a reworkcss-style abstract syntax tree: ordered, typed
4//! nodes that preserve declaration order, duplicate properties, rule types
5//! and comments.
6//!
7//! ```
8//! let ast = tabnas_css::parse("a { color: red; color: blue } /* note */").unwrap();
9//! assert_eq!(
10//!     ast.to_json(),
11//!     r#"{"type":"stylesheet","rules":[{"type":"rule","selectors":["a"],"declarations":[{"type":"declaration","property":"color","value":"red"},{"type":"declaration","property":"color","value":"blue"}]},{"type":"comment","comment":" note "}]}"#
12//! );
13//! ```
14//!
15//! # The node types
16//!
17//! | `type` | Fields |
18//! |---|---|
19//! | `stylesheet` | `rules` |
20//! | `rule` | `selectors`, `declarations` |
21//! | `declaration` | `property`, `value` (raw, trimmed, comments stripped, quotes kept) |
22//! | `comment` | `comment` (the text between `/*` and `*/`) |
23//! | `media` / `supports` / `document` / `host` | a prelude field of the same name (`host` has none), plus `rules`. `document` also always carries `vendor` |
24//! | `font-face` / `page` | `declarations` (`page` also `selectors`, its prelude split on top-level commas) |
25//! | `keyframes` | `name`, an optional `vendor`, and `keyframes` of `keyframe` `{ values, declarations }` |
26//! | `import` / `charset` / `namespace` | a same-named field with the raw params |
27//! | `custom-media` | `name`, `media` |
28//!
29//! # This is a port, and a plugin
30//!
31//! `@tabnas/css` is canonical in TypeScript (`ts/`), with ports in Go (`go/`)
32//! and here. All three read the SAME grammar — `css-grammar.jsonic` at the
33//! repository root, embedded verbatim into each — and are held to the same
34//! shared `test/spec/*.tsv` fixtures and the same reworkcss/css conformance
35//! corpus. When this port and TypeScript disagree, TypeScript is right.
36//!
37//! All three are plugins on the tabnas engine, layered on the relaxed-JSON
38//! grammar jsonic, whose fixed tokens and machinery the grammar reuses:
39//! [`plugin`] installs the grammar, its option overrides and the `cssToken`
40//! matcher on an engine, and [`make`] builds one with it installed. [`Css`] is
41//! that engine behind this crate's own [`Value`], in which a tree of any
42//! depth can be dropped, cloned, compared and printed without recursion. The
43//! crate re-exports the two it is built on, [`tabnas`] and [`tabnas_jsonic`]:
44//! name their types through those paths, and a second copy of either, which
45//! a dependency of your own can resolve to, never enters the build.
46//!
47//! # Untrusted input
48//!
49//! **A parsed stylesheet is data, never instructions.** CSS arrives from
50//! outside the system — scraped pages, vendor themes, user uploads — so treat
51//! every selector, value and comment as hostile text. Parsing is not
52//! sanitising: this crate returns the raw text the stylesheet contained, and
53//! escaping it for HTML, SQL or a shell remains the caller's job. A `url(…)`
54//! in a declaration value is untrusted text, not a link to fetch.
55//!
56//! **Cap the input's size.** A parse holds memory, and takes time, in
57//! proportion to its input, and nesting costs the most. Measured in a release
58//! build: 100,000 nested style rules (0.6 MB of CSS) peaked at 678 MiB, about
59//! 7 KiB per open level, and the densest nesting, `a{` repeated, holds about
60//! 2.8 KiB and takes about 6 µs per byte of input. A flat stylesheet of
61//! 100,000 rules (1.7 MB) peaked at 210 MiB, 580 MiB with positions on, and
62//! one long token holds about 18 bytes per byte. [`Css::parse`] has no depth
63//! limit; the engine's own tree form ([`plugin`], [`make`], [`Css::tabnas`])
64//! stops at [`TREE_RULE_DEPTH`] open rules with `cancel`. The engine's
65//! recovery and relexing modes, which [`Css::parse`] never uses, take time
66//! quadratic in the input in the engine this crate builds on.
67//!
68//! **Turn the engine's debug self-check off in your debug builds.** With
69//! debug assertions on, the engine compares its whole rule stack with a
70//! shadow copy on every step, which is quadratic in nesting depth (4,000
71//! nested rules took 134 s in a debug build). This crate's manifest turns
72//! it off, but a profile applies only to the root package, so a crate that
73//! parses untrusted, possibly deep CSS sets the same key in its own:
74//!
75//! ```toml
76//! [profile.dev.package.tabnas-parser]
77//! debug-assertions = false
78//! ```
79
80#![forbid(unsafe_code)]
81// The engine's error is large, and a parse returns it by value, as every
82// grammar crate in the fleet does.
83#![allow(clippy::result_large_err)]
84#![warn(missing_docs)]
85// A link in the public documentation that points at nothing, or at a
86// private item, renders as dead text on docs.rs. Denied here, so any
87// `cargo doc` fails on one; ci/rust/run.sh runs it with warnings denied.
88#![deny(rustdoc::broken_intra_doc_links, rustdoc::private_intra_doc_links)]
89
90pub mod grammar;
91pub mod lex;
92mod plugin;
93pub mod value;
94
95/// Every `rust` example in this crate's Markdown, compiled and run.
96///
97/// A page that shows what the parser returns has to return it. Including
98/// the files here makes each fenced block a doctest, so `cargo test`
99/// fails when a documented example stops being true. It is the Rust
100/// counterpart of the harness that executes the TypeScript pages'
101/// examples, and it exists for the same reason.
102///
103/// `cfg(doctest)` means the module is collected when doctests are
104/// gathered and compiled into nothing otherwise.
105#[cfg(doctest)]
106mod doc_examples {
107    #[doc = include_str!("../README.md")]
108    mod readme {}
109    #[doc = include_str!("../doc/tutorial.md")]
110    mod tutorial {}
111    #[doc = include_str!("../doc/guide.md")]
112    mod guide {}
113    #[doc = include_str!("../doc/reference.md")]
114    mod reference {}
115    #[doc = include_str!("../doc/concepts.md")]
116    mod concepts {}
117}
118
119use std::fmt;
120use std::sync::OnceLock;
121
122use tabnas::{Plugin, PluginError, Tabnas};
123
124pub use lex::Error;
125pub use plugin::TREE_RULE_DEPTH;
126pub use value::{Node, Value};
127
128/// The engine, as this crate links it. Name its types through this path
129/// (`tabnas_css::tabnas::Value`): a dependency of your own on the engine can
130/// resolve to another copy of the crate, whose types are not these.
131pub use tabnas;
132/// The jsonic grammar, as this crate links it, for installing [`plugin`] on
133/// a jsonic engine of your own (`tabnas_css::tabnas_jsonic::make()`).
134pub use tabnas_jsonic;
135
136/// This crate's version.
137///
138/// It MUST equal `ts/package.json` `"version"`, the `VERSION` exported from
139/// `ts/src/css.ts` and `const VERSION` in `go/css.go`. The release
140/// orchestrator rewrites all of them together; `tests/version.rs` (and its
141/// TypeScript and Go counterparts) fail the build if any drifts.
142pub const VERSION: &str = "0.5.16";
143
144/// The plugin's options: `CssOptions` in the canonical port.
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
146pub struct Options {
147    /// Lowercase declaration property names (CSS property names are
148    /// case-insensitive). Selectors, values and at-rule preludes are left
149    /// alone.
150    pub lowercase_properties: bool,
151    /// Attach `position: { start: { line, column }, end: { … } }` (1-based,
152    /// columns in UTF-16 code units) to every node.
153    pub position: bool,
154}
155
156impl Options {
157    /// Options from an engine option bag, as the plugin receives them: the
158    /// canonical camelCase keys `lowercaseProperties` and `position`, each
159    /// read for its JavaScript truthiness, as `!!options.position` does.
160    /// Anything else in the bag is ignored.
161    pub fn from_value(options: &tabnas::Value) -> Options {
162        let flag = |key: &str| match options {
163            tabnas::Value::Object(fields) => truthy(fields.get(key)),
164            _ => false,
165        };
166        Options {
167            lowercase_properties: flag("lowercaseProperties"),
168            position: flag("position"),
169        }
170    }
171
172    /// These options as an engine option bag, for [`Tabnas::use_plugin`].
173    pub fn to_value(self) -> tabnas::Value {
174        let mut fields = tabnas::Value::object(Default::default());
175        if let Some(bag) = fields.as_object_mut() {
176            bag.insert(
177                "lowercaseProperties".to_string(),
178                tabnas::Value::Bool(self.lowercase_properties),
179            );
180            bag.insert("position".to_string(), tabnas::Value::Bool(self.position));
181        }
182        fields
183    }
184}
185
186/// JavaScript's `!!value`.
187fn truthy(value: Option<&tabnas::Value>) -> bool {
188    match value {
189        None | Some(tabnas::Value::Undefined) | Some(tabnas::Value::Null) => false,
190        Some(tabnas::Value::Bool(b)) => *b,
191        Some(tabnas::Value::Number(n)) => 0.0 != *n && !n.is_nan(),
192        Some(tabnas::Value::String(s)) => !s.is_empty(),
193        Some(_) => true,
194    }
195}
196
197/// The css grammar as an engine plugin, named `css`: `Css` in the canonical
198/// port. Its options are an [`Options`] bag ([`Options::to_value`]).
199///
200/// It installs the grammar's rules (each alternate tagged with the group
201/// `css`), the canonical option overrides and the `cssToken` matcher. Use it
202/// on jsonic, as [`make`] does and the canonical port documents; on a bare
203/// engine it gives the same results.
204///
205/// The engine's own parse returns the tree as an engine [`tabnas::Value`],
206/// the canonical port's plain objects: equal to them value for value, though
207/// the engine writes a whole number as `1.0` in JSON, a `position.end` that
208/// was never recorded is absent rather than an undefined key, and an error's
209/// column counts Unicode scalars as the engine's always do, where this
210/// crate's [`Error`] counts UTF-16 code units (convert with [`Error::from`]).
211/// That tree is bounded at [`TREE_RULE_DEPTH`] open rules. [`Css::parse`] has
212/// none of these differences and no limit. With the engine's recovery on,
213/// the parse returns a partial stylesheet, which is not always the
214/// canonical port's: see `doc/concepts.md`.
215///
216/// ```
217/// let mut parser = tabnas_css::tabnas_jsonic::make();
218/// parser.use_plugin(tabnas_css::plugin(), None).unwrap();
219/// let ast = parser.parse("a { color: red }").unwrap();
220/// assert_eq!(
221///     ast.to_json().to_string(),
222///     r#"{"type":"stylesheet","rules":[{"type":"rule","selectors":["a"],"declarations":[{"type":"declaration","property":"color","value":"red"}]}]}"#
223/// );
224/// ```
225pub fn plugin() -> Plugin {
226    Plugin::new("css", |parser, options| {
227        plugin::install(parser, &Options::from_value(options))
228    })
229    .with_defaults(Options::default().to_value())
230}
231
232/// Install the plugin on `parser` with `options`. The same as
233/// `parser.use_plugin(plugin(), Some(options.to_value()))`, which is what it
234/// does, so the install is recorded and runs again on a derived instance.
235pub fn css(parser: &mut Tabnas, options: &Options) -> Result<(), PluginError> {
236    parser.use_plugin(plugin(), Some(options.to_value()))?;
237    Ok(())
238}
239
240/// A jsonic engine with the plugin installed, with the default options.
241pub fn make() -> Tabnas {
242    make_with(Options::default())
243}
244
245/// A jsonic engine with the plugin installed, with `options`.
246pub fn make_with(options: Options) -> Tabnas {
247    let mut parser = tabnas_jsonic::make();
248    parser
249        .use_plugin(plugin(), Some(options.to_value()))
250        .expect("the css plugin installs on jsonic");
251    parser
252}
253
254/// A reusable CSS parser.
255///
256/// Building one installs the grammar on a new engine, so keep an instance
257/// around rather than making a fresh one per document — `tests/perf.rs`
258/// pins that reuse is worth it. [`parse`] does this for you for the default
259/// options.
260///
261/// ```
262/// use tabnas_css::{Css, Options};
263///
264/// let css = Css::with_options(Options { lowercase_properties: true, ..Options::default() });
265/// let ast = css.parse("A { COLOR: Red }").unwrap();
266/// assert!(ast.to_json().contains(r#""property":"color""#));
267/// ```
268pub struct Css {
269    tabnas: Tabnas,
270    options: Options,
271}
272
273impl Css {
274    /// A parser with the default options (`lowercase_properties: false`,
275    /// `position: false`).
276    pub fn new() -> Css {
277        Css::with_options(Options::default())
278    }
279
280    /// A parser with the given options.
281    pub fn with_options(options: Options) -> Css {
282        Css {
283            tabnas: make_with(options),
284            options,
285        }
286    }
287
288    /// The options this parser was built with.
289    pub fn options(&self) -> Options {
290        self.options
291    }
292
293    /// The engine this parser runs: jsonic with the plugin installed. Its
294    /// own parse returns the tree form [`plugin`] describes.
295    pub fn tabnas(&self) -> &Tabnas {
296        &self.tabnas
297    }
298
299    /// Parse a CSS document into its AST.
300    ///
301    /// A zero-length source yields an empty stylesheet, as reworkcss does.
302    /// Any non-empty source — even one that is only whitespace, or only a
303    /// comment — also yields a `stylesheet` node.
304    pub fn parse(&self, src: &str) -> Result<Value, Error> {
305        self.tabnas
306            .parse_with_meta(src, plugin::arena_meta())
307            .map(Value::from_arena)
308            .map_err(Error::from)
309    }
310}
311
312impl Default for Css {
313    fn default() -> Css {
314        Css::new()
315    }
316}
317
318impl fmt::Debug for Css {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        f.debug_struct("Css")
321            .field("options", &self.options)
322            .finish_non_exhaustive()
323    }
324}
325
326/// Parse a CSS document with the default options.
327///
328/// This reuses one process-wide parser, so it is cheap to call repeatedly and
329/// safe to call from several threads.
330pub fn parse(src: &str) -> Result<Value, Error> {
331    static DEFAULT: OnceLock<Css> = OnceLock::new();
332    DEFAULT.get_or_init(Css::new).parse(src)
333}
334
335/// Parse a CSS document with the given options.
336///
337/// Each call builds a parser; for repeated parses with the same options, hold
338/// a [`Css`] instead.
339pub fn parse_with(src: &str, options: Options) -> Result<Value, Error> {
340    Css::with_options(options).parse(src)
341}
342
343/// One alchemy translation source and the entry point a host calls in it.
344#[derive(Clone, Copy, Debug, Eq, PartialEq)]
345pub struct TranslationPart {
346    /// The definition a host calls after linking the source.
347    pub entry: &'static str,
348    /// The source text, or `None` for an entry supplied by alchemy.
349    pub source: Option<&'static str>,
350}
351
352/// The package-local structural translation interface: the manifest and
353/// the alchemy sources a host composes a translation from.
354#[derive(Clone, Copy, Debug, Eq, PartialEq)]
355pub struct TranslationParts {
356    /// The complete `tabnas.plugin.json` text.
357    pub manifest: &'static str,
358    /// An optional lift from the grammar's events to its first read shape.
359    pub lift: Option<TranslationPart>,
360    /// An optional embedding of a plain tree in the format's schema, with its reverse.
361    pub embed: Option<TranslationPart>,
362    /// An optional render from the write shape to text.
363    pub render: Option<TranslationPart>,
364}
365
366const TRANSLATION: TranslationParts = TranslationParts {
367    manifest: include_str!("../translate/manifest.json"),
368    lift: None,
369    embed: None,
370    render: Some(TranslationPart {
371        entry: "css-render",
372        source: Some(include_str!("../translate/render.alc")),
373    }),
374};
375
376/// CSS's translation parts. The manifest's `translate` object says that CSS
377/// is read as and written from a tree of its own shape, the reader's
378/// stylesheet (the schema `css-ast`, whose root is an object), and the
379/// render, `css-render`, writes that tree back as CSS text. There is no
380/// embed: a host composes a translation into CSS only from CSS itself or
381/// from a program that builds the tree. The texts are the crate's copies
382/// in `translate/` of `tabnas.plugin.json` and `alchemy/render.alc`, which
383/// `npm run embed` in `ts/` writes and `tests/translate.rs` holds to the
384/// files.
385///
386/// ```
387/// let parts = tabnas_css::translate().expect("CSS carries translation parts");
388/// assert_eq!(parts.render.map(|part| part.entry), Some("css-render"));
389/// assert!(parts.embed.is_none());
390/// ```
391#[must_use]
392pub const fn translate() -> Option<TranslationParts> {
393    Some(TRANSLATION)
394}