caixa_ast/lexer.rs
1//! Lisp lexer — scans source into tokens with byte spans.
2//!
3//! Implementation: thin wrapper over [`logos`](https://docs.rs/logos)
4//! 0.14. The hand-rolled byte-level lexer that lived here previously
5//! shipped two latent bugs (UTF-8 mishandling, unterminated-string
6//! detection) and was not maintainable as the syntax grew. logos
7//! delegates regex/UTF-8 to its DFA engine and exposes byte spans
8//! directly, so this file shrinks to atoms + a few callbacks while
9//! getting strictly better correctness.
10//!
11//! Token alphabet (unchanged — parser.rs needs no edits):
12//! - `(` `)` — list delimiters
13//! - `'` `` ` `` `,` `,@` — reader macros
14//! - `"…"` — strings, with `\"` `\\` `\n` `\t` `\r` escapes
15//! - `#t` / `#f` — booleans
16//! - `nil` — the nil atom
17//! - integers / floats with optional sign
18//! - `:name-like` — keywords
19//! - `; …` — line comments
20//! - `\n+` (with surrounding spaces/`\r`/`\t`) — newline runs (carries
21//! the line count so the parser can decide blank-line trivia)
22//! - ` `/`\t` — whitespace (no count needed)
23//! - everything else is a symbol
24
25use std::num::{ParseFloatError, ParseIntError};
26
27use logos::{Lexer, Logos};
28use thiserror::Error;
29
30use crate::span::Span;
31
32/// The typed variant discriminator on the caixa-ast lexer surface — every
33/// [`Token`]'s carrying-shape (delimiter, reader-macro, atom, trivia)
34/// projects through this closed twenty-one-arm partition.
35///
36/// The [`gen_platform::IsVariant`] derive emits per-arm arm-discriminator
37/// predicates — [`Self::is_l_paren`], [`Self::is_r_paren`],
38/// [`Self::is_l_brace`], [`Self::is_r_brace`], [`Self::is_l_bracket`],
39/// [`Self::is_r_bracket`], [`Self::is_quote`], [`Self::is_quasiquote`],
40/// [`Self::is_unquote`], [`Self::is_unquote_splice`], [`Self::is_str`],
41/// [`Self::is_int`], [`Self::is_float`], [`Self::is_bool`], [`Self::is_nil`],
42/// [`Self::is_symbol`], [`Self::is_keyword`], [`Self::is_shebang`],
43/// [`Self::is_line_comment`], [`Self::is_newlines`], [`Self::is_whitespace`]
44/// — so every downstream consumer that only needs the arm-discriminator
45/// projection (not the borrowed field value) reaches for one typed dispatch
46/// on the substrate primitive rather than a hand-rolled `matches!(k,
47/// TokenKind::X | TokenKind::Y(_))` literal. Peer of the sibling
48/// [`crate::NodeKind`] / [`crate::trivia::TriviaKind`] `IsVariant` lifts
49/// already on the caixa-ast surface (7f6aa98 / 44873ae) — extends the same
50/// discipline onto the token-family axis every downstream lexer / parser /
51/// authoring consumer partitions on (the internal `tokenize` test-harness
52/// trivia filter today, a future `caixa-lint` no-tab-indentation or
53/// no-map-in-defcaixa-slot rule that walks tokens before parsing).
54#[derive(Debug, Clone, PartialEq, gen_platform::IsVariant)]
55pub enum TokenKind {
56 /// A verbatim `#!…` first line. See [`crate::trivia::TriviaKind::Shebang`].
57 Shebang(String),
58 LParen,
59 RParen,
60 LBrace,
61 RBrace,
62 LBracket,
63 RBracket,
64 Quote,
65 Quasiquote,
66 Unquote,
67 UnquoteSplice,
68 Str(String),
69 Int(i64),
70 Float(f64),
71 Bool(bool),
72 Nil,
73 Symbol(String),
74 Keyword(String),
75 LineComment(String),
76 Newlines(u32),
77 Whitespace,
78}
79
80#[derive(Debug, Clone, PartialEq)]
81pub struct Token {
82 pub kind: TokenKind,
83 pub span: Span,
84}
85
86#[derive(Debug, Default, Error, PartialEq, Eq, Clone)]
87pub enum LexError {
88 #[default]
89 #[error("unrecognized token")]
90 Unrecognized,
91 #[error("unterminated string at offset {0}")]
92 UnterminatedString(u32),
93 #[error("invalid escape sequence \\{1} at offset {0}")]
94 BadEscape(u32, char),
95 #[error("invalid number literal at offset {0}: {1}")]
96 BadInt(u32, String),
97 #[error("invalid float literal at offset {0}: {1}")]
98 BadFloat(u32, String),
99 #[error("unexpected character {1:?} at offset {0}")]
100 UnexpectedChar(u32, char),
101}
102
103impl From<(u32, ParseIntError)> for LexError {
104 fn from(v: (u32, ParseIntError)) -> Self {
105 Self::BadInt(v.0, v.1.to_string())
106 }
107}
108
109impl From<(u32, ParseFloatError)> for LexError {
110 fn from(v: (u32, ParseFloatError)) -> Self {
111 Self::BadFloat(v.0, v.1.to_string())
112 }
113}
114
115// ── logos token enum ──────────────────────────────────────────────
116//
117// Internal to the module. We translate to the public `TokenKind` /
118// `Token` types in `tokenize` so the parser keeps its existing API.
119
120#[derive(Logos, Debug, PartialEq)]
121#[logos(error = LexError)]
122enum LogosKind {
123 #[token("(")]
124 LParen,
125
126 #[token(")")]
127 RParen,
128
129 // The brace/vector dialect. `{ :k v }` and `[ a b ]` are REAL
130 // SYNTAX, not sugar — theory/TATARA-LISP-CONSOLIDATION.md D4, on the
131 // evidence of 62 live caixa.lisp manifests that author nested maps
132 // (`:package { :name "…" :version "…" }`) and are consumed today.
133 //
134 // Until now these four bytes had no token here at all: they fell
135 // through to the Symbol regex below, so a map lexed as a flat run of
136 // atoms with `{` and `}` as ordinary symbols. That made every real
137 // manifest an odd-length list to the printer, which is why `feira
138 // fmt` abandoned the key/value shape and exploded them one atom per
139 // line. caixa-ts/grammar.js has had `map` and `vector` rules from the
140 // start and its header says the two grammars are kept in lockstep —
141 // this closes the gap on the Rust side.
142 #[token("{")]
143 LBrace,
144
145 #[token("}")]
146 RBrace,
147
148 #[token("[")]
149 LBracket,
150
151 #[token("]")]
152 RBracket,
153
154 #[token("'")]
155 Quote,
156
157 #[token("`")]
158 Quasiquote,
159
160 // `,@` MUST come before `,` so it wins on the longest-match.
161 #[token(",@")]
162 UnquoteSplice,
163
164 #[token(",")]
165 Unquote,
166
167 #[token("#t", |_| true)]
168 #[token("#f", |_| false)]
169 Bool(bool),
170
171 // Strings: opening `"`, then repeated non-`\`/non-`"` chars OR
172 // backslash-something escapes, then closing `"`. The callback
173 // unescapes the body. UTF-8 is delegated to logos / regex.
174 #[regex(r#""(?:[^"\\]|\\.)*""#, lex_string_body)]
175 Str(String),
176
177 // Numbers: integer first (priority 3 so it doesn't lose to symbol).
178 // Float separately — has a `.` or `e/E`.
179 #[regex(r"[+-]?[0-9]+", priority = 3, callback = parse_int)]
180 Int(i64),
181
182 #[regex(
183 r"[+-]?(?:[0-9]+\.[0-9]*|\.[0-9]+|[0-9]+[eE][+-]?[0-9]+|[0-9]+\.[0-9]*[eE][+-]?[0-9]+|\.[0-9]+[eE][+-]?[0-9]+)",
184 priority = 3,
185 callback = parse_float
186 )]
187 Float(f64),
188
189 // Keyword: `:` followed by atom chars. `{}[]` terminate it, or
190 // `:version "0.3.0"}` would lex the closing brace into the keyword.
191 #[regex(":[^\\s()'`,\";\\{\\}\\[\\]]+", |lex| lex.slice()[1..].to_string())]
192 Keyword(String),
193
194 // Line comment: `;` to end of line. The leading `;` is NOT
195 // included in the captured body, matching the prior behavior.
196 #[regex(r";[^\n]*", |lex| {
197 let s = lex.slice();
198 // strip the leading ';'
199 s[1..].to_string()
200 })]
201 LineComment(String),
202
203 // Newline runs: any \n followed by whitespace including more \n's.
204 // The callback counts \n bytes so blank-line detection works
205 // exactly as before (count >= 2 means a blank line).
206 #[regex(r"[\n][ \t\r\n]*", count_newlines)]
207 Newlines(u32),
208
209 // Pure-space whitespace (no newline). Intentional and separate
210 // from Newlines so the parser can skip both without losing
211 // line-count info.
212 #[regex(r"[ \t\r]+")]
213 Whitespace,
214
215 // Anything else is a symbol or `nil`. The atom-terminator set
216 // matches the prior is_atom_terminator (space/tab/cr/lf/parens/
217 // single-quote/backtick/comma/double-quote/semicolon) PLUS `#`,
218 // which is the boolean / reader-macro dispatch prefix and never
219 // appears inside a tatara-lisp symbol. Excluding `#` here lets
220 // adjacent forms like `#t#f` tokenize as two booleans rather
221 // than a single `#t#f` symbol.
222 // `{}[]` join the terminator set for the same reason `()` are in it:
223 // they are structural delimiters now, so `{:name` must lex as LBrace
224 // + Keyword rather than as one symbol `{:name`. caixa-ts states the
225 // same set as an ALLOW-list (`[A-Za-z_+\-*/=<>?!%&~.]…`), which
226 // already excluded braces — this is the Rust side catching up.
227 #[regex(
228 "[^\\s()'`,\";#\\{\\}\\[\\]][^\\s()'`,\";#\\{\\}\\[\\]]*",
229 |lex| lex.slice().to_string()
230 )]
231 Symbol(String),
232}
233
234// ── callbacks ─────────────────────────────────────────────────────
235
236fn lex_string_body(lex: &mut Lexer<LogosKind>) -> Result<String, LexError> {
237 let raw = lex.slice();
238 debug_assert!(raw.starts_with('"') && raw.ends_with('"'));
239 let inner = &raw[1..raw.len() - 1];
240 let span_start = u32::try_from(lex.span().start).unwrap_or(u32::MAX);
241
242 let mut out = String::with_capacity(inner.len());
243 let mut chars = inner.char_indices();
244 while let Some((i, c)) = chars.next() {
245 if c == '\\' {
246 match chars.next() {
247 Some((_, 'n')) => out.push('\n'),
248 Some((_, 't')) => out.push('\t'),
249 Some((_, 'r')) => out.push('\r'),
250 Some((_, '"')) => out.push('"'),
251 Some((_, '\\')) => out.push('\\'),
252 // An UNKNOWN escape yields the character itself, dropping
253 // the backslash — matching the canonical reader exactly
254 // (`tatara-lisp/src/reader.rs`: `other => other`).
255 //
256 // Rejecting these was a real divergence, not strictness:
257 // `actions/db-migrate/run.tlisp` carries a grep pattern
258 // written `'Applied\|migration\|up to date'`, which the
259 // canonical reader accepts and this lexer refused, so the
260 // formatter could not read a file the runtime runs. Two
261 // readers disagreeing about what the language IS is the
262 // concrete cost of the fleet's 13 independent
263 // S-expression readers; here the canonical one is the
264 // oracle and this one conforms.
265 Some((_, other)) => out.push(other),
266 None => {
267 return Err(LexError::BadEscape(
268 span_start + 1 + u32::try_from(i).unwrap_or(0),
269 '\\',
270 ));
271 }
272 }
273 } else {
274 out.push(c);
275 }
276 }
277 Ok(out)
278}
279
280fn parse_int(lex: &mut Lexer<LogosKind>) -> Result<i64, LexError> {
281 let span_start = u32::try_from(lex.span().start).unwrap_or(u32::MAX);
282 lex.slice()
283 .parse::<i64>()
284 .map_err(|e| LexError::BadInt(span_start, e.to_string()))
285}
286
287fn parse_float(lex: &mut Lexer<LogosKind>) -> Result<f64, LexError> {
288 let span_start = u32::try_from(lex.span().start).unwrap_or(u32::MAX);
289 lex.slice()
290 .parse::<f64>()
291 .map_err(|e| LexError::BadFloat(span_start, e.to_string()))
292}
293
294fn count_newlines(lex: &mut Lexer<LogosKind>) -> u32 {
295 let s = lex.slice();
296 let n = s.bytes().filter(|&b| b == b'\n').count();
297 u32::try_from(n).unwrap_or(u32::MAX)
298}
299
300// ── public entry point ────────────────────────────────────────────
301
302/// Scan a source string into tokens. Trivia (whitespace, comments) is
303/// preserved — the parser filters what it doesn't need.
304pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
305 let mut out = Vec::new();
306
307 // A leading `#!` line is a shebang, not source. Emitted as its own
308 // token so it survives formatting verbatim; logos never sees it, since
309 // `#` is not otherwise part of the grammar. Only at offset 0 — a `#!`
310 // anywhere else is genuinely invalid and must still be an error.
311 let body_start = if src.starts_with("#!") {
312 let end = src.find('\n').unwrap_or(src.len());
313 out.push(Token {
314 kind: TokenKind::Shebang(src[..end].to_string()),
315 span: Span::new(0, u32::try_from(end).unwrap_or(u32::MAX)),
316 });
317 end
318 } else {
319 0
320 };
321
322 let mut lex = LogosKind::lexer(&src[body_start..]);
323
324 while let Some(result) = lex.next() {
325 let span = lex.span();
326 let span_start = u32::try_from(span.start + body_start).unwrap_or(u32::MAX);
327 let span_end = u32::try_from(span.end + body_start).unwrap_or(u32::MAX);
328 let span = Span::new(span_start, span_end);
329
330 match result {
331 Ok(kind) => {
332 let public = match kind {
333 LogosKind::LParen => TokenKind::LParen,
334 LogosKind::RParen => TokenKind::RParen,
335 LogosKind::LBrace => TokenKind::LBrace,
336 LogosKind::RBrace => TokenKind::RBrace,
337 LogosKind::LBracket => TokenKind::LBracket,
338 LogosKind::RBracket => TokenKind::RBracket,
339 LogosKind::Quote => TokenKind::Quote,
340 LogosKind::Quasiquote => TokenKind::Quasiquote,
341 LogosKind::Unquote => TokenKind::Unquote,
342 LogosKind::UnquoteSplice => TokenKind::UnquoteSplice,
343 LogosKind::Bool(b) => TokenKind::Bool(b),
344 LogosKind::Str(s) => TokenKind::Str(s),
345 LogosKind::Int(i) => TokenKind::Int(i),
346 LogosKind::Float(f) => TokenKind::Float(f),
347 LogosKind::Keyword(s) => TokenKind::Keyword(s),
348 LogosKind::LineComment(s) => TokenKind::LineComment(s),
349 LogosKind::Newlines(n) => TokenKind::Newlines(n),
350 LogosKind::Whitespace => TokenKind::Whitespace,
351 LogosKind::Symbol(s) => {
352 if s == "nil" {
353 TokenKind::Nil
354 } else {
355 TokenKind::Symbol(s)
356 }
357 }
358 };
359 out.push(Token { kind: public, span });
360 }
361 Err(_) => {
362 // Unrecognized byte — most likely an unterminated
363 // string (since strings are the only multi-byte form
364 // that can fail to close). Distinguish them by source
365 // shape so the LexError carries the right variant.
366 let slice = lex.slice();
367 if slice.starts_with('"') {
368 return Err(LexError::UnterminatedString(span_start));
369 }
370 let ch = slice.chars().next().unwrap_or(' ');
371 return Err(LexError::UnexpectedChar(span_start, ch));
372 }
373 }
374 }
375
376 Ok(out)
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 fn kinds(src: &str) -> Vec<TokenKind> {
384 // Route the trivia-filter through the `gen_platform::IsVariant`-
385 // derived per-arm predicates on `TokenKind` — one typed dispatch
386 // on the substrate primitive per axis rather than a hand-rolled
387 // arm-set `matches!` literal. Byte-parity witness lives at
388 // `token_kind_is_whitespace_and_is_newlines_byte_equal_pre_lift_matches_shape`.
389 tokenize(src)
390 .unwrap()
391 .into_iter()
392 .map(|t| t.kind)
393 .filter(|k| !k.is_whitespace() && !k.is_newlines())
394 .collect()
395 }
396
397 // `3.14` below is the *expected lex output* for the input string
398 // `"3.14"` — a float-literal round-trip fixture, not an approximation
399 // of `f64::consts::PI` used in a computation. `clippy::approx_constant`
400 // is deny-by-default (correctness group), so without this scoped allow
401 // `cargo clippy` aborts this crate with a hard error and never reports
402 // the rest of the workspace at all. Substituting `PI` here would break
403 // the round-trip the assertion exists to prove.
404 #[allow(
405 clippy::approx_constant,
406 reason = "float-literal lex fixture, not a PI approximation"
407 )]
408 #[test]
409 fn basic_atoms() {
410 assert_eq!(kinds("42"), vec![TokenKind::Int(42)]);
411 assert_eq!(kinds("3.14"), vec![TokenKind::Float(3.14)]);
412 assert_eq!(kinds("-7"), vec![TokenKind::Int(-7)]);
413 assert_eq!(kinds("#t"), vec![TokenKind::Bool(true)]);
414 assert_eq!(kinds("#f"), vec![TokenKind::Bool(false)]);
415 assert_eq!(kinds("nil"), vec![TokenKind::Nil]);
416 assert_eq!(kinds("\"hi\\n\""), vec![TokenKind::Str("hi\n".into())]);
417 assert_eq!(
418 kinds(":key-word"),
419 vec![TokenKind::Keyword("key-word".into())]
420 );
421 assert_eq!(kinds("my-sym"), vec![TokenKind::Symbol("my-sym".into())]);
422 }
423
424 #[test]
425 fn lists_and_readers() {
426 assert_eq!(
427 kinds("(a b)"),
428 vec![
429 TokenKind::LParen,
430 TokenKind::Symbol("a".into()),
431 TokenKind::Symbol("b".into()),
432 TokenKind::RParen,
433 ]
434 );
435 assert_eq!(
436 kinds("'x"),
437 vec![TokenKind::Quote, TokenKind::Symbol("x".into())]
438 );
439 assert_eq!(
440 kinds(",@xs"),
441 vec![TokenKind::UnquoteSplice, TokenKind::Symbol("xs".into())]
442 );
443 }
444
445 #[test]
446 fn line_comment() {
447 let toks = tokenize("; hello\nworld").unwrap();
448 assert!(matches!(toks[0].kind, TokenKind::LineComment(ref s) if s == " hello"));
449 assert!(matches!(toks[1].kind, TokenKind::Newlines(_)));
450 assert!(matches!(toks[2].kind, TokenKind::Symbol(ref s) if s == "world"));
451 }
452
453 #[test]
454 fn unterminated_string_errors() {
455 assert!(matches!(
456 tokenize(r#""oops"#),
457 Err(LexError::UnterminatedString(_))
458 ));
459 }
460
461 #[test]
462 fn utf8_in_string_round_trip() {
463 // Multi-byte chars (Greek, emoji, accented) must come back
464 // exactly — the previous byte-as-Latin-1 lexer mangled these.
465 let src = r#""π — émoji 🎉""#;
466 let toks = tokenize(src).unwrap();
467 match &toks[0].kind {
468 TokenKind::Str(s) => assert_eq!(s, "π — émoji 🎉"),
469 other => panic!("{other:?}"),
470 }
471 }
472
473 #[test]
474 fn newline_run_preserves_count() {
475 let toks = tokenize("a\n\n\nb").unwrap();
476 // a, newlines(3), b
477 assert!(matches!(toks[0].kind, TokenKind::Symbol(ref s) if s == "a"));
478 match toks[1].kind {
479 TokenKind::Newlines(n) => assert_eq!(n, 3),
480 ref other => panic!("{other:?}"),
481 }
482 assert!(matches!(toks[2].kind, TokenKind::Symbol(ref s) if s == "b"));
483 }
484
485 #[test]
486 fn float_with_exponent() {
487 assert_eq!(kinds("1.5e10"), vec![TokenKind::Float(1.5e10)]);
488 assert_eq!(kinds("1e-3"), vec![TokenKind::Float(1e-3)]);
489 assert_eq!(kinds("-2.5E2"), vec![TokenKind::Float(-2.5e2)]);
490 }
491
492 #[test]
493 fn bool_keyword_clash_handled() {
494 // `#t#f` should tokenize as two booleans (no separator
495 // required). Logos' longest-match handles this for free.
496 assert_eq!(
497 kinds("#t#f"),
498 vec![TokenKind::Bool(true), TokenKind::Bool(false)]
499 );
500 }
501}
502
503#[cfg(test)]
504mod is_variant_tests {
505 use super::*;
506
507 fn all_variants() -> Vec<(TokenKind, &'static str)> {
508 vec![
509 (TokenKind::Shebang("#!/env t".into()), "Shebang"),
510 (TokenKind::LParen, "LParen"),
511 (TokenKind::RParen, "RParen"),
512 (TokenKind::LBrace, "LBrace"),
513 (TokenKind::RBrace, "RBrace"),
514 (TokenKind::LBracket, "LBracket"),
515 (TokenKind::RBracket, "RBracket"),
516 (TokenKind::Quote, "Quote"),
517 (TokenKind::Quasiquote, "Quasiquote"),
518 (TokenKind::Unquote, "Unquote"),
519 (TokenKind::UnquoteSplice, "UnquoteSplice"),
520 (TokenKind::Str("s".into()), "Str"),
521 (TokenKind::Int(0), "Int"),
522 (TokenKind::Float(0.0), "Float"),
523 (TokenKind::Bool(false), "Bool"),
524 (TokenKind::Nil, "Nil"),
525 (TokenKind::Symbol("x".into()), "Symbol"),
526 (TokenKind::Keyword("k".into()), "Keyword"),
527 (TokenKind::LineComment(" c".into()), "LineComment"),
528 (TokenKind::Newlines(1), "Newlines"),
529 (TokenKind::Whitespace, "Whitespace"),
530 ]
531 }
532
533 fn predicate_row(k: &TokenKind) -> [bool; 21] {
534 [
535 k.is_shebang(),
536 k.is_l_paren(),
537 k.is_r_paren(),
538 k.is_l_brace(),
539 k.is_r_brace(),
540 k.is_l_bracket(),
541 k.is_r_bracket(),
542 k.is_quote(),
543 k.is_quasiquote(),
544 k.is_unquote(),
545 k.is_unquote_splice(),
546 k.is_str(),
547 k.is_int(),
548 k.is_float(),
549 k.is_bool(),
550 k.is_nil(),
551 k.is_symbol(),
552 k.is_keyword(),
553 k.is_line_comment(),
554 k.is_newlines(),
555 k.is_whitespace(),
556 ]
557 }
558
559 // Fail-before-pass-after pin on the [`gen_platform::IsVariant`]
560 // derive-generated per-arm predicate partition — for every variant
561 // in `all_variants()`, the observed 21-slot predicate row must
562 // equal a one-hot row with the `true` at exactly the same index as
563 // the variant's declaration order. Expected rows are generated
564 // live from the enumeration rather than transcribed by hand, so a
565 // copy-paste flip that reroutes one arm through the wrong
566 // predicate lane trips at the identity-diagonal assertion the way
567 // every peer sibling [`crate::NodeKind`] /
568 // [`crate::trivia::TriviaKind`] / `CaixaKind` / `CaixaDialeto` /
569 // `PathShapeViolation` / `RestartStrategy` / `DepSource`
570 // partition pin already does.
571 #[test]
572 fn token_kind_is_variant_predicates_partition_the_arm_set() {
573 let variants = all_variants();
574 for (idx, (variant, name)) in variants.iter().enumerate() {
575 let observed = predicate_row(variant);
576 let mut expected = [false; 21];
577 expected[idx] = true;
578 assert_eq!(
579 observed, expected,
580 "TokenKind::{name} at declaration-order slot {idx} must \
581 satisfy exactly one is_* predicate (its own); observed \
582 row must equal the one-hot expected row"
583 );
584 }
585 }
586
587 // Byte-parity pin on the two field-agnostic `matches!` shapes this
588 // lift replaces at the production trivia-filter call site
589 // (`kinds` test-harness helper, `caixa-ast/src/lexer.rs`
590 // `!matches!(k, TokenKind::Whitespace | TokenKind::Newlines(_))`).
591 // Refuses a future accidental split between the derived predicate
592 // and its pre-lift `matches!` shape (a hand-rolled shadow `impl`
593 // that overrides one path, an accidental rebrand of one converged
594 // call site back to the `matches!` form) on either load-bearing
595 // trivia-arm-discriminator axis every downstream lexer / parser /
596 // authoring consumer of the caixa-ast token surface keys off.
597 #[test]
598 fn token_kind_is_whitespace_and_is_newlines_byte_equal_pre_lift_matches_shape() {
599 for (variant, name) in all_variants() {
600 let via_matches_ws = matches!(variant, TokenKind::Whitespace);
601 let via_predicate_ws = variant.is_whitespace();
602 assert_eq!(
603 via_predicate_ws, via_matches_ws,
604 "TokenKind::{name}.is_whitespace() must byte-equal \
605 matches!(_, TokenKind::Whitespace) — otherwise the \
606 converged trivia-filter in `kinds` would silently \
607 disagree with its pre-lift shape"
608 );
609 let via_matches_nl = matches!(variant, TokenKind::Newlines(_));
610 let via_predicate_nl = variant.is_newlines();
611 assert_eq!(
612 via_predicate_nl, via_matches_nl,
613 "TokenKind::{name}.is_newlines() must byte-equal \
614 matches!(_, TokenKind::Newlines(_)) — otherwise the \
615 converged trivia-filter in `kinds` would silently \
616 disagree with its pre-lift shape"
617 );
618 }
619 }
620}