Skip to main content

polydat_grammar/
lexer.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Lexer for the Polydat DSL.
5//!
6//! Tokenizes `.polydat` source text into a flat token stream. The grammar
7//! is line-oriented but the lexer doesn't enforce line structure —
8//! that's the parser's job.
9
10/// A source location for error reporting.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Span {
13    /// The line, from 1.
14    pub line: usize,
15    /// The column, from 1.
16    pub col: usize,
17}
18
19/// A token with its source location.
20#[derive(Debug, Clone, PartialEq)]
21pub struct Token {
22    /// What the token is.
23    pub kind: TokenKind,
24    /// Where it appears.
25    pub span: Span,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29/// The kinds of token the lexer produces.
30pub enum TokenKind {
31    /// A bare identifier: `cycle`, `hash`, `temp_lut`
32    Ident(String),
33    /// `const` keyword — declares an effectively-const binding.
34    /// Replaces the former `final` / `init` distinction; both
35    /// surfaces collapsed into one. The value is materialized at
36    /// the earliest opportunity (compile-time fold if possible,
37    /// scope-init pull otherwise) and is then immutable for the
38    /// scope's lifetime. Independent of provenance: a `const`
39    /// binding's RHS may reference workload params, iter-vars
40    /// bound by an enclosing comprehension, or other in-scope
41    /// names — whatever the Polydat compiler can resolve.
42    Const,
43    /// `input` keyword — declares one per-cycle kernel input slot.
44    /// Surface: `input <name>[: <type>]` (single) or
45    /// `input (<name>[: <type>], ...)` (tuple sugar). Mirrors the
46    /// module-signature param-list shape.
47    Input,
48    /// `extern` keyword
49    Extern,
50    /// `shared` keyword
51    Shared,
52    /// `volatile` keyword. Wire-coloring modifier excluding the
53    /// binding's value from `hash_const`.
54    Volatile,
55    /// `cursor` keyword
56    Cursor,
57    /// `over` keyword — SRD 71 partition-source binding on
58    /// cursor declarations: `cursor q = range(0, N) over p`.
59    /// Soft keyword: only recognised at statement level after
60    /// a cursor decl's constructor expression; in expression
61    /// position it's a plain identifier.
62    Over,
63    /// `pragma` keyword (module-level directive opening, SRD 15
64    /// §"Module-Level Pragmas"). Followed by an `Ident` naming the
65    /// pragma. Distinct from line comments so the parser sees
66    /// pragmas as first-class statements rather than scraping them
67    /// out of `// @pragma:` text.
68    Pragma,
69    /// `for` followed by its raw comprehension text (SRD 113). The
70    /// lexer captures everything after the keyword up to a `{` at
71    /// nesting depth zero or the end of the line, whichever comes
72    /// first, so the comprehension grammar stays owned by the
73    /// comprehension parser rather than being re-tokenized here.
74    For(String),
75    /// `tile` keyword (SRD 114). The body after `:=` is captured raw
76    /// into a [`TokenKind::TileBody`] when it is a block or heredoc.
77    Tile,
78    /// A raw tile body and how it was written.
79    TileBody(String, crate::ast::TileBodyKind),
80    /// `.` (field access: `base.ordinal`)
81    Dot,
82    /// Integer literal: `1000`, `0xFF`
83    IntLit(u64),
84    /// Float literal: `72.0`, `3.14`
85    FloatLit(f64),
86    /// String literal (contents only, no quotes): `"hello {name}"`
87    StringLit(String),
88    /// `:=` (binding operator, used by every binding shape:
89    /// cycle bindings, `const`, `shared`, `volatile`)
90    ColonEq,
91    /// `=` — the extern default (`extern name: type = default`)
92    /// and the cursor declaration (`cursor name = expr`); bindings
93    /// use `:=`.
94    Eq,
95    /// `(`
96    LParen,
97    /// `)`
98    RParen,
99    /// `[`
100    LBracket,
101    /// `]`
102    RBracket,
103    /// `{`
104    LBrace,
105    /// `}`
106    RBrace,
107    /// `,`
108    Comma,
109    /// `:`
110    Colon,
111    /// `->`
112    Arrow,
113    /// `+`
114    Plus,
115    /// `-` (both binary subtract and unary negate)
116    Minus,
117    /// `*`
118    Star,
119    /// `/`
120    Slash,
121    /// `%`
122    Percent,
123    /// `^` (bitwise XOR)
124    Caret,
125    /// `**` (power)
126    StarStar,
127    /// `<<` (shift left)
128    ShiftLeft,
129    /// `>>` (shift right)
130    ShiftRight,
131    /// `&` (bitwise AND)
132    Ampersand,
133    /// `&&` (logical AND — SRD-84 Part 1)
134    AmpAmp,
135    /// `|` (bitwise OR)
136    Pipe,
137    /// `||` (logical OR — SRD-84 Part 1)
138    PipePipe,
139    /// `!` (unary bitwise NOT)
140    Bang,
141    /// `<` (less-than comparison)
142    Lt,
143    /// `>` (greater-than comparison)
144    Gt,
145    /// `==` (equal-to comparison)
146    EqEq,
147    /// `!=` (not-equal-to comparison)
148    BangEq,
149    /// `<=` (less-than-or-equal comparison)
150    LtEq,
151    /// `>=` (greater-than-or-equal comparison)
152    GtEq,
153    /// End of input
154    Eof,
155}
156
157/// Lex source text into tokens.
158pub fn lex(source: &str) -> Result<Vec<Token>, String> {
159    let mut tokens = Vec::new();
160    let chars: Vec<char> = source.chars().collect();
161    let mut pos = 0;
162    let mut line = 1;
163    let mut col = 1;
164    // Set by the `tile` keyword; the next `:=` captures a raw body.
165    let mut tile_pending = false;
166
167    while pos < chars.len() {
168        let c = chars[pos];
169
170        // Skip whitespace (but track line/col)
171        if c == '\n' {
172            line += 1;
173            col = 1;
174            pos += 1;
175            // A tile header and its `:=` sit on one line; a header that
176            // ends without one is the parser's error to report, not a
177            // reason to read the next binding's value as a tile body.
178            tile_pending = false;
179            continue;
180        }
181        if c.is_ascii_whitespace() {
182            col += 1;
183            pos += 1;
184            continue;
185        }
186
187        // Skip hash comments (# to end of line, YAML-style)
188        if c == '#' {
189            while pos < chars.len() && chars[pos] != '\n' {
190                pos += 1;
191            }
192            continue;
193        }
194
195        // Skip comments (// and /* */)
196        if c == '/' && pos + 1 < chars.len() {
197            if chars[pos + 1] == '/' {
198                // Line comment (// or ///) — skip to end of line
199                while pos < chars.len() && chars[pos] != '\n' {
200                    pos += 1;
201                }
202                continue;
203            }
204            if chars[pos + 1] == '*' {
205                // Block comment /* ... */ — skip to closing */
206                pos += 2;
207                col += 2;
208                while pos + 1 < chars.len() {
209                    if chars[pos] == '\n' {
210                        line += 1;
211                        col = 1;
212                    }
213                    if chars[pos] == '*' && chars[pos + 1] == '/' {
214                        pos += 2;
215                        col += 2;
216                        break;
217                    }
218                    pos += 1;
219                    col += 1;
220                }
221                continue;
222            }
223        }
224
225        // Arithmetic operator `/` (not a comment start)
226        if c == '/' {
227            let span = Span { line, col };
228            tokens.push(Token {
229                kind: TokenKind::Slash,
230                span,
231            });
232            pos += 1;
233            col += 1;
234            continue;
235        }
236
237        let span = Span { line, col };
238
239        // A tile binds a wire, so its body follows `:=` like any other
240        // binding's value. A block that follows the header directly is
241        // left to the parser, which reports the missing `:=`.
242
243        // A heredoc anywhere else is a string literal: `<<<` ... `>>>`
244        // with one newline trimmed from each end. This is how a
245        // template handed in by a host arrives as an argument, as in
246        // `doc := polytile("json", <<< ... >>>)`.
247        if !tile_pending
248            && chars[pos..].starts_with(&['<', '<', '<'])
249            && let Some((body, _, consumed, newlines, end_col)) =
250                capture_tile_body(&chars, pos, col)?
251        {
252            tokens.push(Token {
253                kind: TokenKind::StringLit(body),
254                span,
255            });
256            pos += consumed;
257            if newlines > 0 {
258                line += newlines;
259                col = end_col;
260            } else {
261                col += consumed;
262            }
263            continue;
264        }
265
266        // Two-character operators
267        if c == ':' && pos + 1 < chars.len() && chars[pos + 1] == '=' {
268            tokens.push(Token {
269                kind: TokenKind::ColonEq,
270                span,
271            });
272            pos += 2;
273            col += 2;
274            if tile_pending {
275                tile_pending = false;
276                if let Some((body, kind, consumed, newlines, end_col)) =
277                    capture_tile_body(&chars, pos, col)?
278                {
279                    tokens.push(Token {
280                        kind: TokenKind::TileBody(body, kind),
281                        span: Span { line, col },
282                    });
283                    pos += consumed;
284                    if newlines > 0 {
285                        line += newlines;
286                        col = end_col;
287                    } else {
288                        col += consumed;
289                    }
290                }
291            }
292            continue;
293        }
294        if c == '-' && pos + 1 < chars.len() && chars[pos + 1] == '>' {
295            tokens.push(Token {
296                kind: TokenKind::Arrow,
297                span,
298            });
299            pos += 2;
300            col += 2;
301            continue;
302        }
303
304        // Number literal (integer or float).
305        // The `-` character is always lexed as `Minus`; negative values
306        // are handled by the parser via `UnaryNeg`.
307        if c.is_ascii_digit() {
308            let start = pos;
309
310            // Check for hex: 0x...
311            if pos + 1 < chars.len()
312                && chars[pos] == '0'
313                && (chars[pos + 1] == 'x' || chars[pos + 1] == 'X')
314            {
315                pos += 2;
316                col += 2;
317                let hex_start = pos;
318                while pos < chars.len() && chars[pos].is_ascii_hexdigit() {
319                    pos += 1;
320                    col += 1;
321                }
322                let hex: String = chars[hex_start..pos].iter().collect();
323                let val = u64::from_str_radix(&hex, 16).map_err(|e| {
324                    format!(
325                        "invalid hex literal at line {}, col {}: {e}",
326                        span.line, span.col
327                    )
328                })?;
329                tokens.push(Token {
330                    kind: TokenKind::IntLit(val),
331                    span,
332                });
333                continue;
334            }
335
336            while pos < chars.len() && chars[pos].is_ascii_digit() {
337                pos += 1;
338                col += 1;
339            }
340
341            // Check for float
342            let mut is_float = false;
343            if pos < chars.len()
344                && chars[pos] == '.'
345                && pos + 1 < chars.len()
346                && chars[pos + 1].is_ascii_digit()
347            {
348                is_float = true;
349                pos += 1;
350                col += 1;
351                while pos < chars.len() && chars[pos].is_ascii_digit() {
352                    pos += 1;
353                    col += 1;
354                }
355                // Scientific notation
356                if pos < chars.len() && (chars[pos] == 'e' || chars[pos] == 'E') {
357                    pos += 1;
358                    col += 1;
359                    if pos < chars.len() && (chars[pos] == '+' || chars[pos] == '-') {
360                        pos += 1;
361                        col += 1;
362                    }
363                    while pos < chars.len() && chars[pos].is_ascii_digit() {
364                        pos += 1;
365                        col += 1;
366                    }
367                }
368            } else if pos < chars.len() && (chars[pos] == 'e' || chars[pos] == 'E') {
369                // Scientific notation without a decimal point: 1e10, 2E-3
370                is_float = true;
371                pos += 1;
372                col += 1;
373                if pos < chars.len() && (chars[pos] == '+' || chars[pos] == '-') {
374                    pos += 1;
375                    col += 1;
376                }
377                while pos < chars.len() && chars[pos].is_ascii_digit() {
378                    pos += 1;
379                    col += 1;
380                }
381            }
382
383            // SRD-18c Layer 6 / SRD-18e Push 4: SI suffix
384            // literals. Suffix attaches to the just-lexed
385            // numeric literal when the next 1-2 chars form
386            // a known suffix AND the char after the suffix
387            // isn't an identifier-continuation character
388            // (so `Kilometers` stays a valid identifier
389            // following an unrelated `1`).
390            let suffix_consumed = match peek_si_suffix(&chars, pos) {
391                Some((multiplier, len, is_subunit)) => {
392                    pos += len;
393                    col += len;
394                    Some((multiplier, is_subunit))
395                }
396                None => None,
397            };
398
399            if is_float {
400                let num: String = chars[start..pos - suffix_len_consumed(suffix_consumed)]
401                    .iter()
402                    .collect();
403                let mut val: f64 = num.parse().map_err(|e| {
404                    format!("invalid float at line {}, col {}: {e}", span.line, span.col)
405                })?;
406                if let Some((mult, is_sub)) = suffix_consumed {
407                    if is_sub {
408                        val /= mult as f64;
409                    } else {
410                        val *= mult as f64;
411                    }
412                }
413                // SI-promoted floats become U64 when the
414                // result is exactly integral (`1.5G` →
415                // 1_500_000_000 → U64) — matches SRD-18c
416                // §"Layer 6" type-resolution rule.
417                if suffix_consumed.is_some()
418                    && val.fract() == 0.0
419                    && val >= 0.0
420                    && val <= u64::MAX as f64
421                {
422                    tokens.push(Token {
423                        kind: TokenKind::IntLit(val as u64),
424                        span,
425                    });
426                } else {
427                    tokens.push(Token {
428                        kind: TokenKind::FloatLit(val),
429                        span,
430                    });
431                }
432            } else {
433                // Skip trailing underscore separators in int literals
434                let num_end = pos - suffix_len_consumed(suffix_consumed);
435                let num: String = chars[start..num_end]
436                    .iter()
437                    .filter(|c| **c != '_')
438                    .collect();
439                let val: u64 = num.parse().map_err(|e| {
440                    format!(
441                        "invalid integer at line {}, col {}: {e}",
442                        span.line, span.col
443                    )
444                })?;
445                match suffix_consumed {
446                    Some((mult, true)) => {
447                        // Sub-unit suffix on integer →
448                        // float (`5m` = 0.005).
449                        let f = val as f64 / mult as f64;
450                        tokens.push(Token {
451                            kind: TokenKind::FloatLit(f),
452                            span,
453                        });
454                    }
455                    Some((mult, false)) => {
456                        let v = val.checked_mul(mult).ok_or_else(|| {
457                            format!(
458                                "integer literal with SI suffix overflows u64 at line {}, col {}",
459                                span.line, span.col
460                            )
461                        })?;
462                        tokens.push(Token {
463                            kind: TokenKind::IntLit(v),
464                            span,
465                        });
466                    }
467                    None => {
468                        tokens.push(Token {
469                            kind: TokenKind::IntLit(val),
470                            span,
471                        });
472                    }
473                }
474            }
475            continue;
476        }
477
478        // Single-character tokens
479        match c {
480            '(' => {
481                tokens.push(Token {
482                    kind: TokenKind::LParen,
483                    span,
484                });
485                pos += 1;
486                col += 1;
487                continue;
488            }
489            ')' => {
490                tokens.push(Token {
491                    kind: TokenKind::RParen,
492                    span,
493                });
494                pos += 1;
495                col += 1;
496                continue;
497            }
498            '[' => {
499                tokens.push(Token {
500                    kind: TokenKind::LBracket,
501                    span,
502                });
503                pos += 1;
504                col += 1;
505                continue;
506            }
507            ']' => {
508                tokens.push(Token {
509                    kind: TokenKind::RBracket,
510                    span,
511                });
512                pos += 1;
513                col += 1;
514                continue;
515            }
516            '{' => {
517                tokens.push(Token {
518                    kind: TokenKind::LBrace,
519                    span,
520                });
521                pos += 1;
522                col += 1;
523                continue;
524            }
525            '}' => {
526                tokens.push(Token {
527                    kind: TokenKind::RBrace,
528                    span,
529                });
530                pos += 1;
531                col += 1;
532                continue;
533            }
534            ',' => {
535                tokens.push(Token {
536                    kind: TokenKind::Comma,
537                    span,
538                });
539                pos += 1;
540                col += 1;
541                continue;
542            }
543            '=' => {
544                if pos + 1 < chars.len() && chars[pos + 1] == '=' {
545                    tokens.push(Token {
546                        kind: TokenKind::EqEq,
547                        span,
548                    });
549                    pos += 2;
550                    col += 2;
551                } else {
552                    tokens.push(Token {
553                        kind: TokenKind::Eq,
554                        span,
555                    });
556                    pos += 1;
557                    col += 1;
558                }
559                continue;
560            }
561            ':' => {
562                tokens.push(Token {
563                    kind: TokenKind::Colon,
564                    span,
565                });
566                pos += 1;
567                col += 1;
568                continue;
569            }
570            '+' => {
571                tokens.push(Token {
572                    kind: TokenKind::Plus,
573                    span,
574                });
575                pos += 1;
576                col += 1;
577                continue;
578            }
579            '-' => {
580                tokens.push(Token {
581                    kind: TokenKind::Minus,
582                    span,
583                });
584                pos += 1;
585                col += 1;
586                continue;
587            }
588            '*' => {
589                if pos + 1 < chars.len() && chars[pos + 1] == '*' {
590                    tokens.push(Token {
591                        kind: TokenKind::StarStar,
592                        span,
593                    });
594                    pos += 2;
595                    col += 2;
596                } else {
597                    tokens.push(Token {
598                        kind: TokenKind::Star,
599                        span,
600                    });
601                    pos += 1;
602                    col += 1;
603                }
604                continue;
605            }
606            '%' => {
607                tokens.push(Token {
608                    kind: TokenKind::Percent,
609                    span,
610                });
611                pos += 1;
612                col += 1;
613                continue;
614            }
615            '^' => {
616                tokens.push(Token {
617                    kind: TokenKind::Caret,
618                    span,
619                });
620                pos += 1;
621                col += 1;
622                continue;
623            }
624            '<' => {
625                if pos + 1 < chars.len() && chars[pos + 1] == '<' {
626                    tokens.push(Token {
627                        kind: TokenKind::ShiftLeft,
628                        span,
629                    });
630                    pos += 2;
631                    col += 2;
632                } else if pos + 1 < chars.len() && chars[pos + 1] == '=' {
633                    tokens.push(Token {
634                        kind: TokenKind::LtEq,
635                        span,
636                    });
637                    pos += 2;
638                    col += 2;
639                } else {
640                    tokens.push(Token {
641                        kind: TokenKind::Lt,
642                        span,
643                    });
644                    pos += 1;
645                    col += 1;
646                }
647                continue;
648            }
649            '>' => {
650                if pos + 1 < chars.len() && chars[pos + 1] == '>' {
651                    tokens.push(Token {
652                        kind: TokenKind::ShiftRight,
653                        span,
654                    });
655                    pos += 2;
656                    col += 2;
657                } else if pos + 1 < chars.len() && chars[pos + 1] == '=' {
658                    tokens.push(Token {
659                        kind: TokenKind::GtEq,
660                        span,
661                    });
662                    pos += 2;
663                    col += 2;
664                } else {
665                    tokens.push(Token {
666                        kind: TokenKind::Gt,
667                        span,
668                    });
669                    pos += 1;
670                    col += 1;
671                }
672                continue;
673            }
674            '.' => {
675                tokens.push(Token {
676                    kind: TokenKind::Dot,
677                    span,
678                });
679                pos += 1;
680                col += 1;
681                continue;
682            }
683            '&' => {
684                if pos + 1 < chars.len() && chars[pos + 1] == '&' {
685                    tokens.push(Token {
686                        kind: TokenKind::AmpAmp,
687                        span,
688                    });
689                    pos += 2;
690                    col += 2;
691                    continue;
692                }
693                tokens.push(Token {
694                    kind: TokenKind::Ampersand,
695                    span,
696                });
697                pos += 1;
698                col += 1;
699                continue;
700            }
701            '|' => {
702                if pos + 1 < chars.len() && chars[pos + 1] == '|' {
703                    tokens.push(Token {
704                        kind: TokenKind::PipePipe,
705                        span,
706                    });
707                    pos += 2;
708                    col += 2;
709                    continue;
710                }
711                tokens.push(Token {
712                    kind: TokenKind::Pipe,
713                    span,
714                });
715                pos += 1;
716                col += 1;
717                continue;
718            }
719            '!' => {
720                if pos + 1 < chars.len() && chars[pos + 1] == '=' {
721                    tokens.push(Token {
722                        kind: TokenKind::BangEq,
723                        span,
724                    });
725                    pos += 2;
726                    col += 2;
727                } else {
728                    tokens.push(Token {
729                        kind: TokenKind::Bang,
730                        span,
731                    });
732                    pos += 1;
733                    col += 1;
734                }
735                continue;
736            }
737            _ => {}
738        }
739
740        // String literal (double-quoted or single-quoted)
741        if c == '"' || c == '\'' {
742            let quote = c;
743            pos += 1;
744            col += 1;
745            let mut s = String::new();
746            while pos < chars.len() && chars[pos] != quote {
747                if chars[pos] == '\\' && pos + 1 < chars.len() {
748                    pos += 1;
749                    col += 1;
750                    match chars[pos] {
751                        'n' => s.push('\n'),
752                        't' => s.push('\t'),
753                        '\\' => s.push('\\'),
754                        c if c == quote => s.push(c),
755                        other => {
756                            s.push('\\');
757                            s.push(other);
758                        }
759                    }
760                } else {
761                    s.push(chars[pos]);
762                }
763                pos += 1;
764                col += 1;
765            }
766            if pos < chars.len() {
767                pos += 1; // skip closing quote
768                col += 1;
769            } else {
770                return Err(format!(
771                    "unterminated string at line {}, col {}",
772                    span.line, span.col
773                ));
774            }
775            tokens.push(Token {
776                kind: TokenKind::StringLit(s),
777                span,
778            });
779            continue;
780        }
781
782        // Identifier or keyword
783        if c.is_ascii_alphabetic() || c == '_' {
784            let start = pos;
785            while pos < chars.len() && (chars[pos].is_ascii_alphanumeric() || chars[pos] == '_') {
786                pos += 1;
787                col += 1;
788            }
789            let word: String = chars[start..pos].iter().collect();
790            let kind = match word.as_str() {
791                "const" => TokenKind::Const,
792                "input" => TokenKind::Input,
793                "extern" => TokenKind::Extern,
794                "shared" => TokenKind::Shared,
795                "volatile" => TokenKind::Volatile,
796                "cursor" => TokenKind::Cursor,
797                "over" => TokenKind::Over,
798                "pragma" => TokenKind::Pragma,
799                "tile" => {
800                    tile_pending = true;
801                    TokenKind::Tile
802                }
803                "for" => {
804                    let (text, consumed) = capture_for_text(&chars, pos);
805                    pos += consumed;
806                    col += consumed;
807                    tokens.push(Token {
808                        kind: TokenKind::For(text),
809                        span,
810                    });
811                    continue;
812                }
813                _ => TokenKind::Ident(word),
814            };
815            tokens.push(Token { kind, span });
816            continue;
817        }
818
819        return Err(format!(
820            "unexpected character '{}' at line {}, col {}",
821            c, line, col
822        ));
823    }
824
825    tokens.push(Token {
826        kind: TokenKind::Eof,
827        span: Span { line, col },
828    });
829    Ok(tokens)
830}
831
832/// Capture a raw tile body after `tile ... :=` (SRD 114 §2.1).
833///
834/// Skips whitespace, then: a `{` or `[` starts a balanced, string-aware
835/// block that includes its brackets; `<<<` starts a heredoc ending at
836/// `>>>`, with one leading and one trailing newline trimmed. Anything
837/// else, such as a string literal, is left to the main loop and `None`
838/// is returned. On success returns the body, its kind, the chars
839/// consumed from `start`, the newlines crossed, and the column after
840/// the body.
841type TileBodyCapture = (String, crate::ast::TileBodyKind, usize, usize, usize);
842
843fn capture_tile_body(
844    chars: &[char],
845    start: usize,
846    start_col: usize,
847) -> Result<Option<TileBodyCapture>, String> {
848    use crate::ast::TileBodyKind;
849    let mut pos = start;
850    let mut col = start_col;
851    let mut newlines = 0;
852    while pos < chars.len() && chars[pos].is_whitespace() {
853        if chars[pos] == '\n' {
854            newlines += 1;
855            col = 1;
856        } else {
857            col += 1;
858        }
859        pos += 1;
860    }
861    if pos >= chars.len() {
862        return Ok(None);
863    }
864    let body_start = pos;
865    let kind;
866    match chars[pos] {
867        '{' | '[' => {
868            kind = TileBodyKind::Block;
869            let mut depth = 0i32;
870            let mut quote: Option<char> = None;
871            loop {
872                if pos >= chars.len() {
873                    return Err("unterminated tile body: block never closed".to_string());
874                }
875                let c = chars[pos];
876                if c == '\n' {
877                    newlines += 1;
878                    col = 0;
879                }
880                if let Some(q) = quote {
881                    if c == '\\' && pos + 1 < chars.len() {
882                        pos += 2;
883                        col += 2;
884                        continue;
885                    }
886                    if c == q {
887                        quote = None;
888                    }
889                } else {
890                    match c {
891                        '"' => quote = Some(c),
892                        '{' | '[' => depth += 1,
893                        '}' | ']' => depth -= 1,
894                        _ => {}
895                    }
896                }
897                pos += 1;
898                col += 1;
899                if depth == 0 && quote.is_none() {
900                    break;
901                }
902            }
903        }
904        '<' if chars[pos..].starts_with(&['<', '<', '<']) => {
905            kind = TileBodyKind::Heredoc;
906            pos += 3;
907            col += 3;
908            let text_start = pos;
909            loop {
910                if pos + 2 >= chars.len() {
911                    return Err(
912                        "unterminated tile body: heredoc never closed with `>>>`".to_string()
913                    );
914                }
915                if chars[pos..].starts_with(&['>', '>', '>']) {
916                    break;
917                }
918                if chars[pos] == '\n' {
919                    newlines += 1;
920                    col = 0;
921                }
922                pos += 1;
923                col += 1;
924            }
925            let mut text: String = chars[text_start..pos].iter().collect();
926            if let Some(t) = text.strip_prefix('\n') {
927                text = t.to_string();
928            }
929            if let Some(t) = text.strip_suffix('\n') {
930                text = t.to_string();
931            }
932            pos += 3;
933            col += 3;
934            return Ok(Some((text, kind, pos - start, newlines, col)));
935        }
936        _ => return Ok(None),
937    }
938    let text: String = chars[body_start..pos].iter().collect();
939    Ok(Some((
940        dedent_block(&text),
941        kind,
942        pos - start,
943        newlines,
944        col,
945    )))
946}
947
948/// A block body keeps the author's layout but not the indentation of
949/// the statement it sits in: the common leading whitespace of the lines
950/// after the first is removed, so a tile declared inside a `for` body
951/// renders the same bytes as one at top level.
952fn dedent_block(text: &str) -> String {
953    let mut lines = text.split('\n');
954    let first = lines.next().unwrap_or("");
955    let rest: Vec<&str> = lines.collect();
956    let indent = rest
957        .iter()
958        .filter(|l| !l.trim().is_empty())
959        .map(|l| l.chars().take_while(|c| *c == ' ' || *c == '\t').count())
960        .min()
961        .unwrap_or(0);
962    if indent == 0 {
963        return text.to_string();
964    }
965    let mut out = String::with_capacity(text.len());
966    out.push_str(first);
967    for line in rest {
968        out.push('\n');
969        let skip = line
970            .chars()
971            .take_while(|c| *c == ' ' || *c == '\t')
972            .count()
973            .min(indent);
974        out.push_str(&line.chars().skip(skip).collect::<String>());
975    }
976    out
977}
978
979/// Capture the raw comprehension text after a `for` keyword.
980///
981/// Scans from `start` until a `{` at paren/bracket depth zero or a
982/// newline, stopping early at a `//` or `#` comment. String literals
983/// are skipped whole so braces and commas inside them do not count.
984/// Returns the trimmed text and the number of chars consumed; the `{`
985/// and the newline are left for the main loop.
986fn capture_for_text(chars: &[char], start: usize) -> (String, usize) {
987    let mut pos = start;
988    let mut depth = 0usize;
989    let mut text = String::new();
990    while pos < chars.len() {
991        let c = chars[pos];
992        match c {
993            '\n' => break,
994            '{' if depth == 0 => {
995                // `{name}` inside a `where` predicate is a coordinate
996                // reference, not the start of the block. A block brace
997                // is never immediately followed by an identifier and a
998                // closing brace.
999                match placeholder_len(chars, pos) {
1000                    Some(n) => {
1001                        text.extend(chars[pos..pos + n].iter());
1002                        pos += n;
1003                        continue;
1004                    }
1005                    None => break,
1006                }
1007            }
1008            '#' => break,
1009            '/' if pos + 1 < chars.len() && chars[pos + 1] == '/' => break,
1010            '"' | '\'' => {
1011                let quote = c;
1012                text.push(c);
1013                pos += 1;
1014                while pos < chars.len() && chars[pos] != quote && chars[pos] != '\n' {
1015                    if chars[pos] == '\\' && pos + 1 < chars.len() {
1016                        text.push(chars[pos]);
1017                        pos += 1;
1018                    }
1019                    text.push(chars[pos]);
1020                    pos += 1;
1021                }
1022                if pos < chars.len() && chars[pos] == quote {
1023                    text.push(quote);
1024                    pos += 1;
1025                }
1026                continue;
1027            }
1028            '(' | '[' => depth += 1,
1029            ')' | ']' => depth = depth.saturating_sub(1),
1030            _ => {}
1031        }
1032        text.push(c);
1033        pos += 1;
1034    }
1035    (text.trim().to_string(), pos - start)
1036}
1037
1038/// Length of a `{identifier}` placeholder starting at `pos`, if the
1039/// text there is one.
1040fn placeholder_len(chars: &[char], pos: usize) -> Option<usize> {
1041    let mut i = pos + 1;
1042    let first = *chars.get(i)?;
1043    if !(first.is_ascii_alphabetic() || first == '_') {
1044        return None;
1045    }
1046    while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
1047        i += 1;
1048    }
1049    (chars.get(i) == Some(&'}')).then_some(i + 1 - pos)
1050}
1051
1052/// SRD-18c Layer 6 / SRD-18e Push 4: peek for an SI suffix
1053/// at `pos`. Returns `Some((multiplier, len, is_subunit))`
1054/// on a match, `None` otherwise.
1055///
1056/// Two-char binary suffixes (`Ki`, `Mi`, `Gi`, `Ti`, `Pi`)
1057/// are checked first to avoid `K` greedy-eating the `K` of
1058/// `Ki`. Decimal suffixes (`K`, `M`, `G`, `T`, `P`) and sub-
1059/// unit suffixes (`m`, `u`, `n`) are length-1.
1060///
1061/// A match requires the char *after* the suffix to NOT be
1062/// an identifier-continuation character — so `1Kilometers`
1063/// stays as IntLit(1) + Ident("Kilometers"), not `1000000`.
1064fn peek_si_suffix(chars: &[char], pos: usize) -> Option<(u64, usize, bool)> {
1065    if pos >= chars.len() {
1066        return None;
1067    }
1068    // Try 2-char binary suffixes first.
1069    if pos + 1 < chars.len() && chars[pos + 1] == 'i' {
1070        let mult = match chars[pos] {
1071            'K' => 1u64 << 10,
1072            'M' => 1u64 << 20,
1073            'G' => 1u64 << 30,
1074            'T' => 1u64 << 40,
1075            'P' => 1u64 << 50,
1076            _ => 0,
1077        };
1078        if mult > 0 {
1079            // Check that the suffix isn't part of an identifier.
1080            let next = chars.get(pos + 2);
1081            if !next.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_') {
1082                return Some((mult, 2, false));
1083            }
1084        }
1085    }
1086    // 1-char decimal suffixes.
1087    let (mult, is_subunit) = match chars[pos] {
1088        'K' => (1_000u64, false),
1089        'M' => (1_000_000u64, false),
1090        'G' => (1_000_000_000u64, false),
1091        'T' => (1_000_000_000_000u64, false),
1092        'P' => (1_000_000_000_000_000u64, false),
1093        'm' => (1_000u64, true),         // 10⁻³
1094        'u' => (1_000_000u64, true),     // 10⁻⁶
1095        'n' => (1_000_000_000u64, true), // 10⁻⁹
1096        _ => return None,
1097    };
1098    let next = chars.get(pos + 1);
1099    if !next.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_') {
1100        Some((mult, 1, is_subunit))
1101    } else {
1102        None
1103    }
1104}
1105
1106/// Length consumed for the SI suffix, or 0 when no suffix
1107/// was applied. Used by the numeric-literal branch to
1108/// recover the digit-only end-position when slicing the
1109/// literal text out for parsing.
1110fn suffix_len_consumed(suffix: Option<(u64, bool)>) -> usize {
1111    match suffix {
1112        // We applied either a 1-char or 2-char suffix; the
1113        // caller only stored the multiplier and is_subunit
1114        // pair, so we re-derive: binary multipliers (powers
1115        // of 2 from 2^10 up) take 2 chars, others take 1.
1116        Some((m, _)) => {
1117            if m.is_power_of_two() && m >= (1 << 10) {
1118                2
1119            } else {
1120                1
1121            }
1122        }
1123        None => 0,
1124    }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    #[test]
1132    fn lex_cycle_binding() {
1133        let tokens = lex("seed := hash(cycle)").unwrap();
1134        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "seed"));
1135        assert!(matches!(tokens[1].kind, TokenKind::ColonEq));
1136        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "hash"));
1137        assert!(matches!(tokens[3].kind, TokenKind::LParen));
1138        assert!(matches!(tokens[4].kind, TokenKind::Ident(ref s) if s == "cycle"));
1139        assert!(matches!(tokens[5].kind, TokenKind::RParen));
1140    }
1141
1142    #[test]
1143    fn lex_const_binding() {
1144        let tokens = lex("const lut := dist_normal(72.0, 5.0)").unwrap();
1145        assert!(matches!(tokens[0].kind, TokenKind::Const));
1146        assert!(matches!(tokens[1].kind, TokenKind::Ident(ref s) if s == "lut"));
1147        assert!(matches!(tokens[2].kind, TokenKind::ColonEq));
1148        assert!(matches!(tokens[3].kind, TokenKind::Ident(ref s) if s == "dist_normal"));
1149        assert!(matches!(tokens[5].kind, TokenKind::FloatLit(v) if v == 72.0));
1150        assert!(matches!(tokens[7].kind, TokenKind::FloatLit(v) if v == 5.0));
1151    }
1152
1153    #[test]
1154    fn lex_input_keyword_tuple() {
1155        let tokens = lex("input (cycle: u64, thread: u64)").unwrap();
1156        assert!(matches!(tokens[0].kind, TokenKind::Input));
1157        assert!(matches!(tokens[1].kind, TokenKind::LParen));
1158        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "cycle"));
1159    }
1160
1161    #[test]
1162    fn lex_destructuring() {
1163        let tokens = lex("(a, b, c) := mixed_radix(cycle, 100, 1000, 0)").unwrap();
1164        assert!(matches!(tokens[0].kind, TokenKind::LParen));
1165        assert!(matches!(tokens[1].kind, TokenKind::Ident(ref s) if s == "a"));
1166    }
1167
1168    #[test]
1169    fn lex_string_with_interpolation() {
1170        let tokens = lex(r#"id := "{code}-{seq}""#).unwrap();
1171        // id(0) :=(1) string(2)
1172        assert!(matches!(tokens[2].kind, TokenKind::StringLit(ref s) if s == "{code}-{seq}"));
1173    }
1174
1175    #[test]
1176    fn lex_named_args() {
1177        let tokens = lex("dist_normal(mean: 72.0, stddev: 5.0)").unwrap();
1178        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "mean"));
1179        assert!(matches!(tokens[3].kind, TokenKind::Colon));
1180        assert!(matches!(tokens[4].kind, TokenKind::FloatLit(v) if v == 72.0));
1181    }
1182
1183    #[test]
1184    fn lex_array_literal() {
1185        let tokens = lex("[60.0, 20.0, 15.0, 5.0]").unwrap();
1186        assert!(matches!(tokens[0].kind, TokenKind::LBracket));
1187        assert!(matches!(tokens[1].kind, TokenKind::FloatLit(v) if v == 60.0));
1188        assert!(matches!(tokens[8].kind, TokenKind::RBracket));
1189    }
1190
1191    #[test]
1192    fn lex_comments_stripped() {
1193        let tokens = lex("// this is a comment\nseed := hash(cycle)").unwrap();
1194        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "seed"));
1195    }
1196
1197    #[test]
1198    fn lex_arrow() {
1199        let tokens = lex("(x: u64) -> (y: u64)").unwrap();
1200        // ( x : u64 ) -> ...
1201        // 0 1 2  3  4  5
1202        assert!(matches!(tokens[5].kind, TokenKind::Arrow));
1203    }
1204
1205    #[test]
1206    fn lex_large_int() {
1207        let tokens = lex("1710000000000").unwrap();
1208        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1710000000000)));
1209    }
1210
1211    #[test]
1212    fn lex_hex_int() {
1213        let tokens = lex("0xFF").unwrap();
1214        assert!(matches!(tokens[0].kind, TokenKind::IntLit(255)));
1215    }
1216
1217    #[test]
1218    fn lex_block_comment() {
1219        let tokens = lex("a := /* skip this */ hash(b)").unwrap();
1220        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "a"));
1221        assert!(matches!(tokens[1].kind, TokenKind::ColonEq));
1222        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "hash"));
1223    }
1224
1225    #[test]
1226    fn lex_block_comment_multiline() {
1227        let tokens = lex("a := 42\n/* this\nis\na\nblock */\nb := 7").unwrap();
1228        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "a"));
1229        assert!(matches!(tokens[2].kind, TokenKind::IntLit(42)));
1230        assert!(matches!(tokens[3].kind, TokenKind::Ident(ref s) if s == "b"));
1231    }
1232
1233    #[test]
1234    fn lex_doc_comment() {
1235        // Triple-slash doc comments are stripped like line comments
1236        let tokens = lex("/// doc comment\nseed := hash(cycle)").unwrap();
1237        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "seed"));
1238    }
1239
1240    #[test]
1241    fn lex_input_keyword_bare() {
1242        let tokens = lex("input cycle: u64").unwrap();
1243        assert!(matches!(tokens[0].kind, TokenKind::Input));
1244        assert!(matches!(tokens[1].kind, TokenKind::Ident(ref s) if s == "cycle"));
1245        assert!(matches!(tokens[2].kind, TokenKind::Colon));
1246        assert!(matches!(tokens[3].kind, TokenKind::Ident(ref s) if s == "u64"));
1247    }
1248
1249    #[test]
1250    fn lex_arithmetic_operators() {
1251        let tokens = lex("a + b * 2.0 - c / d % e ^ f").unwrap();
1252        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "a"));
1253        assert!(matches!(tokens[1].kind, TokenKind::Plus));
1254        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "b"));
1255        assert!(matches!(tokens[3].kind, TokenKind::Star));
1256        assert!(matches!(tokens[4].kind, TokenKind::FloatLit(v) if v == 2.0));
1257        assert!(matches!(tokens[5].kind, TokenKind::Minus));
1258        assert!(matches!(tokens[6].kind, TokenKind::Ident(ref s) if s == "c"));
1259        assert!(matches!(tokens[7].kind, TokenKind::Slash));
1260        assert!(matches!(tokens[8].kind, TokenKind::Ident(ref s) if s == "d"));
1261        assert!(matches!(tokens[9].kind, TokenKind::Percent));
1262        assert!(matches!(tokens[10].kind, TokenKind::Ident(ref s) if s == "e"));
1263        assert!(matches!(tokens[11].kind, TokenKind::Caret));
1264        assert!(matches!(tokens[12].kind, TokenKind::Ident(ref s) if s == "f"));
1265    }
1266
1267    #[test]
1268    fn lex_minus_binary_vs_negative_literal() {
1269        // After an identifier, `-` is a binary Minus, not a negative literal.
1270        let tokens = lex("x - 3").unwrap();
1271        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "x"));
1272        assert!(matches!(tokens[1].kind, TokenKind::Minus));
1273        assert!(matches!(tokens[2].kind, TokenKind::IntLit(3)));
1274    }
1275
1276    #[test]
1277    fn lex_negative_via_minus_token() {
1278        // `-3.0` is lexed as Minus + FloatLit(3.0); the parser
1279        // handles negation via UnaryNeg.
1280        let tokens = lex("-3.0").unwrap();
1281        assert!(matches!(tokens[0].kind, TokenKind::Minus));
1282        assert!(matches!(tokens[1].kind, TokenKind::FloatLit(v) if v == 3.0));
1283
1284        // `-3` is Minus + IntLit(3).
1285        let tokens = lex("-3").unwrap();
1286        assert!(matches!(tokens[0].kind, TokenKind::Minus));
1287        assert!(matches!(tokens[1].kind, TokenKind::IntLit(3)));
1288    }
1289
1290    #[test]
1291    fn lex_scientific_notation() {
1292        let tokens = lex("1e10").unwrap();
1293        assert!(matches!(&tokens[0].kind, TokenKind::FloatLit(v) if (*v - 1e10).abs() < 1e5));
1294    }
1295
1296    #[test]
1297    fn lex_scientific_notation_negative_exponent() {
1298        let tokens = lex("1e-10").unwrap();
1299        assert!(matches!(&tokens[0].kind, TokenKind::FloatLit(v) if *v > 0.0 && *v < 1e-5));
1300    }
1301
1302    #[test]
1303    fn lex_scientific_notation_with_decimal() {
1304        let tokens = lex("2.5e3").unwrap();
1305        assert!(matches!(&tokens[0].kind, TokenKind::FloatLit(v) if (*v - 2500.0).abs() < 0.1));
1306    }
1307
1308    #[test]
1309    fn lex_scientific_notation_positive_exponent() {
1310        let tokens = lex("3E+5").unwrap();
1311        assert!(matches!(&tokens[0].kind, TokenKind::FloatLit(v) if (*v - 3e5).abs() < 1.0));
1312    }
1313
1314    #[test]
1315    fn lex_sci_uppercase_e() {
1316        let t = lex("2.5E3").unwrap();
1317        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 2500.0).abs() < 0.1));
1318    }
1319
1320    #[test]
1321    fn lex_sci_explicit_positive_exp() {
1322        let t = lex("1e+10").unwrap();
1323        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 1e10).abs() < 1e5));
1324    }
1325
1326    #[test]
1327    fn lex_sci_decimal_negative_exp() {
1328        let t = lex("3.14e-2").unwrap();
1329        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 0.0314).abs() < 0.001));
1330    }
1331
1332    #[test]
1333    fn lex_sci_zero_exponent() {
1334        let t = lex("0.5e0").unwrap();
1335        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 0.5).abs() < 0.001));
1336    }
1337
1338    #[test]
1339    fn lex_sci_very_small() {
1340        let t = lex("1e-300").unwrap();
1341        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if *v > 0.0 && *v < 1e-299));
1342    }
1343
1344    #[test]
1345    fn lex_sci_uppercase_no_decimal() {
1346        let t = lex("1E10").unwrap();
1347        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 1e10).abs() < 1e5));
1348    }
1349
1350    #[test]
1351    fn lex_slash_vs_comment() {
1352        // Single `/` is Slash, `//` is a comment.
1353        let tokens = lex("a / b // comment").unwrap();
1354        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "a"));
1355        assert!(matches!(tokens[1].kind, TokenKind::Slash));
1356        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "b"));
1357        assert!(matches!(tokens[3].kind, TokenKind::Eof));
1358    }
1359
1360    // ── SRD-18c Layer 6 / SRD-18e Push 4: SI suffixes ──
1361
1362    #[test]
1363    fn lex_si_decimal_k_m_g_t_p() {
1364        let tokens = lex("1K 1M 1G 1T 1P").unwrap();
1365        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1_000)));
1366        assert!(matches!(tokens[1].kind, TokenKind::IntLit(1_000_000)));
1367        assert!(matches!(tokens[2].kind, TokenKind::IntLit(1_000_000_000)));
1368        assert!(matches!(
1369            tokens[3].kind,
1370            TokenKind::IntLit(1_000_000_000_000)
1371        ));
1372        assert!(matches!(
1373            tokens[4].kind,
1374            TokenKind::IntLit(1_000_000_000_000_000)
1375        ));
1376    }
1377
1378    #[test]
1379    fn lex_si_binary_ki_mi_gi_ti_pi() {
1380        let tokens = lex("1Ki 1Mi 1Gi 1Ti 1Pi").unwrap();
1381        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1024)));
1382        assert!(matches!(tokens[1].kind, TokenKind::IntLit(1_048_576)));
1383        assert!(matches!(tokens[2].kind, TokenKind::IntLit(1_073_741_824)));
1384        assert!(matches!(
1385            tokens[3].kind,
1386            TokenKind::IntLit(1_099_511_627_776)
1387        ));
1388        assert!(matches!(
1389            tokens[4].kind,
1390            TokenKind::IntLit(1_125_899_906_842_624)
1391        ));
1392    }
1393
1394    #[test]
1395    fn lex_si_subunit_m_u_n() {
1396        // Sub-unit suffixes promote integer to float (5m = 0.005).
1397        let tokens = lex("5m 5u 5n").unwrap();
1398        assert!(matches!(tokens[0].kind, TokenKind::FloatLit(v) if (v - 0.005).abs() < 1e-12));
1399        assert!(matches!(tokens[1].kind, TokenKind::FloatLit(v) if (v - 0.000_005).abs() < 1e-15));
1400        assert!(
1401            matches!(tokens[2].kind, TokenKind::FloatLit(v) if (v - 0.000_000_005).abs() < 1e-18)
1402        );
1403    }
1404
1405    #[test]
1406    fn lex_si_float_base_with_decimal_suffix() {
1407        // 1.5K = 1500, integral → IntLit per SRD-18c §"Layer 6"
1408        let tokens = lex("1.5K").unwrap();
1409        assert!(
1410            matches!(tokens[0].kind, TokenKind::IntLit(1_500)),
1411            "1.5K should be IntLit(1500), got {:?}",
1412            tokens[0].kind
1413        );
1414    }
1415
1416    #[test]
1417    fn lex_si_float_base_non_integral_stays_float() {
1418        // 1.5G = 1_500_000_000.0 — integral → IntLit
1419        // 1.5K = 1500 — integral → IntLit
1420        // 1.25K = 1250 — integral → IntLit
1421        // To force float: an irrational-result combination.
1422        // 0.001K = 1.0 → still integral → IntLit. Hmm. Let's
1423        // try a sub-unit float: 1.5m = 0.0015, non-integral.
1424        let tokens = lex("1.5m").unwrap();
1425        assert!(matches!(tokens[0].kind, TokenKind::FloatLit(v) if (v - 0.0015).abs() < 1e-12));
1426    }
1427
1428    #[test]
1429    fn lex_si_kilometers_stays_identifier() {
1430        // The K of `Kilometers` is followed by an
1431        // identifier-cont char; suffix doesn't apply.
1432        let tokens = lex("1 Kilometers").unwrap();
1433        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1)));
1434        assert!(matches!(tokens[1].kind, TokenKind::Ident(ref s) if s == "Kilometers"));
1435    }
1436
1437    #[test]
1438    fn lex_si_overflow_errors_loud() {
1439        // 1P × 1000 doesn't overflow but multiplying by P
1440        // a value already at u64::MAX/1000 + 1 would. Use
1441        // a value that 1P would overflow: 100000P = 10^20 > u64::MAX.
1442        let err = lex("100000P").unwrap_err();
1443        assert!(err.contains("overflows u64"), "{err}");
1444    }
1445
1446    #[test]
1447    fn lex_si_in_range_expression() {
1448        // SI literals work in range positions (SRD-18c
1449        // example `1K..1M..100K`). The lexer doesn't know
1450        // about ranges yet, but should produce the right
1451        // numeric tokens.
1452        let tokens = lex("1K..1M..100K").unwrap();
1453        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1_000)));
1454        // Token 1 is `..` (still parsed as Dot Dot in
1455        // pre-Push-3 lexer, but the numeric values are right).
1456        // We just verify the IntLits at positions 0, 3, 6.
1457        // (Positions depend on how `..` is tokenized today;
1458        // we walk the IntLits.)
1459        let int_lits: Vec<u64> = tokens
1460            .iter()
1461            .filter_map(|t| match &t.kind {
1462                TokenKind::IntLit(v) => Some(*v),
1463                _ => None,
1464            })
1465            .collect();
1466        assert_eq!(int_lits, vec![1_000, 1_000_000, 100_000]);
1467    }
1468
1469    #[test]
1470    fn lex_si_disambiguation_two_char_first() {
1471        // `1Ki` should match the 2-char binary suffix, NOT
1472        // 1-char `K` followed by `i` identifier.
1473        let tokens = lex("1Ki").unwrap();
1474        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1024)));
1475        // No leftover identifier after the suffix.
1476        assert!(matches!(tokens[1].kind, TokenKind::Eof));
1477    }
1478
1479    #[test]
1480    fn lex_si_followed_by_operator_applies_suffix() {
1481        // `1K+5` — `K` followed by `+` (not ident-cont) → suffix applies.
1482        let tokens = lex("1K+5").unwrap();
1483        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1000)));
1484        assert!(matches!(tokens[1].kind, TokenKind::Plus));
1485        assert!(matches!(tokens[2].kind, TokenKind::IntLit(5)));
1486    }
1487}