Skip to main content

tabnas_css/
lex.rs

1/* Copyright (c) 2025 Richard Rodger, MIT License */
2
3//! The lexer: the single `cssToken` matcher the plugin installs on the
4//! engine, and the scanners it reads spans with.
5//!
6//! CSS is context-sensitive — the same characters can begin a selector, a
7//! property or a value — so the matcher owns the hard tokenisation and the
8//! grammar only assembles typed nodes from what it emits. Which token comes
9//! out depends on the rule that is active when the character is read: the
10//! engine reads a token only when the alternate being tried needs one, under
11//! the rule trying it, and passes that rule to the matcher.
12//!
13//! See `ts/src/css.ts` for the canonical commentary; the three ports are kept
14//! in step, and this module follows `buildCssTokenMatcher` there line for
15//! line.
16
17use std::fmt;
18use std::sync::Arc;
19
20use tabnas::{
21    Context, Lexer, Rule, TabnasError, Tin as EngineTin, Token, Value, TIN_TX, TIN_VL, TIN_ZZ,
22};
23
24/// A token kind.
25///
26/// `Ob`/`Cb`/`Cl`/`Ca` are the fixed punctuation the canonical port borrows
27/// from the engine (`{` `}` `:` and `;`, the last remapped onto jsonic's
28/// member separator). The rest are this grammar's own: a custom name is
29/// required for a comment NODE because the engine's builtin comment tin is in
30/// the parser's ignore set, so emitting it would silently drop the node.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Tin {
33    /// `{` — start of a block.
34    Ob,
35    /// `}` — end of a block.
36    Cb,
37    /// `:` — declaration separator.
38    Cl,
39    /// `;` — declaration terminator.
40    Ca,
41    /// A selector, keyframe value, or property name.
42    Tx,
43    /// `,` — selector-group separator.
44    Gc,
45    /// A declaration value (raw text).
46    Vl,
47    /// A comment, at a statement position.
48    Cc,
49    /// An at-rule with a rules body (`@media`, `@supports`, …).
50    Atr,
51    /// An at-rule with a declarations body (`@font-face`, `@page`, …).
52    Atd,
53    /// `@keyframes`.
54    Atk,
55    /// A statement at-rule (`@import`, `@charset`, …).
56    Ats,
57    /// End of input.
58    Zz,
59}
60
61impl Tin {
62    /// The token's grammar name, as `css-grammar.jsonic` writes it.
63    pub fn name(self) -> &'static str {
64        match self {
65            Tin::Ob => "#OB",
66            Tin::Cb => "#CB",
67            Tin::Cl => "#CL",
68            Tin::Ca => "#CA",
69            Tin::Tx => "#TX",
70            Tin::Gc => "#GC",
71            Tin::Vl => "#VL",
72            Tin::Cc => "#CC",
73            Tin::Atr => "#ATR",
74            Tin::Atd => "#ATD",
75            Tin::Atk => "#ATK",
76            Tin::Ats => "#ATS",
77            Tin::Zz => "#ZZ",
78        }
79    }
80
81    /// A human description, for diagnostics and diagram legends.
82    pub fn describe(self) -> &'static str {
83        match self {
84            Tin::Ob => "{ — start of a block",
85            Tin::Cb => "} — end of a block",
86            Tin::Cl => ": — declaration separator",
87            Tin::Ca => "; — declaration terminator",
88            Tin::Tx => "a selector, keyframe value, or property name",
89            Tin::Gc => ", — selector-group separator",
90            Tin::Vl => "a declaration value (raw text)",
91            Tin::Cc => "a comment",
92            Tin::Atr => "an at-rule with a rules body (@media, @supports, …)",
93            Tin::Atd => "an at-rule with a declarations body (@font-face, @page)",
94            Tin::Atk => "@keyframes",
95            Tin::Ats => "a statement at-rule (@import, @charset, …)",
96            Tin::Zz => "end of input",
97        }
98    }
99}
100
101/// A parse failure.
102///
103/// `code` is the contract: the shared fixtures pin `ERROR:<code>` and compare
104/// it exactly. [`Css::parse`](crate::Css::parse) raises only the two codes the
105/// canonical port inherits from the engine — `unterminated_comment` and
106/// `unexpected` — and this crate declares none of its own, matching
107/// `tabnas.plugin.json` (`errorCodes: []`). The engine's own tree form can
108/// also stop with `cancel`, at [`TREE_RULE_DEPTH`](crate::TREE_RULE_DEPTH).
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct Error {
111    /// The error code, compared exactly by the conformance fixtures.
112    pub code: String,
113    /// A human-readable explanation.
114    pub message: String,
115    /// 1-based line where the failure was found.
116    pub line: usize,
117    /// 1-based column (UTF-16 code units) where the failure was found.
118    pub column: usize,
119}
120
121impl fmt::Display for Error {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        write!(
124            f,
125            "[css/{}]: {} (line {}, column {})",
126            self.code, self.message, self.line, self.column
127        )
128    }
129}
130
131impl std::error::Error for Error {}
132
133/// The scanners' "there is no valid end here" result: an unclosed
134/// `/* ... */`.
135///
136/// A distinct value rather than an index, because the caller's response is a
137/// REJECTION, not a shorter span. Running the scan to end of source instead is
138/// how an unclosed comment in an at-rule prelude quietly became part of the
139/// prelude: `@import/*red` parsed as an import of `/*red`.
140const UNTERMINATED: usize = usize::MAX;
141
142/// What [`scan_to_brace_or_end`] found first.
143#[derive(Clone, Copy, PartialEq, Eq, Debug)]
144enum Kind {
145    /// A top-level `{` — so the run just scanned is a selector or a prelude.
146    Selector,
147    /// A top-level `;`/`}` or end of input — so it is a property or params.
148    Decl,
149}
150
151/// Rule names at which a `/* */` comment is captured as a NODE: the
152/// statement / declaration / keyframe LIST readers, which lex the first token
153/// of each item.
154///
155/// The item builders (`statement`/`decl`/`keyframe`) are deliberately absent.
156/// They reuse the token the list reader already produced, so a comment seen
157/// mid-construct — between a property and its `:`, say — is read under a
158/// builder, declined here, and skipped as insignificant whitespace.
159///
160/// The block wrappers are present because their empty-block `#OB #CB`
161/// lookahead lexes the first body token, so a comment right after `{` is
162/// captured there.
163const COMMENT_NODE_RULES: [&str; 6] = [
164    "items",
165    "decls",
166    "kfitems",
167    "declbody",
168    "rulesbody",
169    "kfbody",
170];
171
172fn is_comment_node_rule(name: &str) -> bool {
173    COMMENT_NODE_RULES.contains(&name)
174}
175
176/// At-keywords whose body is a declaration list rather than a rules list.
177const DECLS_KW: [&str; 7] = [
178    "font-face",
179    "page",
180    "viewport",
181    "-ms-viewport",
182    "counter-style",
183    "property",
184    "font-palette-values",
185];
186
187/// `^(-[a-z]+-)?keyframes$`, without a regex engine.
188fn is_keyframes_kw(kw: &str) -> bool {
189    if kw == "keyframes" {
190        return true;
191    }
192    match vendor_prefix(kw) {
193        Some(v) => &kw[v.len()..] == "keyframes",
194        None => false,
195    }
196}
197
198/// `^(-[a-z]+-)`: a vendor prefix on an at-keyword, e.g. `-webkit-keyframes`.
199pub fn vendor_prefix(kw: &str) -> Option<&str> {
200    let b = kw.as_bytes();
201    if b.first() != Some(&b'-') {
202        return None;
203    }
204    let mut i = 1;
205    while i < b.len() && b[i].is_ascii_lowercase() {
206        i += 1;
207    }
208    if 1 < i && b.get(i) == Some(&b'-') {
209        Some(&kw[..i + 1])
210    } else {
211        None
212    }
213}
214
215/// Whether `c` is whitespace as ECMAScript defines it, which is NOT what
216/// [`char::is_whitespace`] answers.
217///
218/// The canonical port trims with JavaScript's `String.prototype.trim`, whose
219/// set is ECMAScript WhiteSpace plus LineTerminator. Rust's set is the Unicode
220/// `White_Space` property. They differ by exactly two code points, and both
221/// differences change the AST:
222///
223/// - **U+FEFF** (zero-width no-break space, the byte-order mark) is
224///   ECMAScript whitespace and is not Unicode `White_Space`. A stylesheet that
225///   opens with a byte-order mark yields the selector `a` in the canonical
226///   port and would yield `\u{feff}a` under [`str::trim`].
227/// - **U+0085** (next line) is Unicode `White_Space` and is not ECMAScript
228///   whitespace. `@media x \u{85}{…}` keeps that character in its prelude in
229///   the canonical port and would lose it under [`str::trim`].
230///
231/// Everything else in either set is in both: the ASCII controls, U+0020, the
232/// `Zs` category, and the two line separators U+2028 and U+2029.
233pub fn es_is_whitespace(c: char) -> bool {
234    ('\u{feff}' == c) || (c.is_whitespace() && '\u{85}' != c)
235}
236
237/// Trim as JavaScript's `String.prototype.trim` does. See
238/// [`es_is_whitespace`] for why this is not [`str::trim`].
239pub fn es_trim(s: &str) -> &str {
240    s.trim_matches(es_is_whitespace)
241}
242
243/// The column width of `s`: its length in UTF-16 code units, which is what a
244/// JavaScript string index counts.
245pub(crate) fn col_width(s: &str) -> usize {
246    s.chars().map(char::len_utf16).sum()
247}
248
249/// Slice `src` by byte offsets, clamping to something sliceable.
250///
251/// This is the Rust half of the Go port's `clampLen`, and it exists for the
252/// same reason: the scanners deliberately overshoot. An `i += 2` over a `\`
253/// escape, an unterminated string or an unterminated comment can step past the
254/// end of the source, and JavaScript CLAMPS an out-of-range slice bound where
255/// Go panics — so the Go port clamps to reproduce the JavaScript result rather
256/// than invent a new one.
257///
258/// Rust panics on one bound more than Go does: an index inside a multi-byte
259/// character. Every index these scanners RETURN is at an ASCII character or at
260/// the end of the source, because each one stops at `{`, `}`, `;`, `,`, `*/`,
261/// a quote or end of input, so the fallback below is unreachable in practice —
262/// it is here so that a future scanner change degrades to a shorter span
263/// instead of a panic in a caller's parse.
264fn safe_slice(src: &str, start: usize, end: usize) -> &str {
265    let mut start = start.min(src.len());
266    let mut end = end.clamp(start, src.len());
267    while 0 < start && !src.is_char_boundary(start) {
268        start -= 1;
269    }
270    while start < end && !src.is_char_boundary(end) {
271        end -= 1;
272    }
273    &src[start..end]
274}
275
276/// The index of the `*` that opens the `*/` closing the comment at `i`, or
277/// `None` if there is no closing `*/`.
278fn find_comment_end(src: &[u8], i: usize) -> Option<usize> {
279    let mut j = i + 2;
280    while j + 1 < src.len() {
281        if src[j] == b'*' && src[j + 1] == b'/' {
282            return Some(j);
283        }
284        j += 1;
285    }
286    None
287}
288
289/// The index after a closed `/* ... */`, or [`UNTERMINATED`].
290///
291/// It does NOT run to end of source. An unclosed `/* ... */` is an error, not
292/// a comment to the end of the file — which is what both the engine's builtin
293/// comment matcher and reworkcss do, and what `test/spec/reworkcss.tsv` pins.
294fn skip_comment(src: &[u8], i: usize) -> usize {
295    match find_comment_end(src, i) {
296        // `e + 1` is the `/`, so `e + 2` is always within the source.
297        Some(e) => e + 2,
298        None => UNTERMINATED,
299    }
300}
301
302/// Skip a quoted string; returns the index after the closing quote.
303fn skip_string(src: &[u8], mut i: usize) -> usize {
304    let quote = src[i];
305    i += 1;
306    while i < src.len() {
307        if src[i] == b'\\' {
308            i += 2;
309            continue;
310        }
311        if src[i] == quote {
312            return i + 1;
313        }
314        i += 1;
315    }
316    // A trailing backslash steps i ONE PAST the end. That overshoot is not a
317    // defect to clamp away here: the canonical port returns it too, and its
318    // column arithmetic counts it. See [`css_token`] and [`safe_slice`].
319    i
320}
321
322/// Scan a key prelude: where it ends, and whether it is a selector (a
323/// top-level `{` comes first) or a declaration (a top-level `;`/`}` or end of
324/// input comes first). Strings, `()`, `[]` and comments are skipped.
325fn scan_to_brace_or_end(src: &[u8], i: usize) -> (Kind, usize) {
326    let mut scan = Scan { i, depth: 0 };
327    loop {
328        if let Some(found) = scan.step(src) {
329            return found;
330        }
331    }
332}
333
334/// [`scan_to_brace_or_end`] one step at a time. The position and the
335/// bracket depth are its whole state, so two scans in the same state go on
336/// to the same answer: what [`BraceScans`] relies on.
337#[derive(Clone, Copy, Debug)]
338struct Scan {
339    i: usize,
340    depth: usize,
341}
342
343impl Scan {
344    /// Take one step: past a string, a comment, an escape or one byte.
345    /// `Some` is the scan's answer.
346    fn step(&mut self, src: &[u8]) -> Option<(Kind, usize)> {
347        let Some(&c) = src.get(self.i) else {
348            // i may be one past the end; see skip_string.
349            return Some((Kind::Decl, self.i));
350        };
351        if c == b'"' || c == b'\'' {
352            self.i = skip_string(src, self.i);
353            return None;
354        }
355        if c == b'/' && src.get(self.i + 1) == Some(&b'*') {
356            let n = skip_comment(src, self.i);
357            if UNTERMINATED == n {
358                return Some((Kind::Decl, UNTERMINATED));
359            }
360            self.i = n;
361            return None;
362        }
363        // A CSS escape (`\(`, `\'`, `\3A `, …) hides the next character from
364        // the structural scan.
365        if c == b'\\' {
366            self.i += 2;
367            return None;
368        }
369        if c == b'(' || c == b'[' {
370            self.depth += 1;
371        } else if c == b')' || c == b']' {
372            self.depth = self.depth.saturating_sub(1);
373        } else if 0 == self.depth {
374            if c == b'{' {
375                return Some((Kind::Selector, self.i));
376            }
377            if c == b';' || c == b'}' {
378                return Some((Kind::Decl, self.i));
379            }
380        }
381        self.i += 1;
382        None
383    }
384}
385
386/// [`scan_to_brace_or_end`] for every token of a parse, in linear time.
387///
388/// Each selector of a group, and each value of a keyframe-selector group,
389/// is classified by scanning to the group's `{`, so a group of `k` items
390/// scanned from each item's start costs `k` times the group's length: a
391/// 200 KB list of selectors took 9 s. The scan from an item's start is the
392/// scan from the group's first item, resumed there, whenever that scan
393/// passes the item's start at bracket depth 0 (the state [`Scan`] carries,
394/// outside any string, comment or escape, which it steps over whole). So
395/// the answer is kept with a cursor that follows the tokens forward, and a
396/// later start the cursor reaches in that state takes the kept answer. Any
397/// other start scans afresh and becomes the one kept. The cursor only moves
398/// forward, so a parse's scanning is linear in its length.
399#[derive(Clone, Copy, Debug)]
400struct BraceScans {
401    from: usize,
402    found: (Kind, usize),
403    cursor: Scan,
404}
405
406/// The `ctx.u` key [`BraceScans`] is kept under, for the parse.
407pub(crate) const BRACE_SCAN: &str = "tabnas-css/brace-scan";
408
409impl BraceScans {
410    /// The answer for a token starting at `s_i`, keeping the state under
411    /// [`BRACE_SCAN`] with one map lookup: the matcher asks on every `#TX`,
412    /// and a flat stylesheet is mostly misses.
413    fn scan(ctx: &mut Context, src: &[u8], s_i: usize) -> (Kind, usize) {
414        let Some(Value::Array(slot)) = ctx.u.get_mut(BRACE_SCAN) else {
415            let found = scan_to_brace_or_end(src, s_i);
416            let kept = BraceScans::fresh(s_i, found);
417            ctx.u
418                .insert(BRACE_SCAN.to_string(), Value::array(kept.fields().to_vec()));
419            return found;
420        };
421        let fields = Arc::make_mut(slot);
422        if let Some(mut kept) = BraceScans::decode(fields) {
423            if kept.from <= s_i && s_i < kept.found.1 && kept.cursor.i <= s_i {
424                while kept.cursor.i < s_i && kept.cursor.step(src).is_none() {}
425                let reached = kept.cursor.i == s_i && 0 == kept.cursor.depth;
426                kept.encode(fields);
427                if reached {
428                    return kept.found;
429                }
430            }
431        }
432        // An unclosed comment's answer is kept too: a scan from a later start
433        // the cursor reaches meets the same `/*`, and the engine's recovery
434        // asks again at the very start it failed at, up to its skip budget.
435        let found = scan_to_brace_or_end(src, s_i);
436        BraceScans::fresh(s_i, found).encode(fields);
437        found
438    }
439
440    fn fresh(from: usize, found: (Kind, usize)) -> BraceScans {
441        BraceScans {
442            from,
443            found,
444            cursor: Scan { i: from, depth: 0 },
445        }
446    }
447
448    fn decode(fields: &[Value]) -> Option<BraceScans> {
449        let at = |n: usize| match fields.get(n) {
450            Some(Value::Number(v)) => Some(*v as usize),
451            _ => None,
452        };
453        let kind = if 0 == at(1)? {
454            Kind::Selector
455        } else {
456            Kind::Decl
457        };
458        Some(BraceScans {
459            from: at(0)?,
460            found: (kind, at(2)?),
461            cursor: Scan {
462                i: at(3)?,
463                depth: at(4)?,
464            },
465        })
466    }
467
468    fn numbers(&self) -> [f64; 5] {
469        let kind = match self.found.0 {
470            Kind::Selector => 0,
471            Kind::Decl => 1,
472        };
473        [
474            self.from,
475            kind,
476            self.found.1,
477            self.cursor.i,
478            self.cursor.depth,
479        ]
480        .map(|n| n as f64)
481    }
482
483    fn fields(&self) -> [Value; 5] {
484        self.numbers().map(Value::Number)
485    }
486
487    /// Write this state over `fields`, number by number when the shape fits.
488    fn encode(&self, fields: &mut Vec<Value>) {
489        let numbers = self.numbers();
490        if numbers.len() == fields.len() {
491            for (field, n) in fields.iter_mut().zip(numbers) {
492                match field {
493                    Value::Number(slot) => *slot = n,
494                    other => *other = Value::Number(n),
495                }
496            }
497        } else {
498            *fields = self.fields().to_vec();
499        }
500    }
501}
502
503/// Scan a single selector: to the next top-level `,` (a group separator) or
504/// `{`, or end of input. Strings, `()`, `[]` and comments are skipped.
505fn scan_selector_end(src: &[u8], mut i: usize) -> usize {
506    let mut depth = 0usize;
507    while i < src.len() {
508        let c = src[i];
509        if c == b'"' || c == b'\'' {
510            i = skip_string(src, i);
511            continue;
512        }
513        if c == b'/' && src.get(i + 1) == Some(&b'*') {
514            let n = skip_comment(src, i);
515            if UNTERMINATED == n {
516                return UNTERMINATED;
517            }
518            i = n;
519            continue;
520        }
521        if c == b'\\' {
522            i += 2;
523            continue;
524        }
525        if c == b'(' || c == b'[' {
526            depth += 1;
527        } else if c == b')' || c == b']' {
528            depth = depth.saturating_sub(1);
529        } else if 0 == depth && (c == b',' || c == b'{') {
530            return i;
531        }
532        i += 1;
533    }
534    // i may be one past the end; see skip_string.
535    i
536}
537
538/// Scan a declaration value or at-rule params: to the next top-level `;` or
539/// `}`, or end of input. Strings, `()`, `[]` and comments are skipped.
540fn scan_value_end(src: &[u8], mut i: usize) -> usize {
541    let mut depth = 0usize;
542    while i < src.len() {
543        let c = src[i];
544        if c == b'"' || c == b'\'' {
545            i = skip_string(src, i);
546            continue;
547        }
548        if c == b'/' && src.get(i + 1) == Some(&b'*') {
549            let n = skip_comment(src, i);
550            if UNTERMINATED == n {
551                return UNTERMINATED;
552            }
553            i = n;
554            continue;
555        }
556        if c == b'\\' {
557            i += 2;
558            continue;
559        }
560        if c == b'(' || c == b'[' {
561            depth += 1;
562        } else if c == b')' || c == b']' {
563            depth = depth.saturating_sub(1);
564        } else if 0 == depth && (c == b';' || c == b'}') {
565            return i;
566        }
567        i += 1;
568    }
569    // i may be one past the end; see skip_string.
570    i
571}
572
573/// Remove `/* ... */` comments from a selector or value run, leaving quoted
574/// strings (which may contain `/*`) untouched.
575pub fn strip_comments(s: &str) -> String {
576    if !s.contains("/*") {
577        return s.to_string();
578    }
579    let src = s.as_bytes();
580    let mut out: Vec<u8> = Vec::with_capacity(src.len());
581    let mut i = 0;
582    while i < src.len() {
583        let c = src[i];
584        if c == b'"' || c == b'\'' {
585            let j = skip_string(src, i);
586            out.extend_from_slice(&src[i..j.min(src.len())]);
587            i = j;
588            continue;
589        }
590        if c == b'/' && src.get(i + 1) == Some(&b'*') {
591            let n = skip_comment(src, i);
592            if UNTERMINATED == n {
593                // Unreachable once the callers reject an unclosed comment,
594                // but a sentinel assigned to i would spin. The rest of the
595                // span is comment text; drop it.
596                break;
597            }
598            i = n;
599            continue;
600        }
601        out.push(c);
602        i += 1;
603    }
604    String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
605}
606
607/// A CSS property name character.
608///
609/// Beyond the identifier characters this admits the legacy IE hack prefixes
610/// `*`, `#`, `/` (as in `//prop`) and a literal backslash, matching the
611/// reworkcss property pattern `\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?` — real
612/// stylesheets rely on them.
613fn is_prop_char(c: u8) -> bool {
614    c.is_ascii_alphanumeric()
615        || c == b'-'
616        || c == b'_'
617        || c == b'#'
618        || c == b'*'
619        || c == b'/'
620        || c == b'\\'
621}
622
623/// A `[0-9a-z_-]` character, the only content allowed in a property name's
624/// bracket suffix (e.g. `opacity[sqrt]`).
625fn is_prop_suffix_char(c: u8) -> bool {
626    c.is_ascii_digit() || c.is_ascii_lowercase() || c == b'-' || c == b'_'
627}
628
629/// Scan a property name: the run of property characters plus an optional
630/// `[...]` suffix. Returns the index after the name (`== i` if there is none).
631fn scan_prop_end(src: &[u8], i: usize) -> usize {
632    let mut e_i = i;
633    while e_i < src.len() && is_prop_char(src[e_i]) {
634        e_i += 1;
635    }
636    if e_i == i {
637        return i;
638    }
639    if src.get(e_i) == Some(&b'[') {
640        let mut b_i = e_i + 1;
641        while b_i < src.len() && is_prop_suffix_char(src[b_i]) {
642            b_i += 1;
643        }
644        if e_i + 1 < b_i && src.get(b_i) == Some(&b']') {
645            return b_i + 1;
646        }
647    }
648    e_i
649}
650
651/// An at-keyword character: letters, digits and `-`.
652fn is_at_char(c: u8) -> bool {
653    c.is_ascii_alphanumeric() || c == b'-'
654}
655
656/// Split a selector-group prelude on its top-level commas (commas inside
657/// strings, `()` or `[]` are part of a single selector), stripping comments
658/// and trimming each selector. An empty prelude yields an empty list.
659pub fn split_selectors(prelude: &str) -> Vec<String> {
660    let src = prelude.as_bytes();
661    let mut out = Vec::new();
662    let mut i = 0;
663    while i <= src.len() {
664        let mut end = scan_selector_end(src, i);
665        if UNTERMINATED == end {
666            // Unreachable in practice: the prelude was scanned (and an
667            // unclosed comment rejected) before it got here. Treated as "the
668            // rest" so a sentinel can never index or spin.
669            end = src.len();
670        }
671        let one = es_trim(&strip_comments(safe_slice(prelude, i, end))).to_string();
672        if !one.is_empty() {
673            out.push(one);
674        }
675        if end >= src.len() {
676            break;
677        }
678        i = end + 1;
679    }
680    out
681}
682
683// ---------------------------------------------------------------------------
684// The `cssToken` matcher, as the engine runs it.
685
686/// The token number a matcher gives a token the engine is to resolve by
687/// NAME, as it lexes: the six this grammar names itself (`#CC`, `#GC` and
688/// the four at-rule tokens).
689///
690/// The engine numbers a name when it is first registered, and
691/// [`Tabnas::merge`](tabnas::Tabnas::merge) renumbers every custom name in
692/// the merged instance without running the plugin again, so a number
693/// captured at install names another token there. The canonical port's
694/// `lex.token('#CC', …)` resolves the name on every call for the same
695/// reason. `#TX` and `#VL` are the engine's own, with fixed numbers.
696const BY_NAME: EngineTin = -1;
697
698/// Where a scan ran past the end of the source, and by how much: the
699/// `ctx.u` key [`css_token`] records it under and [`lex_subscriber`] reads.
700pub(crate) const OVERSHOOT: &str = "tabnas-css/overshoot";
701
702/// The most of the source an `unterminated_comment` diagnostic shows. The
703/// span runs from the scan's start to the end of the source, which for a
704/// large stylesheet would be most of it; the message is informative, not the
705/// contract, so it is cut here.
706const BAD_SPAN: usize = 32;
707
708/// What [`plan_token`] decided for the characters at the cursor.
709enum Plan {
710    /// Not this matcher's: the engine's builtin matchers take it.
711    Decline,
712    /// An unclosed `/* ... */` behind the cursor: `unterminated_comment`.
713    Bad,
714    /// A token, and the byte offset just past it, which may be ONE past the
715    /// end of the source (see [`css_token`]).
716    Emit {
717        name: &'static str,
718        tin: EngineTin,
719        val: String,
720        raw: String,
721        use_kv: Option<(&'static str, String)>,
722        end: usize,
723    },
724}
725
726/// Decide the token at byte `s_i` of `src`, read under the rule `rule`.
727///
728/// `buildCssTokenMatcher` in `ts/src/css.ts`, step for step: whitespace is
729/// declined (the engine's space and line matchers take it), a comment is a
730/// node only at a list position, a value runs to the next top-level `;` or
731/// `}` under `declval`, a top-level `,` separates a selector group, `@`
732/// starts an at-rule, `{` `}` `;` are the grammar's, and anything else is a
733/// selector or a property name by `{`-before-`;` lookahead.
734fn plan_token(
735    src: &str,
736    s_i: usize,
737    rule: &str,
738    lower: bool,
739    brace: &mut dyn FnMut(usize) -> (Kind, usize),
740) -> Plan {
741    let bytes = src.as_bytes();
742    let Some(&c) = bytes.get(s_i) else {
743        return Plan::Decline;
744    };
745    if c == b' ' || c == b'\t' || c == b'\r' || c == b'\n' {
746        return Plan::Decline;
747    }
748    let emit = |name, tin, val: String, raw: &str, use_kv, end| Plan::Emit {
749        name,
750        tin,
751        val,
752        raw: raw.to_string(),
753        use_kv,
754        end,
755    };
756
757    // Comments: a node at a list position, otherwise declined, and then
758    // skipped by the engine's builtin comment matcher.
759    if c == b'/' && bytes.get(s_i + 1) == Some(&b'*') {
760        if !is_comment_node_rule(rule) {
761            return Plan::Decline;
762        }
763        return match find_comment_end(bytes, s_i) {
764            None => Plan::Bad,
765            Some(e) => emit(
766                "#CC",
767                BY_NAME,
768                safe_slice(src, s_i + 2, e).to_string(),
769                safe_slice(src, s_i, e + 2),
770                None,
771                e + 2,
772            ),
773        };
774    }
775
776    // Value position: a declaration value up to the next top-level `;`/`}`,
777    // comments stripped and surrounding space trimmed.
778    if "declval" == rule {
779        if c == b'{' || c == b'}' || c == b';' || c == b':' {
780            return Plan::Decline;
781        }
782        let end = scan_value_end(bytes, s_i);
783        if UNTERMINATED == end {
784            return Plan::Bad;
785        }
786        let raw = safe_slice(src, s_i, end);
787        let val = es_trim(&strip_comments(raw)).to_string();
788        return emit("#VL", TIN_VL, val, raw, None, end);
789    }
790
791    // A top-level selector-group comma.
792    if c == b',' {
793        return emit("#GC", BY_NAME, ",".to_string(), ",", None, s_i + 1);
794    }
795
796    // An at-rule: block or statement by `{`-before-`;` lookahead, and a
797    // block's body by keyword. The keyword rides in the value and the
798    // prelude (block) or params (statement) in the token's `use` bag.
799    if c == b'@' {
800        let mut k_end = s_i + 1;
801        while k_end < bytes.len() && is_at_char(bytes[k_end]) {
802            k_end += 1;
803        }
804        let kw = safe_slice(src, s_i + 1, k_end).to_string();
805        let (kind, index) = brace(s_i);
806        if UNTERMINATED == index {
807            return Plan::Bad;
808        }
809        if Kind::Selector == kind {
810            // At-rule preludes KEEP their comments (they are only trimmed),
811            // matching reworkcss.
812            let prelude = es_trim(safe_slice(src, k_end, index)).to_string();
813            let (name, tin) = if is_keyframes_kw(&kw) {
814                ("#ATK", BY_NAME)
815            } else if DECLS_KW.contains(&kw.as_str()) {
816                ("#ATD", BY_NAME)
817            } else {
818                ("#ATR", BY_NAME)
819            };
820            let raw = safe_slice(src, s_i, index);
821            return emit(name, tin, kw, raw, Some(("prelude", prelude)), index);
822        }
823        // A statement at-rule's params run to the next top-level `;`/`}`. A
824        // `;` is consumed (it ends the statement); a `}` or the end of the
825        // source is left for the enclosing rule.
826        let p_end = scan_value_end(bytes, k_end);
827        if UNTERMINATED == p_end {
828            return Plan::Bad;
829        }
830        let params = es_trim(safe_slice(src, k_end, p_end)).to_string();
831        let end = if bytes.get(p_end) == Some(&b';') {
832            p_end + 1
833        } else {
834            p_end
835        };
836        let raw = safe_slice(src, s_i, end);
837        return emit("#ATS", BY_NAME, kw, raw, Some(("params", params)), end);
838    }
839
840    // Other fixed punctuation belongs to the grammar. `:` does not: a run
841    // that starts with one is scanned below, as in the canonical port.
842    if c == b'{' || c == b'}' || c == b';' {
843        return Plan::Decline;
844    }
845
846    // A selector or a property name, by `{`-before-`;` lookahead.
847    let (kind, index) = brace(s_i);
848    if UNTERMINATED == index {
849        return Plan::Bad;
850    }
851    if Kind::Selector == kind {
852        // One selector of a (possible) group: up to the next top-level `,`
853        // or `{`, comments stripped and surrounding space trimmed.
854        let end = scan_selector_end(bytes, s_i);
855        if UNTERMINATED == end {
856            return Plan::Bad;
857        }
858        let raw = safe_slice(src, s_i, end);
859        let val = es_trim(&strip_comments(raw)).to_string();
860        return emit("#TX", TIN_TX, val, raw, None, end);
861    }
862
863    // A property name: the run of property characters up to `:`,
864    // whitespace, `;` or `}`. `/` and `*` are property characters (the
865    // `*prop` and `//prop` IE hacks), so a trailing `/*...*/` hack comment
866    // lands inside the scanned name; comments are stripped out of it, as
867    // reworkcss does.
868    let e_i = scan_prop_end(bytes, s_i);
869    if e_i == s_i {
870        return Plan::Decline;
871    }
872    let raw = safe_slice(src, s_i, e_i);
873    let mut prop = strip_comments(raw);
874    if prop.is_empty() {
875        return Plan::Decline;
876    }
877    if lower {
878        prop = prop.to_lowercase();
879    }
880    emit("#TX", TIN_TX, prop, raw, None, e_i)
881}
882
883/// The `cssToken` matcher, registered as `@css-token` and named by the
884/// grammar's options at order 1e5, ahead of every builtin matcher.
885///
886/// Two things here are deliberate and easy to "fix" wrongly:
887///
888/// - The end of a token may be ONE PAST the source. A `\` at the last
889///   character is an escape whose escaped character is not there, and the
890///   scanners' `i += 2` steps off the end. The canonical port advances its
891///   column by that unclamped width and takes the CLAMPED span for the
892///   token's text, so `@host\` yields a node ending at column 7 inside a
893///   stylesheet ending at column 8, one past a source six characters long.
894///   The engine will not move its cursor past the end, so the overshoot is
895///   recorded under [`OVERSHOOT`] and [`lex_subscriber`] adds it to the
896///   end-of-source token, the only token that can follow it.
897/// - An unclosed comment is reported WITHOUT advancing, so the diagnostic
898///   points at the start of the scan, as the canonical port's does.
899///
900/// A matcher must never advance and then decline: the engine captured its
901/// dispatch state before the custom matchers ran.
902pub(crate) fn css_token(
903    lexer: &mut Lexer<'_>,
904    rule: &mut Rule,
905    ctx: &mut Context,
906    lower: bool,
907) -> Option<Token> {
908    let point = lexer.point();
909    let s_i = point.site.si;
910    let (plan, chars, overshoot) = {
911        let src = lexer.source();
912        let bytes = src.as_bytes();
913        let mut brace = |at: usize| BraceScans::scan(ctx, bytes, at);
914        let plan = plan_token(src, s_i, rule.name.as_str(), lower, &mut brace);
915        let (chars, overshoot) = match &plan {
916            Plan::Decline => return None,
917            Plan::Bad => (
918                safe_slice(src, s_i, src.len())
919                    .chars()
920                    .take(BAD_SPAN)
921                    .count(),
922                0,
923            ),
924            Plan::Emit { end, .. } => (
925                safe_slice(src, s_i, (*end).min(src.len())).chars().count(),
926                end.saturating_sub(src.len()),
927            ),
928        };
929        (plan, chars, overshoot)
930    };
931    match plan {
932        Plan::Decline => None,
933        Plan::Bad => Some(lexer.bad_span(
934            "unterminated_comment",
935            point.site.pos,
936            point.site.pos + chars,
937        )),
938        Plan::Emit {
939            name,
940            tin,
941            val,
942            raw,
943            use_kv,
944            ..
945        } => {
946            lexer.advance_chars(chars);
947            if 0 < overshoot {
948                ctx.u
949                    .insert(OVERSHOOT.to_string(), Value::Number(overshoot as f64));
950            }
951            let mut token = lexer.token(name, tin, Value::String(val), raw, point);
952            if let Some((key, text)) = use_kv {
953                token
954                    .use_data_mut()
955                    .insert(key.to_string(), Value::String(text));
956            }
957            Some(token)
958        }
959    }
960}
961
962/// The plugin's lex subscriber: it sees every token the engine fetches.
963///
964/// - The end-of-source token takes the column overshoot [`css_token`]
965///   recorded, so the stylesheet's end and an error at the end of the source
966///   land where the canonical port puts them.
967/// - It once dropped the unconsumed lookahead when a bad token arrived
968///   behind a good one, so that an unclosed comment hidden from the
969///   property scan was the error reported rather than `unexpected` at the
970///   property. tabnas/parser#274 made the engine raise a fetched bad token
971///   at once, as the canonical does, and record and skip it under
972///   recovery, so that work is the engine's now. `test/spec/comments.tsv`
973///   and `tests/plugin.rs` pin the answers in every mode.
974pub(crate) fn lex_subscriber(token: &mut Token, _rule: &mut Rule, ctx: &mut Context) {
975    if TIN_ZZ == token.tin {
976        if let Some(Value::Number(n)) = ctx.u.get(OVERSHOOT) {
977            token.site.ci += *n as usize;
978        }
979    }
980}
981
982/// The number of astral characters (those two UTF-16 code units wide) in
983/// scalars `[from, to)` of `src`.
984pub(crate) fn astral_between(src: &str, from: usize, to: usize) -> usize {
985    src.chars()
986        .skip(from)
987        .take(to.saturating_sub(from))
988        .filter(|c| 2 == c.len_utf16())
989        .count()
990}
991
992/// An engine error as this crate's, with the column in UTF-16 code units.
993///
994/// The engine counts a column in Unicode scalars since the last reset (a
995/// `\n` in any token, or the end of a run of line characters); the canonical
996/// port counts UTF-16 code units since the same points. The two differ by
997/// the astral characters between the reset and the error, which is what is
998/// added here. The message is the engine's, and is not the contract.
999impl From<TabnasError> for Error {
1000    fn from(error: TabnasError) -> Error {
1001        let from = error.pos.saturating_sub(error.col.saturating_sub(1));
1002        let wide = astral_between(&error.full_source, from, error.pos);
1003        Error {
1004            code: error.code,
1005            message: error.detail,
1006            line: error.row,
1007            column: error.col + wide,
1008        }
1009    }
1010}