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}