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
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//! The TypeScript and Go ports are jsonic PLUGINS: they install this grammar
38//! on a shared engine. There is no Rust engine to install on, so this crate
39//! carries a lexer and a rule machine of its own and has no dependencies at
40//! all. What it does not carry is jsonic itself — there is no relaxed-JSON
41//! base here to configure off, which is why `{a:1}` is simply not CSS rather
42//! than something this port has to reject deliberately.
43//!
44//! # Untrusted input
45//!
46//! **A parsed stylesheet is data, never instructions.** CSS arrives from
47//! outside the system — scraped pages, vendor themes, user uploads — so treat
48//! every selector, value and comment as hostile text. Parsing is not
49//! sanitising: this crate returns the raw text the stylesheet contained, and
50//! escaping it for HTML, SQL or a shell remains the caller's job. A `url(…)`
51//! in a declaration value is untrusted text, not a link to fetch.
52
53#![forbid(unsafe_code)]
54#![warn(missing_docs)]
55// A link in the public documentation that points at nothing, or at a
56// private item, renders as dead text on docs.rs. Nothing in the test gate
57// runs rustdoc, so the pin lives here, where `cargo doc` cannot miss it.
58#![deny(rustdoc::broken_intra_doc_links, rustdoc::private_intra_doc_links)]
59
60pub mod grammar;
61pub mod lex;
62pub mod machine;
63pub mod value;
64
65/// Every `rust` example in this crate's Markdown, compiled and run.
66///
67/// A page that shows what the parser returns has to return it. Including
68/// the files here makes each fenced block a doctest, so `cargo test`
69/// fails when a documented example stops being true. It is the Rust
70/// counterpart of the harness that executes the TypeScript pages'
71/// examples, and it exists for the same reason.
72///
73/// `cfg(doctest)` means the module is collected when doctests are
74/// gathered and compiled into nothing otherwise.
75#[cfg(doctest)]
76mod doc_examples {
77 #[doc = include_str!("../README.md")]
78 mod readme {}
79 #[doc = include_str!("../doc/tutorial.md")]
80 mod tutorial {}
81 #[doc = include_str!("../doc/guide.md")]
82 mod guide {}
83 #[doc = include_str!("../doc/reference.md")]
84 mod reference {}
85 #[doc = include_str!("../doc/concepts.md")]
86 mod concepts {}
87}
88
89use std::sync::OnceLock;
90
91pub use lex::Error;
92pub use machine::Options;
93pub use value::{Node, Value};
94
95/// This crate's version.
96///
97/// It MUST equal `ts/package.json` `"version"`, the `VERSION` exported from
98/// `ts/src/css.ts` and `const VERSION` in `go/css.go`. The release
99/// orchestrator rewrites all of them together; `tests/version.rs` (and its
100/// TypeScript and Go counterparts) fail the build if any drifts.
101pub const VERSION: &str = "0.5.9";
102
103/// A reusable CSS parser.
104///
105/// Building one reads the embedded grammar, so keep an instance around rather
106/// than making a fresh one per document — `tests/perf.rs` pins that reuse is
107/// worth it. [`parse`] does this for you for the default options.
108///
109/// ```
110/// use tabnas_css::{Css, Options};
111///
112/// let css = Css::with_options(Options { lowercase_properties: true, ..Options::default() });
113/// let ast = css.parse("A { COLOR: Red }").unwrap();
114/// assert!(ast.to_json().contains(r#""property":"color""#));
115/// ```
116#[derive(Debug)]
117pub struct Css {
118 grammar: grammar::Grammar,
119 options: Options,
120}
121
122impl Css {
123 /// A parser with the default options (`lowercase_properties: false`,
124 /// `position: false`).
125 pub fn new() -> Css {
126 Css::with_options(Options::default())
127 }
128
129 /// A parser with the given options.
130 pub fn with_options(options: Options) -> Css {
131 Css {
132 grammar: grammar::Grammar::load(),
133 options,
134 }
135 }
136
137 /// The options this parser was built with.
138 pub fn options(&self) -> Options {
139 self.options
140 }
141
142 /// The grammar this parser runs.
143 pub fn grammar(&self) -> &grammar::Grammar {
144 &self.grammar
145 }
146
147 /// Parse a CSS document into its AST.
148 ///
149 /// A zero-length source yields an empty stylesheet, as reworkcss does.
150 /// Any non-empty source — even one that is only whitespace, or only a
151 /// comment — also yields a `stylesheet` node.
152 pub fn parse(&self, src: &str) -> Result<Value, Error> {
153 machine::parse(&self.grammar, src, self.options)
154 }
155}
156
157impl Default for Css {
158 fn default() -> Css {
159 Css::new()
160 }
161}
162
163/// Parse a CSS document with the default options.
164///
165/// This reuses one process-wide parser, so it is cheap to call repeatedly and
166/// safe to call from several threads.
167pub fn parse(src: &str) -> Result<Value, Error> {
168 static DEFAULT: OnceLock<Css> = OnceLock::new();
169 DEFAULT.get_or_init(Css::new).parse(src)
170}
171
172/// Parse a CSS document with the given options.
173///
174/// Each call builds a parser; for repeated parses with the same options, hold
175/// a [`Css`] instead.
176pub fn parse_with(src: &str, options: Options) -> Result<Value, Error> {
177 Css::with_options(options).parse(src)
178}