css (Rust)
A parser that reads CSS
into a faithful abstract syntax tree (the
reworkcss/css model): ordered, typed
nodes that preserve declaration order, duplicate properties, rule types, and
comments.
This is the Rust port of @tabnas/css. The TypeScript package is canonical,
the Go module tracks it, and so does this crate. All three are plugins on
the tabnas engine, read the same grammar file and are held to the same
shared conformance fixtures, so the same stylesheet gives the same tree in
every one.
Where a port does differ, the difference is a row of
../test/divergent.tsv with a cell per runtime, and
each suite asserts its own cell. There are five rows. Four are Go against
TypeScript, and this crate follows TypeScript on every one, including the
column arithmetic it would be tidier to round off. The fifth is this crate's
own: the engine's tree form (see the engine's own API)
refuses nesting deeper than TREE_RULE_DEPTH open rules, where TypeScript
and Go have no limit. parse and Css::parse have no limit either.
Install
The crate is published on crates.io, where it depends on the engine,
tabnas-parser, and on tabnas-jsonic:
It re-exports both, as tabnas_css::tabnas and tabnas_css::tabnas_jsonic,
so the examples on this page need no other entry. Version 0.5.9 on crates.io
is a different implementation: it has no dependencies, and none of the plugin
API this page describes.
The Cargo.toml in this repository differs from the published one. Its own
dependencies are named by relative path to sibling checkouts
(path = "../../parser/rs"), so a build here needs tabnas/parser,
tabnas/json, tabnas/jsonic and, for the tests, tabnas/debug to be
cloned beside it. The release workflow swaps those paths for crates.io
versions when it publishes the crate. A crate taken from git does not get
that swap.
The crate needs Rust 1.85 or later, the engine's own floor.
One example
parse is the one-call entry point: pass source, get the AST and an error:
let ast = parse.unwrap;
assert_eq!;
Every node is a Node, an insertion-ordered map with a type key. Fields
such as rules, declarations and selectors are Value::List. Read them
with Node::get and the Value accessors, or call Value::to_json and hand
the text to whatever consumes it:
use Value;
let ast = parse.unwrap;
let rule = ast.as_node.unwrap
.get.and_then.unwrap
.as_node.unwrap;
assert_eq!;
parse reuses one process-wide parser and is safe to call from several
threads. Building a parser installs the grammar on a new engine, which costs
more than a parse does, so a hot loop that needs options should hold a Css
rather than call parse_with each time.
Options
Options has two fields, both false by default:
lowercase_properties. Normalise property names to lower case. Selectors and values are left untouched.position. Attach aposition(1-basedstartandend, each a line and a column) to every node.
use ;
let css = with_options;
let ast = css.parse.unwrap;
// Every node gains a position; the stylesheet's spans the source.
assert!;
Columns count UTF-16 code units, which is what a JavaScript string index counts, so a column here is the column the canonical port reports for the same source.
CSS nesting
A style rule or an at-rule may appear inside another style rule's declaration
block. Nested nodes are appended to the parent's declarations in source
order, interleaved with declarations:
let ast = parse.unwrap;
// The rule's declarations: a declaration, then the nested rule.
assert_eq!;
A plugin on the tabnas engine
The TypeScript and Go ports install the grammar on the tabnas engine, layered
on the relaxed-JSON grammar jsonic, and this crate does the same in Rust: it
runs on tabnas-parser (imported as tabnas) and tabnas-jsonic, and
re-exports both, as tabnas_css::tabnas and tabnas_css::tabnas_jsonic, so
that a caller names their types without a second copy of either. jsonic
reads css-grammar.jsonic, and the plugin installs its rules with the
canonical option overrides and the cssToken lex matcher. Those overrides
switch jsonic's own rules and value matchers off, so {a:1} is refused here
as it is in the other two ports.
The engine's own API
plugin() is the grammar as an engine plugin, make() builds a jsonic
engine with it installed, and Css::tabnas() is the engine a Css runs.
Their parse returns the engine's own tabnas::Value, the same tree as the
canonical port's plain objects, with these differences from Css::parse:
- the engine writes a whole number, such as a line in a
position, as1.0in JSON; - an engine error's column counts Unicode scalars, where this crate's
Errorcounts UTF-16 code units (tabnas_css::Error::fromconverts one); - the tree is bounded at
TREE_RULE_DEPTH(768) open rules, and one level more fails withcancel; - with the engine's recovery on, the partial stylesheet is not always the canonical port's (the concepts page says where they part).
The how-to guide has the recipe, and the reference the details.
Building from a checkout
The engine, jsonic, the strict-JSON grammar jsonic takes, and the debug
plugin the tests use are sibling checkouts, the standard tabnas development
model. Clone parser,
json,
jsonic and
debug next to this repository, and
cargo test in rs/ builds against them.
Untrusted input
A parsed stylesheet is data, never instructions. CSS arrives from outside the
system, so treat every selector, value, and comment as hostile text. Parsing is
not sanitising: this crate returns the raw text the stylesheet contained, and
escaping it for HTML, SQL or a shell remains the caller's job. A url(...) in
a declaration value is untrusted text, not a link to fetch.
A parse holds memory, and takes time, in proportion to its input, and
nesting costs the most. Measured in a release build, 100,000 flat rules
(1.7 MB of CSS) peaked at 210 MiB, or 580 MiB with positions on, and
100,000 nested rules (0.6 MB) at 678 MiB, about 7 KiB per open level. The
densest nesting, a{ repeated, holds about 2.8 KiB and takes about 6 µs
per byte of input. A host that parses untrusted CSS should cap the input's
size.
The engine's parse.recover and lex.relex modes are the exception to
that proportion: in the engine this crate builds on, both take time
quadratic in the input, and a valid 16 KB stylesheet takes about 16 s with
recovery on. parse and Css::parse use neither.
parse and Css::parse have no depth limit: 20,000 nested rules parse, and
the result drops, clones, compares and prints without recursion, on a 2 MiB
thread. The engine's tree form stops at TREE_RULE_DEPTH open rules, as
described above.
When debug assertions are on, the engine checks its whole rule stack against a shadow copy on every step, which makes a debug build's parse quadratic in nesting depth. Measured in a debug build, 1,000 nested rules took 8.3 s, 2,000 took 32.9 s and 4,000 took 134 s. This crate's manifest turns the check off for its own builds, but a profile setting applies only to the root package, so a crate that parses untrusted, possibly deep CSS in its own debug builds sets the same key in its own manifest:
[]
= false
Documentation
Full documentation follows the Diátaxis framework:
- Tutorial. A guided first parse, start to finish.
- How-to guide. Short recipes for individual tasks.
- Reference. The public API, every option, and the complete AST node reference.
- Concepts. How the parser is built, and how the Rust port differs from TypeScript.
For the canonical TypeScript implementation, see
../ts/README.md. For the Go port, see
../go/README.md.
License
MIT.