Skip to main content

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}