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