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: the modifier that makes a binding
53    /// re-evaluated on every pull and never constant-folded.
54    Volatile,
55    /// `cursor` keyword
56    Cursor,
57    /// `over` keyword — the partition-source binding on cursor
58    /// declarations (cursor_partitions.md §7.2): `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 (opens a scope directive,
64    /// polydat_grammar.md §14). Followed by an `Ident` naming the
65    /// pragma. Distinct from line comments, so the parser sees
66    /// pragmas as first-class statements.
67    Pragma,
68    /// `for` followed by its raw comprehension text (for_traversal.md §2). The
69    /// lexer captures everything after the keyword up to a `{` at
70    /// nesting depth zero or the end of a line at nesting depth zero
71    /// (a bracketed union runs across lines), 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 (polytile.md §2.1). 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 — polydat_grammar.md §7)
134    AmpAmp,
135    /// `|` (bitwise OR)
136    Pipe,
137    /// `||` (logical OR — polydat_grammar.md §7)
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            // SI suffix literals (polydat_grammar.md §2.3).
384            // 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), per polydat_grammar.md
416                // §2.3.
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                    // A bracketed union runs across lines: the position keeps
806                    // counting them.
807                    for c in &chars[pos..pos + consumed] {
808                        if *c == '\n' {
809                            line += 1;
810                            col = 1;
811                        } else {
812                            col += 1;
813                        }
814                    }
815                    pos += consumed;
816                    tokens.push(Token {
817                        kind: TokenKind::For(text),
818                        span,
819                    });
820                    continue;
821                }
822                _ => TokenKind::Ident(word),
823            };
824            tokens.push(Token { kind, span });
825            continue;
826        }
827
828        return Err(format!(
829            "unexpected character '{}' at line {}, col {}",
830            c, line, col
831        ));
832    }
833
834    tokens.push(Token {
835        kind: TokenKind::Eof,
836        span: Span { line, col },
837    });
838    Ok(tokens)
839}
840
841/// Capture a raw tile body after `tile ... :=` (polytile.md §2.1).
842///
843/// Skips whitespace, then: a `{` or `[` starts a balanced, string-aware
844/// block that includes its brackets; `<<<` starts a heredoc ending at
845/// `>>>`, with one leading and one trailing newline trimmed. Anything
846/// else, such as a string literal, is left to the main loop and `None`
847/// is returned. On success returns the body, its kind, the chars
848/// consumed from `start`, the newlines crossed, and the column after
849/// the body.
850type TileBodyCapture = (String, crate::ast::TileBodyKind, usize, usize, usize);
851
852fn capture_tile_body(
853    chars: &[char],
854    start: usize,
855    start_col: usize,
856) -> Result<Option<TileBodyCapture>, String> {
857    use crate::ast::TileBodyKind;
858    let mut pos = start;
859    let mut col = start_col;
860    let mut newlines = 0;
861    while pos < chars.len() && chars[pos].is_whitespace() {
862        if chars[pos] == '\n' {
863            newlines += 1;
864            col = 1;
865        } else {
866            col += 1;
867        }
868        pos += 1;
869    }
870    if pos >= chars.len() {
871        return Ok(None);
872    }
873    let body_start = pos;
874    let kind;
875    match chars[pos] {
876        '{' | '[' => {
877            kind = TileBodyKind::Block;
878            let mut depth = 0i32;
879            let mut quote: Option<char> = None;
880            loop {
881                if pos >= chars.len() {
882                    return Err("unterminated tile body: block never closed".to_string());
883                }
884                let c = chars[pos];
885                if c == '\n' {
886                    newlines += 1;
887                    col = 0;
888                }
889                if let Some(q) = quote {
890                    if c == '\\' && pos + 1 < chars.len() {
891                        pos += 2;
892                        col += 2;
893                        continue;
894                    }
895                    if c == q {
896                        quote = None;
897                    }
898                } else {
899                    match c {
900                        '"' => quote = Some(c),
901                        '{' | '[' => depth += 1,
902                        '}' | ']' => depth -= 1,
903                        _ => {}
904                    }
905                }
906                pos += 1;
907                col += 1;
908                if depth == 0 && quote.is_none() {
909                    break;
910                }
911            }
912        }
913        '<' if chars[pos..].starts_with(&['<', '<', '<']) => {
914            kind = TileBodyKind::Heredoc;
915            pos += 3;
916            col += 3;
917            let text_start = pos;
918            loop {
919                if pos + 2 >= chars.len() {
920                    return Err(
921                        "unterminated tile body: heredoc never closed with `>>>`".to_string()
922                    );
923                }
924                if chars[pos..].starts_with(&['>', '>', '>']) {
925                    break;
926                }
927                if chars[pos] == '\n' {
928                    newlines += 1;
929                    col = 0;
930                }
931                pos += 1;
932                col += 1;
933            }
934            let mut text: String = chars[text_start..pos].iter().collect();
935            if let Some(t) = text.strip_prefix('\n') {
936                text = t.to_string();
937            }
938            if let Some(t) = text.strip_suffix('\n') {
939                text = t.to_string();
940            }
941            pos += 3;
942            col += 3;
943            return Ok(Some((text, kind, pos - start, newlines, col)));
944        }
945        _ => return Ok(None),
946    }
947    let text: String = chars[body_start..pos].iter().collect();
948    Ok(Some((
949        dedent_block(&text),
950        kind,
951        pos - start,
952        newlines,
953        col,
954    )))
955}
956
957/// A block body keeps the author's layout but not the indentation of
958/// the statement it sits in: the common leading whitespace of the lines
959/// after the first is removed, so a tile declared inside a `for` body
960/// renders the same bytes as one at top level.
961fn dedent_block(text: &str) -> String {
962    let mut lines = text.split('\n');
963    let first = lines.next().unwrap_or("");
964    let rest: Vec<&str> = lines.collect();
965    let indent = rest
966        .iter()
967        .filter(|l| !l.trim().is_empty())
968        .map(|l| l.chars().take_while(|c| *c == ' ' || *c == '\t').count())
969        .min()
970        .unwrap_or(0);
971    if indent == 0 {
972        return text.to_string();
973    }
974    let mut out = String::with_capacity(text.len());
975    out.push_str(first);
976    for line in rest {
977        out.push('\n');
978        let skip = line
979            .chars()
980            .take_while(|c| *c == ' ' || *c == '\t')
981            .count()
982            .min(indent);
983        out.push_str(&line.chars().skip(skip).collect::<String>());
984    }
985    out
986}
987
988/// Capture the raw comprehension text after a `for` keyword.
989///
990/// Scans from `start` until a `{` at paren/bracket depth zero or a
991/// newline, stopping early at a `//` or `#` comment. String literals
992/// are skipped whole so braces and commas inside them do not count.
993/// Returns the trimmed text and the number of chars consumed; the `{`
994/// and the newline are left for the main loop.
995fn capture_for_text(chars: &[char], start: usize) -> (String, usize) {
996    let mut pos = start;
997    let mut depth = 0usize;
998    let mut text = String::new();
999    while pos < chars.len() {
1000        let c = chars[pos];
1001        match c {
1002            '\n' if depth == 0 => break,
1003            '\n' => {
1004                // Inside brackets the capture runs on: a union's members
1005                // sit one per line (comprehension_forms.md §8.2).
1006                text.push(' ');
1007                pos += 1;
1008                continue;
1009            }
1010            '{' if depth == 0 => {
1011                // `{name}` inside a `where` predicate is a coordinate
1012                // reference, not the start of the block. A block brace
1013                // is never immediately followed by an identifier and a
1014                // closing brace.
1015                match placeholder_len(chars, pos) {
1016                    Some(n) => {
1017                        text.extend(chars[pos..pos + n].iter());
1018                        pos += n;
1019                        continue;
1020                    }
1021                    None => break,
1022                }
1023            }
1024            '#' if depth == 0 => break,
1025            '/' if depth == 0 && pos + 1 < chars.len() && chars[pos + 1] == '/' => break,
1026            '#' => {
1027                skip_comment(chars, &mut pos);
1028                continue;
1029            }
1030            '/' if pos + 1 < chars.len() && chars[pos + 1] == '/' => {
1031                skip_comment(chars, &mut pos);
1032                continue;
1033            }
1034            '"' | '\'' => {
1035                let quote = c;
1036                text.push(c);
1037                pos += 1;
1038                while pos < chars.len() && chars[pos] != quote && chars[pos] != '\n' {
1039                    if chars[pos] == '\\' && pos + 1 < chars.len() {
1040                        text.push(chars[pos]);
1041                        pos += 1;
1042                    }
1043                    text.push(chars[pos]);
1044                    pos += 1;
1045                }
1046                if pos < chars.len() && chars[pos] == quote {
1047                    text.push(quote);
1048                    pos += 1;
1049                }
1050                continue;
1051            }
1052            '(' | '[' => depth += 1,
1053            ')' | ']' => depth = depth.saturating_sub(1),
1054            _ => {}
1055        }
1056        text.push(c);
1057        pos += 1;
1058    }
1059    (text.trim().to_string(), pos - start)
1060}
1061
1062/// Length of a `{identifier}` placeholder starting at `pos`, if the
1063/// text there is one.
1064fn placeholder_len(chars: &[char], pos: usize) -> Option<usize> {
1065    let mut i = pos + 1;
1066    let first = *chars.get(i)?;
1067    if !(first.is_ascii_alphabetic() || first == '_') {
1068        return None;
1069    }
1070    while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
1071        i += 1;
1072    }
1073    (chars.get(i) == Some(&'}')).then_some(i + 1 - pos)
1074}
1075
1076/// Peek for an SI suffix (polydat_grammar.md §2.3) at `pos`. Returns `Some((multiplier, len, is_subunit))`
1077/// on a match, `None` otherwise.
1078///
1079/// Two-char binary suffixes (`Ki`, `Mi`, `Gi`, `Ti`, `Pi`)
1080/// are checked first to avoid `K` greedy-eating the `K` of
1081/// `Ki`. Decimal suffixes (`K`, `M`, `G`, `T`, `P`) and sub-
1082/// unit suffixes (`m`, `u`, `n`) are length-1.
1083///
1084/// A match requires the char *after* the suffix to NOT be
1085/// an identifier-continuation character — so `1Kilometers`
1086/// stays as IntLit(1) + Ident("Kilometers"), not `1000000`.
1087fn peek_si_suffix(chars: &[char], pos: usize) -> Option<(u64, usize, bool)> {
1088    if pos >= chars.len() {
1089        return None;
1090    }
1091    // Try 2-char binary suffixes first.
1092    if pos + 1 < chars.len() && chars[pos + 1] == 'i' {
1093        let mult = match chars[pos] {
1094            'K' => 1u64 << 10,
1095            'M' => 1u64 << 20,
1096            'G' => 1u64 << 30,
1097            'T' => 1u64 << 40,
1098            'P' => 1u64 << 50,
1099            _ => 0,
1100        };
1101        if mult > 0 {
1102            // Check that the suffix isn't part of an identifier.
1103            let next = chars.get(pos + 2);
1104            if !next.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_') {
1105                return Some((mult, 2, false));
1106            }
1107        }
1108    }
1109    // 1-char decimal suffixes.
1110    let (mult, is_subunit) = match chars[pos] {
1111        'K' => (1_000u64, false),
1112        'M' => (1_000_000u64, false),
1113        'G' => (1_000_000_000u64, false),
1114        'T' => (1_000_000_000_000u64, false),
1115        'P' => (1_000_000_000_000_000u64, false),
1116        'm' => (1_000u64, true),         // 10⁻³
1117        'u' => (1_000_000u64, true),     // 10⁻⁶
1118        'n' => (1_000_000_000u64, true), // 10⁻⁹
1119        _ => return None,
1120    };
1121    let next = chars.get(pos + 1);
1122    if !next.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_') {
1123        Some((mult, 1, is_subunit))
1124    } else {
1125        None
1126    }
1127}
1128
1129/// Length consumed for the SI suffix, or 0 when no suffix
1130/// was applied. Used by the numeric-literal branch to
1131/// recover the digit-only end-position when slicing the
1132/// literal text out for parsing.
1133fn suffix_len_consumed(suffix: Option<(u64, bool)>) -> usize {
1134    match suffix {
1135        // We applied either a 1-char or 2-char suffix; the
1136        // caller only stored the multiplier and is_subunit
1137        // pair, so we re-derive: binary multipliers (powers
1138        // of 2 from 2^10 up) take 2 chars, others take 1.
1139        Some((m, _)) => {
1140            if m.is_power_of_two() && m >= (1 << 10) {
1141                2
1142            } else {
1143                1
1144            }
1145        }
1146        None => 0,
1147    }
1148}
1149
1150/// Advance `pos` past a line comment inside a bracketed `for` capture:
1151/// to the newline, which the caller then folds into the capture.
1152fn skip_comment(chars: &[char], pos: &mut usize) {
1153    while *pos < chars.len() && chars[*pos] != '\n' {
1154        *pos += 1;
1155    }
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161
1162    #[test]
1163    fn lex_cycle_binding() {
1164        let tokens = lex("seed := hash(cycle)").unwrap();
1165        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "seed"));
1166        assert!(matches!(tokens[1].kind, TokenKind::ColonEq));
1167        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "hash"));
1168        assert!(matches!(tokens[3].kind, TokenKind::LParen));
1169        assert!(matches!(tokens[4].kind, TokenKind::Ident(ref s) if s == "cycle"));
1170        assert!(matches!(tokens[5].kind, TokenKind::RParen));
1171    }
1172
1173    #[test]
1174    fn lex_const_binding() {
1175        let tokens = lex("const lut := dist_normal(72.0, 5.0)").unwrap();
1176        assert!(matches!(tokens[0].kind, TokenKind::Const));
1177        assert!(matches!(tokens[1].kind, TokenKind::Ident(ref s) if s == "lut"));
1178        assert!(matches!(tokens[2].kind, TokenKind::ColonEq));
1179        assert!(matches!(tokens[3].kind, TokenKind::Ident(ref s) if s == "dist_normal"));
1180        assert!(matches!(tokens[5].kind, TokenKind::FloatLit(v) if v == 72.0));
1181        assert!(matches!(tokens[7].kind, TokenKind::FloatLit(v) if v == 5.0));
1182    }
1183
1184    #[test]
1185    fn lex_input_keyword_tuple() {
1186        let tokens = lex("input (cycle: u64, thread: u64)").unwrap();
1187        assert!(matches!(tokens[0].kind, TokenKind::Input));
1188        assert!(matches!(tokens[1].kind, TokenKind::LParen));
1189        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "cycle"));
1190    }
1191
1192    #[test]
1193    fn lex_destructuring() {
1194        let tokens = lex("(a, b, c) := mixed_radix(cycle, 100, 1000, 0)").unwrap();
1195        assert!(matches!(tokens[0].kind, TokenKind::LParen));
1196        assert!(matches!(tokens[1].kind, TokenKind::Ident(ref s) if s == "a"));
1197    }
1198
1199    #[test]
1200    fn lex_string_with_interpolation() {
1201        let tokens = lex(r#"id := "{code}-{seq}""#).unwrap();
1202        // id(0) :=(1) string(2)
1203        assert!(matches!(tokens[2].kind, TokenKind::StringLit(ref s) if s == "{code}-{seq}"));
1204    }
1205
1206    #[test]
1207    fn lex_named_args() {
1208        let tokens = lex("dist_normal(mean: 72.0, stddev: 5.0)").unwrap();
1209        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "mean"));
1210        assert!(matches!(tokens[3].kind, TokenKind::Colon));
1211        assert!(matches!(tokens[4].kind, TokenKind::FloatLit(v) if v == 72.0));
1212    }
1213
1214    #[test]
1215    fn lex_array_literal() {
1216        let tokens = lex("[60.0, 20.0, 15.0, 5.0]").unwrap();
1217        assert!(matches!(tokens[0].kind, TokenKind::LBracket));
1218        assert!(matches!(tokens[1].kind, TokenKind::FloatLit(v) if v == 60.0));
1219        assert!(matches!(tokens[8].kind, TokenKind::RBracket));
1220    }
1221
1222    #[test]
1223    fn lex_comments_stripped() {
1224        let tokens = lex("// this is a comment\nseed := hash(cycle)").unwrap();
1225        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "seed"));
1226    }
1227
1228    #[test]
1229    fn lex_arrow() {
1230        let tokens = lex("(x: u64) -> (y: u64)").unwrap();
1231        // ( x : u64 ) -> ...
1232        // 0 1 2  3  4  5
1233        assert!(matches!(tokens[5].kind, TokenKind::Arrow));
1234    }
1235
1236    #[test]
1237    fn lex_large_int() {
1238        let tokens = lex("1710000000000").unwrap();
1239        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1710000000000)));
1240    }
1241
1242    #[test]
1243    fn lex_hex_int() {
1244        let tokens = lex("0xFF").unwrap();
1245        assert!(matches!(tokens[0].kind, TokenKind::IntLit(255)));
1246    }
1247
1248    #[test]
1249    fn lex_block_comment() {
1250        let tokens = lex("a := /* skip this */ hash(b)").unwrap();
1251        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "a"));
1252        assert!(matches!(tokens[1].kind, TokenKind::ColonEq));
1253        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "hash"));
1254    }
1255
1256    #[test]
1257    fn lex_block_comment_multiline() {
1258        let tokens = lex("a := 42\n/* this\nis\na\nblock */\nb := 7").unwrap();
1259        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "a"));
1260        assert!(matches!(tokens[2].kind, TokenKind::IntLit(42)));
1261        assert!(matches!(tokens[3].kind, TokenKind::Ident(ref s) if s == "b"));
1262    }
1263
1264    #[test]
1265    fn lex_doc_comment() {
1266        // Triple-slash doc comments are stripped like line comments
1267        let tokens = lex("/// doc comment\nseed := hash(cycle)").unwrap();
1268        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "seed"));
1269    }
1270
1271    #[test]
1272    fn lex_input_keyword_bare() {
1273        let tokens = lex("input cycle: u64").unwrap();
1274        assert!(matches!(tokens[0].kind, TokenKind::Input));
1275        assert!(matches!(tokens[1].kind, TokenKind::Ident(ref s) if s == "cycle"));
1276        assert!(matches!(tokens[2].kind, TokenKind::Colon));
1277        assert!(matches!(tokens[3].kind, TokenKind::Ident(ref s) if s == "u64"));
1278    }
1279
1280    #[test]
1281    fn lex_arithmetic_operators() {
1282        let tokens = lex("a + b * 2.0 - c / d % e ^ f").unwrap();
1283        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "a"));
1284        assert!(matches!(tokens[1].kind, TokenKind::Plus));
1285        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "b"));
1286        assert!(matches!(tokens[3].kind, TokenKind::Star));
1287        assert!(matches!(tokens[4].kind, TokenKind::FloatLit(v) if v == 2.0));
1288        assert!(matches!(tokens[5].kind, TokenKind::Minus));
1289        assert!(matches!(tokens[6].kind, TokenKind::Ident(ref s) if s == "c"));
1290        assert!(matches!(tokens[7].kind, TokenKind::Slash));
1291        assert!(matches!(tokens[8].kind, TokenKind::Ident(ref s) if s == "d"));
1292        assert!(matches!(tokens[9].kind, TokenKind::Percent));
1293        assert!(matches!(tokens[10].kind, TokenKind::Ident(ref s) if s == "e"));
1294        assert!(matches!(tokens[11].kind, TokenKind::Caret));
1295        assert!(matches!(tokens[12].kind, TokenKind::Ident(ref s) if s == "f"));
1296    }
1297
1298    #[test]
1299    fn lex_minus_binary_vs_negative_literal() {
1300        // After an identifier, `-` is a binary Minus, not a negative literal.
1301        let tokens = lex("x - 3").unwrap();
1302        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "x"));
1303        assert!(matches!(tokens[1].kind, TokenKind::Minus));
1304        assert!(matches!(tokens[2].kind, TokenKind::IntLit(3)));
1305    }
1306
1307    #[test]
1308    fn lex_negative_via_minus_token() {
1309        // `-3.0` is lexed as Minus + FloatLit(3.0); the parser
1310        // handles negation via UnaryNeg.
1311        let tokens = lex("-3.0").unwrap();
1312        assert!(matches!(tokens[0].kind, TokenKind::Minus));
1313        assert!(matches!(tokens[1].kind, TokenKind::FloatLit(v) if v == 3.0));
1314
1315        // `-3` is Minus + IntLit(3).
1316        let tokens = lex("-3").unwrap();
1317        assert!(matches!(tokens[0].kind, TokenKind::Minus));
1318        assert!(matches!(tokens[1].kind, TokenKind::IntLit(3)));
1319    }
1320
1321    #[test]
1322    fn lex_scientific_notation() {
1323        let tokens = lex("1e10").unwrap();
1324        assert!(matches!(&tokens[0].kind, TokenKind::FloatLit(v) if (*v - 1e10).abs() < 1e5));
1325    }
1326
1327    #[test]
1328    fn lex_scientific_notation_negative_exponent() {
1329        let tokens = lex("1e-10").unwrap();
1330        assert!(matches!(&tokens[0].kind, TokenKind::FloatLit(v) if *v > 0.0 && *v < 1e-5));
1331    }
1332
1333    #[test]
1334    fn lex_scientific_notation_with_decimal() {
1335        let tokens = lex("2.5e3").unwrap();
1336        assert!(matches!(&tokens[0].kind, TokenKind::FloatLit(v) if (*v - 2500.0).abs() < 0.1));
1337    }
1338
1339    #[test]
1340    fn lex_scientific_notation_positive_exponent() {
1341        let tokens = lex("3E+5").unwrap();
1342        assert!(matches!(&tokens[0].kind, TokenKind::FloatLit(v) if (*v - 3e5).abs() < 1.0));
1343    }
1344
1345    #[test]
1346    fn lex_sci_uppercase_e() {
1347        let t = lex("2.5E3").unwrap();
1348        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 2500.0).abs() < 0.1));
1349    }
1350
1351    #[test]
1352    fn lex_sci_explicit_positive_exp() {
1353        let t = lex("1e+10").unwrap();
1354        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 1e10).abs() < 1e5));
1355    }
1356
1357    #[test]
1358    fn lex_sci_decimal_negative_exp() {
1359        let t = lex("3.14e-2").unwrap();
1360        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 0.0314).abs() < 0.001));
1361    }
1362
1363    #[test]
1364    fn lex_sci_zero_exponent() {
1365        let t = lex("0.5e0").unwrap();
1366        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 0.5).abs() < 0.001));
1367    }
1368
1369    #[test]
1370    fn lex_sci_very_small() {
1371        let t = lex("1e-300").unwrap();
1372        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if *v > 0.0 && *v < 1e-299));
1373    }
1374
1375    #[test]
1376    fn lex_sci_uppercase_no_decimal() {
1377        let t = lex("1E10").unwrap();
1378        assert!(matches!(&t[0].kind, TokenKind::FloatLit(v) if (*v - 1e10).abs() < 1e5));
1379    }
1380
1381    #[test]
1382    fn lex_slash_vs_comment() {
1383        // Single `/` is Slash, `//` is a comment.
1384        let tokens = lex("a / b // comment").unwrap();
1385        assert!(matches!(tokens[0].kind, TokenKind::Ident(ref s) if s == "a"));
1386        assert!(matches!(tokens[1].kind, TokenKind::Slash));
1387        assert!(matches!(tokens[2].kind, TokenKind::Ident(ref s) if s == "b"));
1388        assert!(matches!(tokens[3].kind, TokenKind::Eof));
1389    }
1390
1391    // ── SI suffixes (polydat_grammar.md §2.3) ──
1392
1393    #[test]
1394    fn lex_si_decimal_k_m_g_t_p() {
1395        let tokens = lex("1K 1M 1G 1T 1P").unwrap();
1396        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1_000)));
1397        assert!(matches!(tokens[1].kind, TokenKind::IntLit(1_000_000)));
1398        assert!(matches!(tokens[2].kind, TokenKind::IntLit(1_000_000_000)));
1399        assert!(matches!(
1400            tokens[3].kind,
1401            TokenKind::IntLit(1_000_000_000_000)
1402        ));
1403        assert!(matches!(
1404            tokens[4].kind,
1405            TokenKind::IntLit(1_000_000_000_000_000)
1406        ));
1407    }
1408
1409    #[test]
1410    fn lex_si_binary_ki_mi_gi_ti_pi() {
1411        let tokens = lex("1Ki 1Mi 1Gi 1Ti 1Pi").unwrap();
1412        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1024)));
1413        assert!(matches!(tokens[1].kind, TokenKind::IntLit(1_048_576)));
1414        assert!(matches!(tokens[2].kind, TokenKind::IntLit(1_073_741_824)));
1415        assert!(matches!(
1416            tokens[3].kind,
1417            TokenKind::IntLit(1_099_511_627_776)
1418        ));
1419        assert!(matches!(
1420            tokens[4].kind,
1421            TokenKind::IntLit(1_125_899_906_842_624)
1422        ));
1423    }
1424
1425    #[test]
1426    fn lex_si_subunit_m_u_n() {
1427        // Sub-unit suffixes promote integer to float (5m = 0.005).
1428        let tokens = lex("5m 5u 5n").unwrap();
1429        assert!(matches!(tokens[0].kind, TokenKind::FloatLit(v) if (v - 0.005).abs() < 1e-12));
1430        assert!(matches!(tokens[1].kind, TokenKind::FloatLit(v) if (v - 0.000_005).abs() < 1e-15));
1431        assert!(
1432            matches!(tokens[2].kind, TokenKind::FloatLit(v) if (v - 0.000_000_005).abs() < 1e-18)
1433        );
1434    }
1435
1436    #[test]
1437    fn lex_si_float_base_with_decimal_suffix() {
1438        // 1.5K = 1500, integral → IntLit per polydat_grammar.md §2.3
1439        let tokens = lex("1.5K").unwrap();
1440        assert!(
1441            matches!(tokens[0].kind, TokenKind::IntLit(1_500)),
1442            "1.5K should be IntLit(1500), got {:?}",
1443            tokens[0].kind
1444        );
1445    }
1446
1447    #[test]
1448    fn lex_si_float_base_non_integral_stays_float() {
1449        // 1.5G = 1_500_000_000.0 — integral → IntLit
1450        // 1.5K = 1500 — integral → IntLit
1451        // 1.25K = 1250 — integral → IntLit
1452        // To force float: an irrational-result combination.
1453        // 0.001K = 1.0 → still integral → IntLit. Hmm. Let's
1454        // try a sub-unit float: 1.5m = 0.0015, non-integral.
1455        let tokens = lex("1.5m").unwrap();
1456        assert!(matches!(tokens[0].kind, TokenKind::FloatLit(v) if (v - 0.0015).abs() < 1e-12));
1457    }
1458
1459    #[test]
1460    fn lex_si_kilometers_stays_identifier() {
1461        // The K of `Kilometers` is followed by an
1462        // identifier-cont char; suffix doesn't apply.
1463        let tokens = lex("1 Kilometers").unwrap();
1464        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1)));
1465        assert!(matches!(tokens[1].kind, TokenKind::Ident(ref s) if s == "Kilometers"));
1466    }
1467
1468    #[test]
1469    fn lex_si_overflow_errors_loud() {
1470        // 1P × 1000 doesn't overflow but multiplying by P
1471        // a value already at u64::MAX/1000 + 1 would. Use
1472        // a value that 1P would overflow: 100000P = 10^20 > u64::MAX.
1473        let err = lex("100000P").unwrap_err();
1474        assert!(err.contains("overflows u64"), "{err}");
1475    }
1476
1477    #[test]
1478    fn lex_si_in_range_expression() {
1479        // SI literals work in range positions, as in
1480        // `1K..1M..100K`. The lexer does not interpret
1481        // ranges; it produces the numeric tokens.
1482        let tokens = lex("1K..1M..100K").unwrap();
1483        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1_000)));
1484        // The IntLits' positions depend on how `..` is
1485        // tokenized, so the test walks the IntLits rather than
1486        // indexing them.
1487        let int_lits: Vec<u64> = tokens
1488            .iter()
1489            .filter_map(|t| match &t.kind {
1490                TokenKind::IntLit(v) => Some(*v),
1491                _ => None,
1492            })
1493            .collect();
1494        assert_eq!(int_lits, vec![1_000, 1_000_000, 100_000]);
1495    }
1496
1497    #[test]
1498    fn lex_si_disambiguation_two_char_first() {
1499        // `1Ki` should match the 2-char binary suffix, NOT
1500        // 1-char `K` followed by `i` identifier.
1501        let tokens = lex("1Ki").unwrap();
1502        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1024)));
1503        // No leftover identifier after the suffix.
1504        assert!(matches!(tokens[1].kind, TokenKind::Eof));
1505    }
1506
1507    #[test]
1508    fn lex_si_followed_by_operator_applies_suffix() {
1509        // `1K+5` — `K` followed by `+` (not ident-cont) → suffix applies.
1510        let tokens = lex("1K+5").unwrap();
1511        assert!(matches!(tokens[0].kind, TokenKind::IntLit(1000)));
1512        assert!(matches!(tokens[1].kind, TokenKind::Plus));
1513        assert!(matches!(tokens[2].kind, TokenKind::IntLit(5)));
1514    }
1515}