tabnas_css/plugin.rs
1/* Copyright (c) 2025 Richard Rodger, MIT License */
2
3//! The plugin: installing the grammar on an engine, and the actions that
4//! build the AST from what it matches.
5//!
6//! `makeActions` in `ts/src/css.ts` is the canonical form. Its node
7//! constructors REBIND `r.node`, and its setters and pushers mutate the node
8//! object a rule inherited from its parent by reference. The engine here
9//! shares one node CELL between a rule and the rules it pushes or replaces
10//! into, so the constructors install a fresh cell rather than write into the
11//! one they were handed, which would overwrite the parent's node; the setters
12//! and pushers write through the shared cell, as the canonical ones do
13//! through the shared object.
14//!
15//! A node is kept in one of two stores, chosen per parse:
16//!
17//! - **The tree**, for a parse through the engine's own API
18//! ([`make`](crate::make), [`plugin`](crate::plugin),
19//! [`Css::tabnas`](crate::Css::tabnas)): each cell holds the node itself,
20//! an engine object, and a child is moved into its parent's list when it is
21//! complete. The engine returns the stylesheet, and with recovery on a
22//! partial one, which is not always the canonical port's (see
23//! `doc/concepts.md`). The engine's value drops, copies and prints by
24//! recursion, one frame per level, so this form is bounded at
25//! [`TREE_RULE_DEPTH`] open rules by a parse guard.
26//! - **The arena**, for [`Css::parse`](crate::Css::parse), which asks for it
27//! with [`arena_meta`]: every node is one flat record in a
28//! per-parse list in `ctx.u`, a cell holds the record's id, and a child list
29//! holds ids. The engine never holds a nested value, so no depth reaches a
30//! recursion; [`Value::from_arena`](crate::Value) builds the crate's own
31//! tree from the list without one, and this form has no depth limit.
32
33use std::cell::RefCell;
34use std::rc::Rc;
35use std::sync::{Arc, OnceLock};
36
37use tabnas::{ActionError, Context, GrammarSetting, PluginError, Rule, Tabnas, Token, Value};
38
39use crate::grammar::{specs, RULES};
40use crate::lex::{
41 self, col_width, es_is_whitespace, es_trim, split_selectors, vendor_prefix, BRACE_SCAN,
42 OVERSHOOT,
43};
44use crate::Options;
45
46/// The most rules the tree form lets be open at once.
47///
48/// 768 frames: 191 nested style rules (each takes four: `decls`, `decl`,
49/// `sel`, `declbody`) or 256 nested `@media` blocks (three: `items`,
50/// `statement`, `rulesbody`). A tree at the limit survives being dropped,
51/// cloned, compared, printed and written as JSON on a 2 MiB thread, the
52/// smallest stack a spawned thread gets by default, in a debug build. A
53/// deeper document fails with `cancel`. [`Css::parse`](crate::Css::parse)
54/// has no such limit, and neither does the canonical port: this is a
55/// Rust-only refusal, registered in `test/divergent.tsv`.
56pub const TREE_RULE_DEPTH: usize = 768;
57
58/// The name the tree form's bound is installed under, as a parse guard.
59const DEPTH_GUARD: &str = "tabnas-css/depth";
60
61/// The key of the meta [`arena_meta`] builds, for whoever reads it.
62const ARENA: &str = "tabnas-css/arena";
63
64/// The arena: the `ctx.u` key holding the per-parse list of node records.
65const NODES: &str = "tabnas-css/nodes";
66
67/// The `ctx.u` key holding the scalar positions of the source's astral
68/// characters, built when the first position is recorded.
69const ASTRAL: &str = "tabnas-css/astral";
70
71/// The meta [`Css::parse`](crate::Css::parse) passes to ask for the arena
72/// store; a parse without it builds the tree.
73///
74/// Recognised by its ADDRESS, not by its contents: the engine's API lets a
75/// caller pass meta of its own, and a key a caller could spell would let it
76/// switch the store, and with it the tree form's bound, from outside. Every
77/// call hands out the one allocation made here, which a caller has no way
78/// to make, and the engine passes meta to the context as it was given.
79pub(crate) fn arena_meta() -> Value {
80 arena_marker().clone()
81}
82
83fn arena_marker() -> &'static Value {
84 static META: OnceLock<Value> = OnceLock::new();
85 META.get_or_init(|| {
86 let mut meta = object();
87 put(&mut meta, ARENA, Value::Bool(true));
88 meta
89 })
90}
91
92/// Whether this parse builds into the arena.
93fn arena(ctx: &Context) -> bool {
94 match (&ctx.meta, arena_marker()) {
95 (Value::Object(meta), Value::Object(ours)) => Arc::ptr_eq(meta, ours),
96 _ => false,
97 }
98}
99
100fn object() -> Value {
101 Value::object(Default::default())
102}
103
104fn list() -> Value {
105 Value::array(Vec::new())
106}
107
108fn text(s: &str) -> Value {
109 Value::String(s.to_string())
110}
111
112/// Set `key` where assigning a JavaScript object property puts it: in its
113/// place when it is already present, and otherwise last, unless it is an
114/// array index, which JavaScript enumerates first, in ascending order. An
115/// at-keyword is a key (`@0 x;` is `{"0": "x", "type": "0"}` in the
116/// canonical port), so the order reaches the JSON.
117fn put(target: &mut Value, key: &str, value: Value) {
118 let Some(fields) = target.as_object_mut() else {
119 return;
120 };
121 match array_index(key) {
122 Some(index) if !fields.contains_key(key) => {
123 let at = fields
124 .keys()
125 .take_while(|k| array_index(k).is_some_and(|other| other < index))
126 .count();
127 fields.shift_insert(at, key.to_string(), value);
128 }
129 _ => {
130 fields.insert(key.to_string(), value);
131 }
132 }
133}
134
135/// The array index `key` names, if it names one: the canonical decimal
136/// form of an integer below 2^32 - 1, as ECMAScript defines one.
137fn array_index(key: &str) -> Option<u32> {
138 let canonical = !key.is_empty()
139 && key.bytes().all(|b| b.is_ascii_digit())
140 && (key == "0" || !key.starts_with('0'));
141 if !canonical {
142 return None;
143 }
144 key.parse::<u32>().ok().filter(|&index| index < u32::MAX)
145}
146
147/// Append to the list at `field`.
148fn push_to(target: &mut Value, field: &str, item: Value) {
149 let Some(fields) = target.as_object_mut() else {
150 return;
151 };
152 match fields.get_mut(field) {
153 Some(Value::Array(items)) => Arc::make_mut(items).push(item),
154 _ => {
155 fields.insert(field.to_string(), Value::array(vec![item]));
156 }
157 }
158}
159
160/// The arena's list of records.
161fn nodes(ctx: &mut Context) -> &mut Vec<Value> {
162 let slot = ctx.u.entry(NODES.to_string()).or_insert_with(list);
163 if slot.as_array_mut().is_none() {
164 *slot = list();
165 }
166 slot.as_array_mut().expect("the arena was just made a list")
167}
168
169/// Make `record` this rule's node, in a FRESH cell. The cell a rule starts
170/// with is its parent's (a push or a replace shares it), and the canonical
171/// constructor's `r.node = …` rebinds the child's name without touching the
172/// parent's object; writing into the shared cell would overwrite the parent.
173fn install_node(rule: &mut Rule, ctx: &mut Context, record: Value) {
174 let node = if arena(ctx) {
175 let records = nodes(ctx);
176 let id = records.len();
177 records.push(record);
178 Value::Number(id as f64)
179 } else {
180 record
181 };
182 rule.node = Rc::new(RefCell::new(node));
183}
184
185/// Edit this rule's node where it lives: in the arena, or in the cell.
186fn with_node(rule: &Rule, ctx: &mut Context, edit: impl FnOnce(&mut Value)) {
187 if arena(ctx) {
188 let id = match *rule.node.borrow() {
189 Value::Number(id) => id as usize,
190 _ => return,
191 };
192 if let Some(record) = nodes(ctx).get_mut(id) {
193 edit(record);
194 }
195 } else {
196 edit(&mut rule.node.borrow_mut());
197 }
198}
199
200// ---------------------------------------------------------------------------
201// Positions.
202
203/// The column of scalar `pos`, in UTF-16 code units, from the engine's
204/// column `ci` there.
205///
206/// The engine counts a column in Unicode scalars since the last reset (a
207/// `\n` in any token, or the end of a run of line characters); the canonical
208/// port counts UTF-16 code units since the same points. The difference is the
209/// astral characters in between, looked up in a sorted list built once per
210/// parse, so the cost per position is a binary search, and nothing at all for
211/// a source with no astral character.
212fn col16(ctx: &mut Context, pos: usize, ci: usize) -> usize {
213 if !ctx.u.contains_key(ASTRAL) {
214 let wide: Vec<Value> = if ctx.source.bytes().any(|b| 0xF0 <= b) {
215 ctx.source
216 .chars()
217 .enumerate()
218 .filter(|(_, c)| 2 == c.len_utf16())
219 .map(|(i, _)| Value::Number(i as f64))
220 .collect()
221 } else {
222 Vec::new()
223 };
224 ctx.u.insert(ASTRAL.to_string(), Value::array(wide));
225 }
226 let Some(Value::Array(wide)) = ctx.u.get(ASTRAL) else {
227 return ci;
228 };
229 if wide.is_empty() {
230 return ci;
231 }
232 let at = |v: &Value| match v {
233 Value::Number(n) => *n as usize,
234 _ => 0,
235 };
236 // The end-of-source token's column carries the scanners' overshoot, so
237 // this window can start before the reset; the scalar there is a reset
238 // character or before the source, never astral.
239 let from = pos.saturating_sub(ci.saturating_sub(1));
240 let low = wide.partition_point(|v| at(v) < from);
241 let high = wide.partition_point(|v| at(v) < pos);
242 ci + (high - low)
243}
244
245fn point(line: usize, column: usize) -> Value {
246 let mut at = object();
247 put(&mut at, "line", Value::Number(line as f64));
248 put(&mut at, "column", Value::Number(column as f64));
249 at
250}
251
252/// A token's first character: `startPos` in the canonical port.
253fn start_pos(ctx: &mut Context, token: &Token) -> Value {
254 let column = col16(ctx, token.site.pos, token.site.ci);
255 point(token.site.ri, column)
256}
257
258/// Just after a token's last character: `endPos` in the canonical port,
259/// measured over the token's source text.
260fn end_pos(ctx: &mut Context, token: &Token) -> Value {
261 let src = token.src.as_str();
262 match src.rfind('\n') {
263 Some(last) => point(
264 token.site.ri + src.matches('\n').count(),
265 1 + col_width(&src[last + 1..]),
266 ),
267 None => {
268 let column = col16(ctx, token.site.pos, token.site.ci);
269 point(token.site.ri, column + col_width(src))
270 }
271 }
272}
273
274/// `withPos` in the canonical port: a `position` for a node built from the
275/// rule's first matched token, with its end when `end` is set. An end not
276/// recorded here is absent until an action records it.
277fn with_pos(rule: &Rule, ctx: &mut Context, position: bool, end: bool) -> Option<Value> {
278 if !position {
279 return None;
280 }
281 let token = rule.o0()?;
282 let mut at = object();
283 put(&mut at, "start", start_pos(ctx, token));
284 if end {
285 put(&mut at, "end", end_pos(ctx, token));
286 }
287 Some(at)
288}
289
290/// Record the end of this rule's node, if it has a position.
291fn set_end(rule: &Rule, ctx: &mut Context, end: Value) {
292 with_node(rule, ctx, |record| {
293 if let Some(at) = record.as_object_mut().and_then(|f| f.get_mut("position")) {
294 put(at, "end", end);
295 }
296 });
297}
298
299// ---------------------------------------------------------------------------
300// Node builders: `makeAtRules`, `makeAtDecls`, `makeKeyframes` and
301// `makeAtStmt` in the canonical port. Key order is JavaScript's: a key
302// assigned twice keeps its first place.
303
304fn token_text(token: &Token) -> String {
305 match &token.val {
306 Value::String(s) => s.clone(),
307 _ => String::new(),
308 }
309}
310
311fn use_text(token: &Token, key: &str) -> String {
312 match token.use_data().get(key) {
313 Some(Value::String(s)) => s.clone(),
314 _ => String::new(),
315 }
316}
317
318/// A block at-rule whose body is a list of rules (`@media`, `@supports`,
319/// `@document`, `@host`, and any other with a rules body).
320fn make_at_rules(token: &Token) -> Value {
321 let kw = token_text(token);
322 let prelude = use_text(token, "prelude");
323 let mut node = object();
324 match kw.as_str() {
325 "host" => put(&mut node, "type", text("host")),
326 _ if "document" == kw || kw.ends_with("-document") => {
327 // `vendor` is always present on a document node, the empty
328 // string when the at-keyword carries no prefix.
329 put(&mut node, "type", text("document"));
330 put(&mut node, "document", text(&prelude));
331 put(&mut node, "vendor", text(vendor_prefix(&kw).unwrap_or("")));
332 }
333 _ => {
334 put(&mut node, "type", text(&kw));
335 put(&mut node, &kw, text(&prelude));
336 }
337 }
338 put(&mut node, "rules", list());
339 node
340}
341
342/// A block at-rule whose body is declarations (`@font-face`, `@page`, and
343/// any other with a declarations body). A `@page` prelude is a selector
344/// group: `@page toc, index:blank`.
345fn make_at_decls(token: &Token) -> Value {
346 let kw = token_text(token);
347 let mut node = object();
348 put(&mut node, "type", text(&kw));
349 if "page" == kw {
350 let selectors = split_selectors(&use_text(token, "prelude"))
351 .into_iter()
352 .map(Value::String)
353 .collect();
354 put(&mut node, "selectors", Value::array(selectors));
355 }
356 put(&mut node, "declarations", list());
357 node
358}
359
360/// A `@keyframes` block, possibly vendor-prefixed.
361fn make_keyframes(token: &Token) -> Value {
362 let kw = token_text(token);
363 let mut node = object();
364 put(&mut node, "type", text("keyframes"));
365 put(&mut node, "name", text(&use_text(token, "prelude")));
366 if let Some(vendor) = vendor_prefix(&kw) {
367 put(&mut node, "vendor", text(vendor));
368 }
369 put(&mut node, "keyframes", list());
370 node
371}
372
373/// A statement at-rule (`@import`, `@charset`, `@namespace`, …): the
374/// at-keyword is the node type and the field carrying its params.
375///
376/// `@custom-media --name <query>` splits its params into a name and a media
377/// query, as `/^(--\S+)\s*([\s\S]*)$/` does in the canonical port: `--` and
378/// at least one non-space character, then the rest, trimmed.
379fn make_at_stmt(token: &Token) -> Value {
380 let kw = token_text(token);
381 let params = use_text(token, "params");
382 let mut node = object();
383 if "custom-media" == kw && params.starts_with("--") {
384 let name_end = params.find(es_is_whitespace).unwrap_or(params.len());
385 if 2 < name_end {
386 put(&mut node, "type", text("custom-media"));
387 put(&mut node, "name", text(¶ms[..name_end]));
388 put(&mut node, "media", text(es_trim(¶ms[name_end..])));
389 return node;
390 }
391 }
392 put(&mut node, "type", text(&kw));
393 put(&mut node, &kw, text(¶ms));
394 node
395}
396
397// ---------------------------------------------------------------------------
398// The actions.
399
400type Outcome = Result<(), ActionError>;
401
402/// The text of the rule's first matched token, which the engine keeps after
403/// a `b: 1` backtrack.
404fn o0_text(rule: &Rule) -> String {
405 rule.o0().map(token_text).unwrap_or_default()
406}
407
408/// Append the completed child's node to this rule's node's `field`.
409///
410/// A child that built no node of its own is skipped. Every rule these
411/// pushers follow (`statement`, `decl`, `keyframe`) builds its node in each
412/// open alternate, so such a child failed before its constructor ran, which
413/// only the engine's recovery lets a parse survive. It still holds this
414/// rule's cell, and the engine then leaves `child_node` undefined, where
415/// `child_value()` would answer with this rule's own node. The canonical
416/// port pushes that node into itself there (`undefined !== c` sees the
417/// parent), a cycle, which a value here cannot hold; pushing a copy would
418/// put a stylesheet inside its own `rules`.
419fn push_child(rule: &Rule, ctx: &mut Context, field: &str) {
420 if rule.child_node.is_undefined() {
421 return;
422 }
423 let child = rule.child_node.clone();
424 with_node(rule, ctx, |record| push_to(record, field, child));
425}
426
427/// Register every action the grammar names, capturing the options each
428/// needs. Registration replaces by name, so a second install with other
429/// options takes effect.
430fn register_actions(parser: &mut Tabnas, position: bool) {
431 parser.action_with_context("@cssSheet", move |rule, ctx| -> Outcome {
432 let mut node = object();
433 put(&mut node, "type", text("stylesheet"));
434 put(&mut node, "rules", list());
435 if position {
436 let mut at = object();
437 put(&mut at, "start", point(1, 1));
438 put(&mut node, "position", at);
439 }
440 install_node(rule, ctx, node);
441 Ok(())
442 });
443
444 // The constructors of nodes built from the rule's first token: a start
445 // position always, and an end only for the single-token nodes.
446 type Build = fn(&Rule) -> Value;
447 let simple: [(&str, Build, bool); 4] = [
448 (
449 "@cssRule",
450 |_| {
451 let mut node = object();
452 put(&mut node, "type", text("rule"));
453 put(&mut node, "selectors", list());
454 put(&mut node, "declarations", list());
455 node
456 },
457 false,
458 ),
459 (
460 "@cssDecl",
461 |rule| {
462 let mut node = object();
463 put(&mut node, "type", text("declaration"));
464 put(&mut node, "property", Value::String(o0_text(rule)));
465 put(&mut node, "value", text(""));
466 node
467 },
468 false,
469 ),
470 (
471 "@cssComment",
472 |rule| {
473 let mut node = object();
474 put(&mut node, "type", text("comment"));
475 put(&mut node, "comment", Value::String(o0_text(rule)));
476 node
477 },
478 true,
479 ),
480 (
481 "@cssKeyframe",
482 |_| {
483 let mut node = object();
484 put(&mut node, "type", text("keyframe"));
485 put(&mut node, "values", list());
486 put(&mut node, "declarations", list());
487 node
488 },
489 false,
490 ),
491 ];
492 for (name, build, end) in simple {
493 parser.action_with_context(name, move |rule, ctx| -> Outcome {
494 let mut node = build(rule);
495 if let Some(at) = with_pos(rule, ctx, position, end) {
496 put(&mut node, "position", at);
497 }
498 install_node(rule, ctx, node);
499 Ok(())
500 });
501 }
502
503 type AtBuild = fn(&Token) -> Value;
504 let at_rules: [(&str, AtBuild, bool); 4] = [
505 ("@cssAtRules", make_at_rules, false),
506 ("@cssAtDecls", make_at_decls, false),
507 ("@cssKeyframes", make_keyframes, false),
508 ("@cssAtStmt", make_at_stmt, true),
509 ];
510 for (name, build, end) in at_rules {
511 parser.action_with_context(name, move |rule, ctx| -> Outcome {
512 let mut node = rule.o0().map(build).unwrap_or_else(object);
513 if let Some(at) = with_pos(rule, ctx, position, end) {
514 put(&mut node, "position", at);
515 }
516 install_node(rule, ctx, node);
517 Ok(())
518 });
519 }
520
521 // Field setters: they change the node the rule inherited.
522 for (name, field) in [("@cssSelector", "selectors"), ("@cssKfValue", "values")] {
523 parser.action_with_context(name, move |rule, ctx| -> Outcome {
524 let value = Value::String(o0_text(rule));
525 with_node(rule, ctx, |record| push_to(record, field, value));
526 Ok(())
527 });
528 }
529 parser.action_with_context("@cssDeclVal", move |rule, ctx| -> Outcome {
530 let value = Value::String(o0_text(rule));
531 with_node(rule, ctx, |record| put(record, "value", value));
532 if position {
533 if let Some(token) = rule.o0() {
534 let end = end_pos(ctx, token);
535 set_end(rule, ctx, end);
536 }
537 }
538 Ok(())
539 });
540
541 // The closing brace, or the end of the source for the stylesheet: the
542 // end of the node the block belongs to. A close phase, so the matched
543 // token is the rule's first CLOSE token.
544 //
545 // At the stylesheet's end an arena parse also hands back the whole list,
546 // as the root rule's node, for `Value::from_arena` to assemble: nothing
547 // runs after this close but the engine taking that node as the result.
548 // It is done here rather than in a rule lifecycle action so that the
549 // installed rules are the canonical port's, alternate for alternate and
550 // with no hook it lacks. A tree parse's node is already the stylesheet.
551 parser.action_with_context("@cssEnd", move |rule, ctx| -> Outcome {
552 if position {
553 if let Some(token) = rule.c0() {
554 let end = end_pos(ctx, token);
555 set_end(rule, ctx, end);
556 }
557 }
558 if "stylesheet" == rule.name.as_str() && arena(ctx) {
559 let records = ctx.u.shift_remove(NODES).unwrap_or_else(list);
560 rule.node = Rc::new(RefCell::new(records));
561 }
562 Ok(())
563 });
564
565 // List pushers: a completed child node into its parent's list.
566 for (name, field) in [
567 ("@cssPushRule", "rules"),
568 ("@cssPushDecl", "declarations"),
569 ("@cssPushKf", "keyframes"),
570 ] {
571 parser.action_with_context(name, move |rule, ctx| -> Outcome {
572 push_child(rule, ctx, field);
573 Ok(())
574 });
575 }
576}
577
578/// Whether `action` is this grammar's `@cssSheet`: as installed, or as
579/// [`Tabnas::merge`] renames it, `@<tag>:cssSheet`.
580fn is_css_sheet(action: &str) -> bool {
581 "@cssSheet" == action || (action.starts_with('@') && action.ends_with(":cssSheet"))
582}
583
584/// Install the plugin on `parser`. See [`plugin`](crate::plugin).
585///
586/// Safe to run more than once on one instance, and it is: `use_plugin` runs
587/// it again when the same plugin is used again with other options, and
588/// [`Tabnas::derive`] runs it on every derived instance. A second run
589/// replaces the matcher and the actions (which capture the options) and
590/// installs the rules afresh; the lex subscriber, which captures nothing, is
591/// added only by the first, since subscribers are not named and would
592/// otherwise run twice.
593pub(crate) fn install(parser: &mut Tabnas, options: &Options) -> Result<(), PluginError> {
594 let specs = specs().map_err(PluginError)?;
595 // Installed before on THIS instance when its `stylesheet` rule runs this
596 // grammar's `@cssSheet`. A rule of that name from another grammar is not
597 // this one, and the lex subscriber must still be added. The rule table
598 // is rebuilt on `derive`, as the subscribers are, so the two agree there.
599 let first = !parser.rule_specs().iter().any(|spec| {
600 "stylesheet" == spec.name
601 && spec
602 .open
603 .iter()
604 .any(|alt| alt.a.iter().any(|action| is_css_sheet(action)))
605 });
606
607 // The matcher emits these by name (see `lex::BY_NAME`); registering
608 // them here gives them numbers before the rules that name them.
609 for name in ["#CC", "#ATR", "#ATD", "#ATK", "#ATS", "#GC"] {
610 parser.token(name);
611 }
612 let lower = options.lowercase_properties;
613 parser.imperative_lex_match_ref("@css-token", move |lexer, rule, ctx| {
614 lex::css_token(lexer, rule, ctx, lower)
615 });
616 register_actions(parser, options.position);
617
618 // The plugin's per-parse state starts empty on every parse, whatever
619 // the caller seeded `ctx.u` with.
620 parser.parse_prepare_ref("@css-prepare", |ctx| {
621 ctx.u.shift_remove(NODES);
622 ctx.u.shift_remove(ASTRAL);
623 ctx.u.shift_remove(OVERSHOOT);
624 ctx.u.shift_remove(BRACE_SCAN);
625 });
626 if first {
627 parser.subscribe_lex(lex::lex_subscriber);
628 }
629
630 // A name of the plugin's own. A later plugin that installs a guard
631 // named `depth`, as jsonic, json and the grammars layered on them do,
632 // replaces only that one, and this bound stays. jsonic's own `depth`
633 // guard is removed: it counts `map` and `list` rules, which the
634 // grammar's options exclude, so it could never refuse a css parse, and
635 // it ran on every step, about 1% of a flat stylesheet's parse. The
636 // count comes first because it is the cheap test.
637 parser.remove_parse_guard("depth");
638 parser.parse_guard(DEPTH_GUARD, |ctx| {
639 ctx.rule_stack.len() <= TREE_RULE_DEPTH || arena(ctx)
640 });
641
642 if !first {
643 for name in RULES {
644 parser.remove_rule(name);
645 }
646 }
647 parser
648 .grammar(&specs.options)
649 .map_err(|e| PluginError(e.0))?;
650 parser
651 .grammar_with_setting(&specs.rules, &GrammarSetting::groups("css"))
652 .map_err(|e| PluginError(e.0))?;
653 Ok(())
654}