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.15";
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}