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}