Skip to main content

logicaffeine_language/
lexer.rs

1//! Two-stage lexer for LOGOS natural language input.
2//!
3//! The lexer transforms natural language text into a token stream suitable
4//! for parsing. It operates in two stages:
5//!
6//! ## Stage 1: Line Lexer
7//!
8//! The [`LineLexer`] handles structural concerns:
9//!
10//! - **Indentation**: Tracks indent levels, emits `Indent`/`Dedent` tokens
11//! - **Block boundaries**: Identifies significant whitespace
12//! - **Content extraction**: Passes line content to Stage 2
13//!
14//! ## Stage 2: Word Lexer
15//!
16//! The [`Lexer`] performs word-level tokenization:
17//!
18//! - **Vocabulary lookup**: Identifies words via the lexicon database
19//! - **Morphological analysis**: Handles inflection (verb tenses, plurals)
20//! - **Ambiguity resolution**: Uses priority rules for ambiguous words
21//!
22//! ## Ambiguity Rules
23//!
24//! When a word matches multiple lexicon entries, priority determines the token:
25//!
26//! 1. **Quantifiers** over nouns ("some" → Quantifier, not Noun)
27//! 2. **Determiners** over adjectives ("the" → Determiner, not Adjective)
28//! 3. **Verbs** over nouns for -ing/-ed forms ("running" → Verb)
29//!
30//! ## Example
31//!
32//! ```text
33//! Input:  "Every cat sleeps."
34//! Output: [Quantifier("every"), Noun("cat"), Verb("sleeps"), Period]
35//! ```
36
37use logicaffeine_base::Interner;
38use crate::lexicon::{self, Aspect, Definiteness, Lexicon, Time};
39use crate::token::{BlockType, CalendarUnit, FocusKind, MeasureKind, Span, Token, TokenType};
40
41// ============================================================================
42// Stage 1: Line Lexer (Spec §2.5.2)
43// ============================================================================
44
45/// Tokens emitted by the LineLexer (Stage 1).
46/// Handles structural tokens (Indent, Dedent, Newline) while treating
47/// all other content as opaque for Stage 2 word classification.
48#[derive(Debug, Clone, PartialEq)]
49pub enum LineToken {
50    /// Block increased indentation
51    Indent,
52    /// Block decreased indentation
53    Dedent,
54    /// Logical newline (statement boundary) - reserved for future use
55    Newline,
56    /// Content to be further tokenized (line content, trimmed)
57    Content { text: String, start: usize, end: usize },
58}
59
60/// Stage 1 Lexer: Handles only lines, indentation, and structural tokens.
61/// Treats all other text as opaque `Content` for the Stage 2 WordLexer.
62pub struct LineLexer<'a> {
63    source: &'a str,
64    bytes: &'a [u8],
65    indent_stack: Vec<usize>,
66    pending_dedents: usize,
67    position: usize,
68    /// True if we need to emit Content for current line
69    has_pending_content: bool,
70    pending_content_start: usize,
71    pending_content_end: usize,
72    pending_content_text: String,
73    /// True after we've finished processing all lines
74    finished_lines: bool,
75    /// True if we've emitted at least one Indent (need to emit Dedents at EOF)
76    emitted_indent: bool,
77    /// Escape block body byte ranges to skip (start_byte, end_byte)
78    escape_body_ranges: Vec<(usize, usize)>,
79}
80
81impl<'a> LineLexer<'a> {
82    pub fn new(source: &'a str) -> Self {
83        Self {
84            source,
85            bytes: source.as_bytes(),
86            indent_stack: vec![0],
87            pending_dedents: 0,
88            position: 0,
89            has_pending_content: false,
90            pending_content_start: 0,
91            pending_content_end: 0,
92            pending_content_text: String::new(),
93            finished_lines: false,
94            emitted_indent: false,
95            escape_body_ranges: Vec::new(),
96        }
97    }
98
99    pub fn with_escape_ranges(source: &'a str, escape_body_ranges: Vec<(usize, usize)>) -> Self {
100        Self {
101            source,
102            bytes: source.as_bytes(),
103            indent_stack: vec![0],
104            pending_dedents: 0,
105            position: 0,
106            has_pending_content: false,
107            pending_content_start: 0,
108            pending_content_end: 0,
109            pending_content_text: String::new(),
110            finished_lines: false,
111            emitted_indent: false,
112            escape_body_ranges,
113        }
114    }
115
116    /// Check if a byte position falls within an escape body range.
117    fn is_in_escape_body(&self, pos: usize) -> bool {
118        self.escape_body_ranges.iter().any(|(start, end)| pos >= *start && pos < *end)
119    }
120
121    /// Calculate indentation level at current position (at start of line).
122    /// Returns (indent_level, content_start_pos).
123    fn measure_indent(&self, line_start: usize) -> (usize, usize) {
124        let mut indent = 0;
125        let mut pos = line_start;
126
127        while pos < self.bytes.len() {
128            match self.bytes[pos] {
129                b' ' => {
130                    indent += 1;
131                    pos += 1;
132                }
133                b'\t' => {
134                    indent += 4; // Tab = 4 spaces
135                    pos += 1;
136                }
137                _ => break,
138            }
139        }
140
141        (indent, pos)
142    }
143
144    /// Read content from current position until end of line or EOF.
145    /// Returns (content_text, content_start, content_end, next_line_start).
146    fn read_line_content(&self, content_start: usize) -> (String, usize, usize, usize) {
147        let mut pos = content_start;
148
149        // Find end of line
150        while pos < self.bytes.len() && self.bytes[pos] != b'\n' {
151            pos += 1;
152        }
153
154        let content_end = pos;
155        let text = self.source[content_start..content_end].trim_end().to_string();
156
157        // Move past newline if present
158        let next_line_start = if pos < self.bytes.len() && self.bytes[pos] == b'\n' {
159            pos + 1
160        } else {
161            pos
162        };
163
164        (text, content_start, content_end, next_line_start)
165    }
166
167    /// Check if the line starting at `pos` is blank (only whitespace).
168    fn is_blank_line(&self, line_start: usize) -> bool {
169        let mut pos = line_start;
170        while pos < self.bytes.len() {
171            match self.bytes[pos] {
172                b' ' | b'\t' => pos += 1,
173                b'\n' => return true,
174                _ => return false,
175            }
176        }
177        true // EOF counts as blank
178    }
179
180    /// Process the next line and update internal state.
181    /// Returns true if we have tokens to emit, false if we're done.
182    fn process_next_line(&mut self) -> bool {
183        // Skip blank lines
184        while self.position < self.bytes.len() && self.is_blank_line(self.position) {
185            // Skip to next line
186            while self.position < self.bytes.len() && self.bytes[self.position] != b'\n' {
187                self.position += 1;
188            }
189            if self.position < self.bytes.len() {
190                self.position += 1; // Skip the newline
191            }
192        }
193
194        // Check if we've reached EOF
195        if self.position >= self.bytes.len() {
196            self.finished_lines = true;
197            // Emit remaining dedents at EOF
198            if self.indent_stack.len() > 1 {
199                self.pending_dedents = self.indent_stack.len() - 1;
200                self.indent_stack.truncate(1);
201            }
202            return self.pending_dedents > 0;
203        }
204
205        // Measure indentation of current line
206        let (line_indent, content_start) = self.measure_indent(self.position);
207
208        // Read line content
209        let (text, start, end, next_pos) = self.read_line_content(content_start);
210
211        // Skip if content is empty (shouldn't happen after blank line skip, but be safe)
212        if text.is_empty() {
213            self.position = next_pos;
214            return self.process_next_line();
215        }
216
217        let current_indent = *self.indent_stack.last().unwrap();
218
219        // Handle indentation changes
220        if line_indent > current_indent {
221            // Indent: push new level
222            self.indent_stack.push(line_indent);
223            self.emitted_indent = true;
224            // Store content to emit after Indent
225            self.has_pending_content = true;
226            self.pending_content_text = text;
227            self.pending_content_start = start;
228            self.pending_content_end = end;
229            self.position = next_pos;
230            // We'll emit Indent first, then Content
231            return true;
232        } else if line_indent < current_indent {
233            // Dedent: pop until we match
234            while self.indent_stack.len() > 1 {
235                let top = *self.indent_stack.last().unwrap();
236                if line_indent < top {
237                    self.indent_stack.pop();
238                    self.pending_dedents += 1;
239                } else {
240                    break;
241                }
242            }
243            // Store content to emit after Dedents
244            self.has_pending_content = true;
245            self.pending_content_text = text;
246            self.pending_content_start = start;
247            self.pending_content_end = end;
248            self.position = next_pos;
249            return true;
250        } else {
251            // Same indentation level
252            self.has_pending_content = true;
253            self.pending_content_text = text;
254            self.pending_content_start = start;
255            self.pending_content_end = end;
256            self.position = next_pos;
257            return true;
258        }
259    }
260}
261
262impl<'a> Iterator for LineLexer<'a> {
263    type Item = LineToken;
264
265    fn next(&mut self) -> Option<LineToken> {
266        // 1. Emit pending dedents first
267        if self.pending_dedents > 0 {
268            self.pending_dedents -= 1;
269            return Some(LineToken::Dedent);
270        }
271
272        // 2. Emit pending content
273        if self.has_pending_content {
274            self.has_pending_content = false;
275            let text = std::mem::take(&mut self.pending_content_text);
276            let start = self.pending_content_start;
277            let end = self.pending_content_end;
278            return Some(LineToken::Content { text, start, end });
279        }
280
281        // 3. Check if we need to emit Indent (after pushing to stack)
282        // This happens when we detected an indent but haven't emitted the token yet
283        // We need to check if indent_stack was just modified
284
285        // 4. Process next line
286        if !self.finished_lines {
287            let had_indent = self.indent_stack.len();
288            if self.process_next_line() {
289                // Check if we added an indent level
290                if self.indent_stack.len() > had_indent {
291                    return Some(LineToken::Indent);
292                }
293                // Check if we have pending dedents
294                if self.pending_dedents > 0 {
295                    self.pending_dedents -= 1;
296                    return Some(LineToken::Dedent);
297                }
298                // Otherwise emit content
299                if self.has_pending_content {
300                    self.has_pending_content = false;
301                    let text = std::mem::take(&mut self.pending_content_text);
302                    let start = self.pending_content_start;
303                    let end = self.pending_content_end;
304                    return Some(LineToken::Content { text, start, end });
305                }
306            } else if self.pending_dedents > 0 {
307                // EOF with pending dedents
308                self.pending_dedents -= 1;
309                return Some(LineToken::Dedent);
310            }
311        }
312
313        // 5. Emit any remaining dedents at EOF
314        if self.pending_dedents > 0 {
315            self.pending_dedents -= 1;
316            return Some(LineToken::Dedent);
317        }
318
319        None
320    }
321}
322
323// ============================================================================
324// Stage 2: Word Lexer (existing Lexer)
325// ============================================================================
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
328pub enum LexerMode {
329    #[default]
330    Declarative, // Logic, Theorems, Definitions
331    Imperative,  // Main, Functions, Code
332}
333
334pub struct Lexer<'a> {
335    words: Vec<WordItem>,
336    pos: usize,
337    lexicon: Lexicon,
338    interner: &'a mut Interner,
339    input_len: usize,
340    in_let_context: bool,
341    mode: LexerMode,
342    source: String,
343    /// Escape block body byte ranges: (skip_start, skip_end) for filtering LineLexer events
344    escape_body_ranges: Vec<(usize, usize)>,
345}
346
347struct WordItem {
348    word: String,
349    trailing_punct: Option<char>,
350    start: usize,
351    end: usize,
352    punct_pos: Option<usize>,
353}
354
355impl<'a> Lexer<'a> {
356    /// Creates a new lexer for the given input text.
357    ///
358    /// The lexer will tokenize natural language text according to the
359    /// lexicon database, performing morphological analysis and ambiguity
360    /// resolution.
361    ///
362    /// # Arguments
363    ///
364    /// * `input` - The natural language text to tokenize
365    /// * `interner` - String interner for efficient symbol handling
366    ///
367    /// # Example
368    ///
369    /// ```
370    /// use logicaffeine_language::lexer::Lexer;
371    /// use logicaffeine_base::Interner;
372    ///
373    /// let mut interner = Interner::new();
374    /// let mut lexer = Lexer::new("Every cat sleeps.", &mut interner);
375    /// let tokens = lexer.tokenize();
376    ///
377    /// assert_eq!(tokens.len(), 5); // Quantifier, Noun, Verb, Period, EOI
378    /// ```
379    pub fn new(input: &str, interner: &'a mut Interner) -> Self {
380        let escape_ranges = Self::find_escape_block_ranges(input);
381        let escape_body_ranges: Vec<(usize, usize)> = escape_ranges.iter()
382            .map(|(_, end, content_start, _)| (*content_start, *end))
383            .collect();
384        let words = Self::split_into_words(input, &escape_ranges);
385        let input_len = input.len();
386
387        Lexer {
388            words,
389            pos: 0,
390            lexicon: Lexicon::new(),
391            interner,
392            input_len,
393            in_let_context: false,
394            mode: LexerMode::Declarative,
395            source: input.to_string(),
396            escape_body_ranges,
397        }
398    }
399
400    /// Pre-scan source text for escape block bodies.
401    /// Returns (skip_start_byte, skip_end_byte, content_start_byte, raw_code) tuples.
402    /// `skip_start` is the line start (for byte skipping in split_into_words).
403    /// `content_start` is after leading whitespace (for token span alignment with Indent events).
404    fn find_escape_block_ranges(source: &str) -> Vec<(usize, usize, usize, String)> {
405        let mut ranges = Vec::new();
406        let lines: Vec<&str> = source.split('\n').collect();
407        let mut line_starts: Vec<usize> = Vec::with_capacity(lines.len());
408        let mut pos = 0;
409        for line in &lines {
410            line_starts.push(pos);
411            pos += line.len() + 1; // +1 for the newline
412        }
413
414        let mut i = 0;
415        while i < lines.len() {
416            let trimmed = lines[i].trim();
417            // Check if this line contains an escape header: "Escape to Rust:"
418            // Matches both statement position (whole line) and expression position
419            // (e.g., "Let x: Int be Escape to Rust:")
420            let lower = trimmed.to_lowercase();
421            if lower == "escape to rust:" ||
422               lower.ends_with(" escape to rust:") ||
423               (lower.starts_with("escape to ") && lower.ends_with(':'))
424            {
425                // Find the body: subsequent lines with deeper indentation
426                let header_indent = Self::measure_indent_static(lines[i]);
427                i += 1;
428
429                // Skip blank lines to find the first body line
430                let mut body_start_line = i;
431                while body_start_line < lines.len() && lines[body_start_line].trim().is_empty() {
432                    body_start_line += 1;
433                }
434
435                if body_start_line >= lines.len() {
436                    // No body found
437                    continue;
438                }
439
440                let base_indent = Self::measure_indent_static(lines[body_start_line]);
441                if base_indent <= header_indent {
442                    // No indented body
443                    continue;
444                }
445
446                // Capture all lines at base_indent or deeper
447                let body_byte_start = line_starts[body_start_line];
448                let mut body_end_line = body_start_line;
449                let mut code_lines: Vec<String> = Vec::new();
450
451                let mut j = body_start_line;
452                while j < lines.len() {
453                    let line = lines[j];
454                    if line.trim().is_empty() {
455                        // Blank lines are preserved
456                        code_lines.push(String::new());
457                        body_end_line = j;
458                        j += 1;
459                        continue;
460                    }
461                    let line_indent = Self::measure_indent_static(line);
462                    if line_indent < base_indent {
463                        break;
464                    }
465                    // Strip base indentation
466                    let stripped = Self::strip_indent(line, base_indent);
467                    code_lines.push(stripped);
468                    body_end_line = j;
469                    j += 1;
470                }
471
472                // Trim trailing empty lines from code
473                while code_lines.last().map_or(false, |l| l.is_empty()) {
474                    code_lines.pop();
475                }
476
477                if !code_lines.is_empty() {
478                    let body_byte_end = if body_end_line + 1 < lines.len() {
479                        line_starts[body_end_line + 1]
480                    } else {
481                        source.len()
482                    };
483                    // Compute content start (after leading whitespace of first body line)
484                    let content_start = body_byte_start + Self::leading_whitespace_bytes(lines[body_start_line]);
485                    let raw_code = code_lines.join("\n");
486                    ranges.push((body_byte_start, body_byte_end, content_start, raw_code));
487                }
488
489                i = j;
490            } else {
491                i += 1;
492            }
493        }
494
495        ranges
496    }
497
498    /// Count leading whitespace bytes in a line.
499    fn leading_whitespace_bytes(line: &str) -> usize {
500        let mut count = 0;
501        for c in line.chars() {
502            match c {
503                ' ' | '\t' => count += c.len_utf8(),
504                _ => break,
505            }
506        }
507        count
508    }
509
510    /// Measure indent of a line (static helper for pre-scan).
511    fn measure_indent_static(line: &str) -> usize {
512        let mut indent = 0;
513        for c in line.chars() {
514            match c {
515                ' ' => indent += 1,
516                '\t' => indent += 4,
517                _ => break,
518            }
519        }
520        indent
521    }
522
523    /// Strip `count` leading spaces/tabs from a line.
524    fn strip_indent(line: &str, count: usize) -> String {
525        let mut stripped = 0;
526        let mut byte_pos = 0;
527        for (i, c) in line.char_indices() {
528            if stripped >= count {
529                byte_pos = i;
530                break;
531            }
532            match c {
533                ' ' => { stripped += 1; byte_pos = i + 1; }
534                '\t' => { stripped += 4; byte_pos = i + 1; }
535                _ => { byte_pos = i; break; }
536            }
537        }
538        if stripped < count {
539            byte_pos = line.len();
540        }
541        line[byte_pos..].to_string()
542    }
543
544    fn split_into_words(input: &str, escape_ranges: &[(usize, usize, usize, String)]) -> Vec<WordItem> {
545        let mut items = Vec::new();
546        let mut current_word = String::new();
547        let mut word_start = 0;
548        let chars: Vec<char> = input.chars().collect();
549        let mut char_idx = 0;
550        let mut skip_count = 0;
551        // Inside `[` … `]` a digit-flanked comma is a list separator, not a
552        // thousands separator — `[1,2,3]` is three elements. Money literals
553        // (`$125,000`) keep their commas at any depth; line-local so an
554        // unbalanced bracket never poisons the rest of the input.
555        let mut bracket_depth: usize = 0;
556        // Track byte offset for escape range matching
557        let mut skip_to_byte: Option<usize> = None;
558
559        for (i, c) in input.char_indices() {
560            if skip_count > 0 {
561                skip_count -= 1;
562                char_idx += 1;
563                continue;
564            }
565            // Skip bytes inside escape block bodies
566            if let Some(end) = skip_to_byte {
567                if i < end {
568                    char_idx += 1;
569                    continue;
570                }
571                skip_to_byte = None;
572                word_start = i;
573            }
574            // Check if this byte position starts an escape block body
575            if let Some((_, end, content_start, raw_code)) = escape_ranges.iter().find(|(s, _, _, _)| i == *s) {
576                // Flush any pending word
577                if !current_word.is_empty() {
578                    items.push(WordItem {
579                        word: std::mem::take(&mut current_word),
580                        trailing_punct: None,
581                        start: word_start,
582                        end: i,
583                        punct_pos: None,
584                    });
585                }
586                // Emit the entire block as a single \x00ESC: marker
587                // Use content_start (after whitespace) for span alignment with Indent events
588                items.push(WordItem {
589                    word: format!("\x00ESC:{}", raw_code),
590                    trailing_punct: None,
591                    start: *content_start,
592                    end: *end,
593                    punct_pos: None,
594                });
595                skip_to_byte = Some(*end);
596                word_start = *end;
597                char_idx += 1;
598                continue;
599            }
600            let next_pos = i + c.len_utf8();
601            match c {
602                // "8:15 pm" — a space between a H:MM time and "am"/"pm" is part of
603                // the time literal: skip the flush so "pm" joins "8:15" → "8:15pm".
604                ' ' if Self::is_time_space_before_ampm(&current_word, &chars, char_idx) => {}
605                ' ' | '\t' | '\n' | '\r' => {
606                    if c == '\n' {
607                        bracket_depth = 0;
608                    }
609                    if !current_word.is_empty() {
610                        items.push(WordItem {
611                            word: std::mem::take(&mut current_word),
612                            trailing_punct: None,
613                            start: word_start,
614                            end: i,
615                            punct_pos: None,
616                        });
617                    }
618                    word_start = next_pos;
619                }
620                '.' => {
621                    // Check if this is a decimal point (digit before and after)
622                    let prev_is_digit = !current_word.is_empty()
623                        && current_word.chars().last().map_or(false, |ch| ch.is_ascii_digit());
624                    let next_is_digit = char_idx + 1 < chars.len()
625                        && chars[char_idx + 1].is_ascii_digit();
626
627                    // Field-access / UFCS DOT: an identifier char (letter/`_`) or a
628                    // closing `)`/`]` immediately before, and an identifier-start
629                    // immediately after — no whitespace either side. `p.x`, `xs.f(a)`,
630                    // `(e).m`. Digit-glue wins (`5.sqrt` stays `5` + period + `sqrt`,
631                    // `5.0` stays a decimal), and `Show x.` stays a sentence (the next
632                    // char is a newline, not an identifier). Mode is resolved later:
633                    // `classify_with_lookahead` maps the `\x00DOT` marker to `Dot`
634                    // (imperative) or a sentence `Period` (declarative — prose keeps
635                    // `e.g.` exactly as today).
636                    let prev_ident = current_word
637                        .chars()
638                        .last()
639                        .map_or(false, |ch| ch.is_alphabetic() || ch == '_');
640                    let prev_close = current_word.is_empty()
641                        && char_idx > 0
642                        && matches!(chars[char_idx - 1], ')' | ']');
643                    let next_ident = char_idx + 1 < chars.len()
644                        && (chars[char_idx + 1].is_alphabetic() || chars[char_idx + 1] == '_');
645
646                    if prev_is_digit && next_is_digit {
647                        // This is a decimal point, include it in the current word
648                        current_word.push(c);
649                    } else if (prev_ident || prev_close) && next_ident {
650                        // Flush the receiver, then the mode-deferred dot marker.
651                        if !current_word.is_empty() {
652                            items.push(WordItem {
653                                word: std::mem::take(&mut current_word),
654                                trailing_punct: None,
655                                start: word_start,
656                                end: i,
657                                punct_pos: None,
658                            });
659                        }
660                        items.push(WordItem {
661                            word: "\x00DOT".to_string(),
662                            trailing_punct: None,
663                            start: i,
664                            end: next_pos,
665                            punct_pos: None,
666                        });
667                        word_start = next_pos;
668                    } else {
669                        // This is a sentence period
670                        if !current_word.is_empty() {
671                            items.push(WordItem {
672                                word: std::mem::take(&mut current_word),
673                                trailing_punct: Some(c),
674                                start: word_start,
675                                end: i,
676                                punct_pos: Some(i),
677                            });
678                        } else {
679                            items.push(WordItem {
680                                word: String::new(),
681                                trailing_punct: Some(c),
682                                start: i,
683                                end: next_pos,
684                                punct_pos: Some(i),
685                            });
686                        }
687                        word_start = next_pos;
688                    }
689                }
690                '#' => {
691                    // Check for ## block header (markdown-style)
692                    if char_idx + 1 < chars.len() && chars[char_idx + 1] == '#' {
693                        // This is a ## block header
694                        // Skip the second # and capture the next word as a block header
695                        if !current_word.is_empty() {
696                            items.push(WordItem {
697                                word: std::mem::take(&mut current_word),
698                                trailing_punct: None,
699                                start: word_start,
700                                end: i,
701                                punct_pos: None,
702                            });
703                        }
704                        // Skip whitespace after ##
705                        let header_start = i;
706                        let mut j = char_idx + 2;
707                        while j < chars.len() && (chars[j] == ' ' || chars[j] == '\t') {
708                            j += 1;
709                        }
710                        // Capture the block type word
711                        let mut block_word = String::from("##");
712                        while j < chars.len() && chars[j].is_alphabetic() {
713                            block_word.push(chars[j]);
714                            j += 1;
715                        }
716                        if block_word.len() > 2 {
717                            items.push(WordItem {
718                                word: block_word,
719                                trailing_punct: None,
720                                start: header_start,
721                                end: header_start + (j - char_idx),
722                                punct_pos: None,
723                            });
724                        }
725                        skip_count = j - char_idx - 1;
726                        word_start = header_start + (j - char_idx);
727                    } else {
728                        // Single # - treat as comment, skip to end of line
729                        // Count how many chars to skip (without modifying char_idx here -
730                        // the main loop's skip handler will increment it)
731                        let mut look_ahead = char_idx + 1;
732                        while look_ahead < chars.len() && chars[look_ahead] != '\n' {
733                            skip_count += 1;
734                            look_ahead += 1;
735                        }
736                        if !current_word.is_empty() {
737                            items.push(WordItem {
738                                word: std::mem::take(&mut current_word),
739                                trailing_punct: None,
740                                start: word_start,
741                                end: i,
742                                punct_pos: None,
743                            });
744                        }
745                        word_start = look_ahead + 1; // Start after the newline
746                    }
747                }
748                // String literals: "hello world" or """multi-line"""
749                '"' => {
750                    // `r"…"` RAW string: the adjacent `r` prefix is part of
751                    // the literal, not a word — backslashes stay verbatim
752                    // (paths, regexes). Only the exact word `r` glued to the
753                    // quote triggers it; `Show r.` (a variable) is untouched.
754                    let raw_string = current_word == "r";
755                    if raw_string {
756                        current_word.clear();
757                    } else if !current_word.is_empty() {
758                        items.push(WordItem {
759                            word: std::mem::take(&mut current_word),
760                            trailing_punct: None,
761                            start: word_start,
762                            end: i,
763                            punct_pos: None,
764                        });
765                    }
766
767                    // Check for triple-quote: """
768                    if char_idx + 2 < chars.len() && chars[char_idx + 1] == '"' && chars[char_idx + 2] == '"' {
769                        let string_start = i;
770                        let mut j = char_idx + 3; // skip opening """
771                        // Skip optional newline after opening """
772                        if j < chars.len() && chars[j] == '\n' {
773                            j += 1;
774                        }
775                        let mut raw_content = String::new();
776                        // Scan until closing """
777                        while j < chars.len() {
778                            if j + 2 < chars.len() && chars[j] == '"' && chars[j + 1] == '"' && chars[j + 2] == '"' {
779                                break;
780                            }
781                            raw_content.push(chars[j]);
782                            j += 1;
783                        }
784                        // Strip trailing newline before closing """
785                        if raw_content.ends_with('\n') {
786                            raw_content.pop();
787                        }
788                        // Dedent: find minimum common indentation and strip it
789                        let dedented = Self::dedent_triple_quote(&raw_content);
790                        let end_pos = if j + 2 < chars.len() { j + 3 } else { chars.len() };
791                        items.push(WordItem {
792                            word: format!("\x00STR:{}", dedented),
793                            trailing_punct: None,
794                            start: string_start,
795                            end: end_pos,
796                            punct_pos: None,
797                        });
798                        // Skip past the closing """
799                        if j + 2 < chars.len() {
800                            skip_count = (j + 2) - char_idx;
801                        } else {
802                            skip_count = chars.len() - 1 - char_idx;
803                        }
804                        word_start = end_pos;
805                    } else {
806                        // Single-quoted string: scan until closing quote
807                        let string_start = i;
808                        let mut j = char_idx + 1;
809                        let mut string_content = String::new();
810                        while j < chars.len() && chars[j] != '"' {
811                            if chars[j] == '\\' && j + 1 < chars.len() && !raw_string {
812                                // DECODE the escape (`"a\nb"` is two lines,
813                                // not the letters a-n-b). Unknown escapes and
814                                // malformed \u{…} stay verbatim rather than
815                                // silently dropping the backslash.
816                                j += 1;
817                                match chars[j] {
818                                    'n' => string_content.push('\n'),
819                                    't' => string_content.push('\t'),
820                                    'r' => string_content.push('\r'),
821                                    '0' => string_content.push('\0'),
822                                    '\\' => string_content.push('\\'),
823                                    '"' => string_content.push('"'),
824                                    'u' if j + 1 < chars.len() && chars[j + 1] == '{' => {
825                                        let mut k = j + 2;
826                                        let mut hex = String::new();
827                                        while k < chars.len() && chars[k] != '}' {
828                                            hex.push(chars[k]);
829                                            k += 1;
830                                        }
831                                        match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
832                                            Some(c) if k < chars.len() => {
833                                                string_content.push(c);
834                                                j = k;
835                                            }
836                                            _ => {
837                                                string_content.push('\\');
838                                                string_content.push('u');
839                                            }
840                                        }
841                                    }
842                                    other => {
843                                        string_content.push('\\');
844                                        string_content.push(other);
845                                    }
846                                }
847                            } else {
848                                string_content.push(chars[j]);
849                            }
850                            j += 1;
851                        }
852
853                        // Create a special marker for string literals
854                        // We prefix with a special character to identify in tokenize()
855                        //
856                        // `end` and the following `word_start` are BYTE offsets
857                        // (Span fields are byte positions, like `start`/`i`); `j`
858                        // is a CHAR index, so convert through the byte length of
859                        // the covered source chars. Emitting `j + 1` (a char
860                        // index) produced a byte-start / char-end span that
861                        // underflows downstream on multibyte input.
862                        let end_char = if j < chars.len() { j + 1 } else { j };
863                        let end_byte = string_start
864                            + chars[char_idx..end_char].iter().map(|c| c.len_utf8()).sum::<usize>();
865                        items.push(WordItem {
866                            word: format!("\x00STR:{}", string_content),
867                            trailing_punct: None,
868                            start: string_start,
869                            end: end_byte,
870                            punct_pos: None,
871                        });
872
873                        // Skip past the closing quote
874                        if j < chars.len() {
875                            skip_count = j - char_idx;
876                        } else {
877                            skip_count = j - char_idx - 1;
878                        }
879                        word_start = end_byte;
880                    }
881                }
882                // Character literals with backticks: `x`
883                '`' => {
884                    // Push any pending word
885                    if !current_word.is_empty() {
886                        items.push(WordItem {
887                            word: std::mem::take(&mut current_word),
888                            trailing_punct: None,
889                            start: word_start,
890                            end: i,
891                            punct_pos: None,
892                        });
893                    }
894
895                    // Scan for character content and closing backtick
896                    let char_start = i;
897                    let mut j = char_idx + 1;
898                    let mut char_content = String::new();
899
900                    if j < chars.len() {
901                        if chars[j] == '\\' && j + 1 < chars.len() {
902                            // Escape sequence
903                            j += 1;
904                            let escaped_char = match chars[j] {
905                                'n' => '\n',
906                                't' => '\t',
907                                'r' => '\r',
908                                '\\' => '\\',
909                                '`' => '`',
910                                '0' => '\0',
911                                c => c,
912                            };
913                            char_content.push(escaped_char);
914                            j += 1;
915                        } else if chars[j] != '`' {
916                            // Regular character
917                            char_content.push(chars[j]);
918                            j += 1;
919                        }
920                    }
921
922                    // Expect closing backtick
923                    if j < chars.len() && chars[j] == '`' {
924                        j += 1; // skip closing backtick
925                    }
926
927                    // Create a special marker for char literals
928                    items.push(WordItem {
929                        word: format!("\x00CHAR:{}", char_content),
930                        trailing_punct: None,
931                        start: char_start,
932                        end: if j <= chars.len() { char_start + (j - char_idx) } else { char_start + 1 },
933                        punct_pos: None,
934                    });
935
936                    if j > char_idx + 1 {
937                        skip_count = j - char_idx - 1;
938                    }
939                    word_start = char_start + (j - char_idx);
940                }
941                // Handle -> as a single token for return type syntax
942                '-' if char_idx + 1 < chars.len() && chars[char_idx + 1] == '>' => {
943                    // Push any pending word first
944                    if !current_word.is_empty() {
945                        items.push(WordItem {
946                            word: std::mem::take(&mut current_word),
947                            trailing_punct: None,
948                            start: word_start,
949                            end: i,
950                            punct_pos: None,
951                        });
952                    }
953                    // Push -> as its own word
954                    items.push(WordItem {
955                        word: "->".to_string(),
956                        trailing_punct: None,
957                        start: i,
958                        end: i + 2,
959                        punct_pos: None,
960                    });
961                    skip_count = 1; // Skip the '>' character
962                    word_start = i + 2;
963                }
964                // Grand Challenge: Handle <= as a single token
965                '<' if char_idx + 1 < chars.len() && chars[char_idx + 1] == '=' => {
966                    if !current_word.is_empty() {
967                        items.push(WordItem {
968                            word: std::mem::take(&mut current_word),
969                            trailing_punct: None,
970                            start: word_start,
971                            end: i,
972                            punct_pos: None,
973                        });
974                    }
975                    items.push(WordItem {
976                        word: "<=".to_string(),
977                        trailing_punct: None,
978                        start: i,
979                        end: i + 2,
980                        punct_pos: None,
981                    });
982                    skip_count = 1;
983                    word_start = i + 2;
984                }
985                // Grand Challenge: Handle >= as a single token
986                '>' if char_idx + 1 < chars.len() && chars[char_idx + 1] == '=' => {
987                    if !current_word.is_empty() {
988                        items.push(WordItem {
989                            word: std::mem::take(&mut current_word),
990                            trailing_punct: None,
991                            start: word_start,
992                            end: i,
993                            punct_pos: None,
994                        });
995                    }
996                    items.push(WordItem {
997                        word: ">=".to_string(),
998                        trailing_punct: None,
999                        start: i,
1000                        end: i + 2,
1001                        punct_pos: None,
1002                    });
1003                    skip_count = 1;
1004                    word_start = i + 2;
1005                }
1006                // Handle == as a single token
1007                '=' if char_idx + 1 < chars.len() && chars[char_idx + 1] == '=' => {
1008                    if !current_word.is_empty() {
1009                        items.push(WordItem {
1010                            word: std::mem::take(&mut current_word),
1011                            trailing_punct: None,
1012                            start: word_start,
1013                            end: i,
1014                            punct_pos: None,
1015                        });
1016                    }
1017                    items.push(WordItem {
1018                        word: "==".to_string(),
1019                        trailing_punct: None,
1020                        start: i,
1021                        end: i + 2,
1022                        punct_pos: None,
1023                    });
1024                    skip_count = 1;
1025                    word_start = i + 2;
1026                }
1027                // Handle != as a single token
1028                '!' if char_idx + 1 < chars.len() && chars[char_idx + 1] == '=' => {
1029                    if !current_word.is_empty() {
1030                        items.push(WordItem {
1031                            word: std::mem::take(&mut current_word),
1032                            trailing_punct: None,
1033                            start: word_start,
1034                            end: i,
1035                            punct_pos: None,
1036                        });
1037                    }
1038                    items.push(WordItem {
1039                        word: "!=".to_string(),
1040                        trailing_punct: None,
1041                        start: i,
1042                        end: i + 2,
1043                        punct_pos: None,
1044                    });
1045                    skip_count = 1;
1046                    word_start = i + 2;
1047                }
1048                // Special handling for '-' in ISO-8601 dates (YYYY-MM-DD)
1049                '-' if Self::is_date_hyphen(&current_word, &chars, char_idx) => {
1050                    // This hyphen is part of a date, include it in the word
1051                    current_word.push(c);
1052                }
1053                // Special handling for ':' in time literals (9:30am, 11:45pm)
1054                ':' if Self::is_time_colon(&current_word, &chars, char_idx) => {
1055                    // This colon is part of a time, include it in the word
1056                    current_word.push(c);
1057                }
1058                // Hyphenated compounds ("weak-until", "highest-priority"):
1059                // a hyphen BETWEEN letters is part of the word, never the
1060                // arithmetic minus (which is space- or digit-adjacent).
1061                '-' if char_idx > 0
1062                    && chars[char_idx - 1].is_alphabetic()
1063                    && char_idx + 1 < chars.len()
1064                    && chars[char_idx + 1].is_alphabetic() => {
1065                    current_word.push(c);
1066                }
1067                // Scientific notation: 4.84e+00, 1.66E-03, 2.5e-2
1068                '+' | '-' if Self::is_exponent_sign(&current_word, &chars, char_idx) => {
1069                    current_word.push(c);
1070                }
1071                // Alphanumeric codes ("AV-435", "FRZ-192", "I-5"): a hyphen with
1072                // an UPPERCASE-code word before it and a DIGIT after is part of
1073                // the identifier, not an arithmetic minus. The all-uppercase gate
1074                // keeps lowercase variables ("x-1") and digit arithmetic ("5-3")
1075                // emitting a Minus.
1076                '-' if char_idx > 0
1077                    && chars[char_idx - 1].is_alphabetic()
1078                    && char_idx + 1 < chars.len()
1079                    && chars[char_idx + 1].is_ascii_digit()
1080                    && !current_word.is_empty()
1081                    && current_word.chars().all(|ch| ch.is_ascii_uppercase()) => {
1082                    current_word.push(c);
1083                }
1084                // Letter grades ("B+", "A+"): a '+' directly after an
1085                // uppercase-letter word, NOT followed by a digit (arithmetic
1086                // "B+2"), is part of the grade. A spaced "B + C" flushes "B"
1087                // before the '+', so only the glued grade form is caught.
1088                '+' if !current_word.is_empty()
1089                    && current_word.chars().all(|ch| ch.is_ascii_uppercase())
1090                    && (char_idx + 1 >= chars.len()
1091                        || !chars[char_idx + 1].is_ascii_digit()) => {
1092                    current_word.push(c);
1093                }
1094                // Thousands separator inside a number: "125,000", "1,234,567".
1095                // A comma flanked by digits is part of the numeral, not a clause
1096                // separator — except inside a bracketed list, where `[1,2,3]`
1097                // means three elements. A money word (`$125,000`) keeps its
1098                // commas even there.
1099                ',' if char_idx > 0
1100                    && chars[char_idx - 1].is_ascii_digit()
1101                    && char_idx + 1 < chars.len()
1102                    && chars[char_idx + 1].is_ascii_digit()
1103                    && (bracket_depth == 0
1104                        || current_word.chars().next().map_or(false, Self::is_currency_symbol)) => {
1105                    current_word.push(c);
1106                }
1107                // Date separator inside a numeral: "04/2024", "12/25", "04/2024"
1108                // (MM/YYYY, MM/DD). A slash flanked by digits is part of the date
1109                // token, not an arithmetic division (rare in NL clues).
1110                '/' if char_idx > 0
1111                    && chars[char_idx - 1].is_ascii_digit()
1112                    && char_idx + 1 < chars.len()
1113                    && chars[char_idx + 1].is_ascii_digit() => {
1114                    current_word.push(c);
1115                }
1116                // "28-inch", "6-year-old", "12-year-old" — a hyphen with a DIGIT
1117                // before and a LETTER after is orthographic (= "28 inch"), so
1118                // split into separate words here (flush the number, skip the
1119                // hyphen as a word boundary) rather than emitting an arithmetic
1120                // Minus. A digit-digit hyphen ("5-3") stays Minus (letter-after
1121                // required); a letter-letter hyphen ("well-known") is handled above.
1122                '-' if char_idx > 0
1123                    && chars[char_idx - 1].is_ascii_digit()
1124                    && char_idx + 1 < chars.len()
1125                    && chars[char_idx + 1].is_alphabetic() => {
1126                    if !current_word.is_empty() {
1127                        items.push(WordItem {
1128                            word: std::mem::take(&mut current_word),
1129                            trailing_punct: None,
1130                            start: word_start,
1131                            end: i,
1132                            punct_pos: None,
1133                        });
1134                    }
1135                    word_start = next_pos;
1136                }
1137                // `**` exponentiation — a single two-char token (before the
1138                // generic punct arm and the `*=` arm; `**` is `*` then `*`).
1139                '*' if char_idx + 1 < chars.len() && chars[char_idx + 1] == '*' => {
1140                    if !current_word.is_empty() {
1141                        items.push(WordItem {
1142                            word: std::mem::take(&mut current_word),
1143                            trailing_punct: None,
1144                            start: word_start,
1145                            end: i,
1146                            punct_pos: None,
1147                        });
1148                    }
1149                    items.push(WordItem {
1150                        word: "**".to_string(),
1151                        trailing_punct: None,
1152                        start: i,
1153                        end: i + 2,
1154                        punct_pos: None,
1155                    });
1156                    skip_count = 1;
1157                    word_start = i + 2;
1158                }
1159                // `//` floor division — a single two-char token (before the generic
1160                // punct arm and the `/=` arm; `//` is `/` then `/`, not `/` then `=`).
1161                // A digit-flanked `/` is already claimed above as a date separator, so
1162                // this only fires on a genuine `x // y`.
1163                '/' if char_idx + 1 < chars.len() && chars[char_idx + 1] == '/' => {
1164                    if !current_word.is_empty() {
1165                        items.push(WordItem {
1166                            word: std::mem::take(&mut current_word),
1167                            trailing_punct: None,
1168                            start: word_start,
1169                            end: i,
1170                            punct_pos: None,
1171                        });
1172                    }
1173                    items.push(WordItem {
1174                        word: "//".to_string(),
1175                        trailing_punct: None,
1176                        start: i,
1177                        end: i + 2,
1178                        punct_pos: None,
1179                    });
1180                    skip_count = 1;
1181                    word_start = i + 2;
1182                }
1183                // Compound assignment `+= -= *= /= %=` — a single two-char token
1184                // (before the generic punct arm so `+` etc. don't split first).
1185                // `-=` is distinct from `->` (next is `=`, not `>`); `/=` from `//`.
1186                '+' | '-' | '*' | '/' | '%'
1187                    if char_idx + 1 < chars.len() && chars[char_idx + 1] == '=' =>
1188                {
1189                    if !current_word.is_empty() {
1190                        items.push(WordItem {
1191                            word: std::mem::take(&mut current_word),
1192                            trailing_punct: None,
1193                            start: word_start,
1194                            end: i,
1195                            punct_pos: None,
1196                        });
1197                    }
1198                    items.push(WordItem {
1199                        word: format!("{c}="),
1200                        trailing_punct: None,
1201                        start: i,
1202                        end: i + 2,
1203                        punct_pos: None,
1204                    });
1205                    skip_count = 1;
1206                    word_start = i + 2;
1207                }
1208                '(' | ')' | '[' | ']' | '{' | '}' | '|' | '~' | '^' | ',' | '?' | '!' | ':' | '+' | '-' | '*' | '/' | '%' | '<' | '>' | '=' => {
1209                    match c {
1210                        // `{…}` literals are bracket contexts for the comma
1211                        // rule too: `{1,2}` is a two-element set, not `{12}`.
1212                        '[' | '{' => bracket_depth += 1,
1213                        ']' | '}' => bracket_depth = bracket_depth.saturating_sub(1),
1214                        _ => {}
1215                    }
1216                    if !current_word.is_empty() {
1217                        items.push(WordItem {
1218                            word: std::mem::take(&mut current_word),
1219                            trailing_punct: Some(c),
1220                            start: word_start,
1221                            end: i,
1222                            punct_pos: Some(i),
1223                        });
1224                    } else {
1225                        items.push(WordItem {
1226                            word: String::new(),
1227                            trailing_punct: Some(c),
1228                            start: i,
1229                            end: next_pos,
1230                            punct_pos: Some(i),
1231                        });
1232                    }
1233                    word_start = next_pos;
1234                }
1235                '\'' => {
1236                    // Handle contractions: expand "don't" → "do" + "not", etc.
1237                    let remaining: String = chars[char_idx + 1..].iter().collect();
1238                    let remaining_lower = remaining.to_lowercase();
1239
1240                    if remaining_lower.starts_with("t ") || remaining_lower.starts_with("t.") ||
1241                       remaining_lower.starts_with("t,") || remaining_lower == "t" ||
1242                       (char_idx + 1 < chars.len() && chars[char_idx + 1] == 't' &&
1243                        (char_idx + 2 >= chars.len() || !chars[char_idx + 2].is_alphabetic())) {
1244                        // The splitter broke the word at the apostrophe, so
1245                        // `current_word` is the n't-contraction STEM ("isn",
1246                        // "won"). Which stems contract — and what they expand
1247                        // to ("won" is suppletive: "will not") — is lexical
1248                        // knowledge, so the lexicon owns the table; here we
1249                        // only apply its expansion.
1250                        let word_lower = current_word.to_lowercase();
1251                        if let Some(expansion) =
1252                            crate::lexicon::lookup_negative_contraction(&word_lower)
1253                        {
1254                            let words: Vec<&str> = expansion.split_whitespace().collect();
1255                            let last_idx = words.len().saturating_sub(1);
1256                            for (idx, part) in words.iter().enumerate() {
1257                                items.push(WordItem {
1258                                    word: (*part).to_string(),
1259                                    trailing_punct: None,
1260                                    start: if idx == 0 { word_start } else { i },
1261                                    end: if idx == last_idx { i + 2 } else { i },
1262                                    punct_pos: None,
1263                                });
1264                            }
1265                            current_word.clear();
1266                            word_start = next_pos + 1;
1267                            skip_count = 1;
1268                        } else {
1269                            // Unknown contraction, split normally
1270                            if !current_word.is_empty() {
1271                                items.push(WordItem {
1272                                    word: std::mem::take(&mut current_word),
1273                                    trailing_punct: Some('\''),
1274                                    start: word_start,
1275                                    end: i,
1276                                    punct_pos: Some(i),
1277                                });
1278                            }
1279                            word_start = next_pos;
1280                        }
1281                    } else {
1282                        // Not a 't contraction, handle normally
1283                        if !current_word.is_empty() {
1284                            items.push(WordItem {
1285                                word: std::mem::take(&mut current_word),
1286                                trailing_punct: Some('\''),
1287                                start: word_start,
1288                                end: i,
1289                                punct_pos: Some(i),
1290                            });
1291                        }
1292                        word_start = next_pos;
1293                    }
1294                }
1295                // Currency-symbol money literal: a `$ € £ ¥` directly before a digit starts a money
1296                // word (`$19.99`, `€5`, `¥100`), so the symbol survives into the word for
1297                // `classify_with_lookahead` to read as `MoneyLiteral`. A lone or non-numeric symbol
1298                // (`$ each`) still falls through to the default arm and is dropped, as before.
1299                c if Self::is_currency_symbol(c)
1300                    && char_idx + 1 < chars.len()
1301                    && chars[char_idx + 1].is_ascii_digit() => {
1302                    if !current_word.is_empty() {
1303                        items.push(WordItem {
1304                            word: std::mem::take(&mut current_word),
1305                            trailing_punct: None,
1306                            start: word_start,
1307                            end: i,
1308                            punct_pos: None,
1309                        });
1310                    }
1311                    word_start = i;
1312                    current_word.push(c);
1313                }
1314                c if c.is_alphabetic() || c.is_ascii_digit() || (c == '.' && !current_word.is_empty() && current_word.chars().all(|ch| ch.is_ascii_digit())) || c == '_' => {
1315                    if current_word.is_empty() {
1316                        word_start = i;
1317                    }
1318                    current_word.push(c);
1319                }
1320                '&' => {
1321                    // "&" is COLOR coordination ("black & red" → emit "and" →
1322                    // Black ∧ Red) when its neighbour is LOWERCASE, but a FIRM-NAME
1323                    // joiner ("Leach & Mccall", "Ingram & Kemp") when CAPITALIZED —
1324                    // there it is dropped so the proper-name absorber joins the
1325                    // names (Leach_Mccall), the prior behaviour. Decide on the next
1326                    // word's case.
1327                    if !current_word.is_empty() {
1328                        items.push(WordItem {
1329                            word: std::mem::take(&mut current_word),
1330                            trailing_punct: None,
1331                            start: word_start,
1332                            end: i,
1333                            punct_pos: None,
1334                        });
1335                    }
1336                    let next_cap = chars[char_idx + 1..]
1337                        .iter()
1338                        .find(|ch| !ch.is_whitespace())
1339                        .map_or(false, |ch| ch.is_ascii_uppercase());
1340                    if !next_cap {
1341                        // Mode-deferred: `classify_with_lookahead` resolves the
1342                        // marker to prose "and" (Declarative) or the bitwise
1343                        // `&` operator (Imperative).
1344                        items.push(WordItem {
1345                            word: "\x00AMP".to_string(),
1346                            trailing_punct: None,
1347                            start: i,
1348                            end: next_pos,
1349                            punct_pos: None,
1350                        });
1351                    }
1352                    word_start = next_pos;
1353                }
1354                _ => {
1355                    word_start = next_pos;
1356                }
1357            }
1358            char_idx += 1;
1359        }
1360
1361        if !current_word.is_empty() {
1362            items.push(WordItem {
1363                word: current_word,
1364                trailing_punct: None,
1365                start: word_start,
1366                end: input.len(),
1367                punct_pos: None,
1368            });
1369        }
1370
1371        items
1372    }
1373
1374    fn peek_word(&self, offset: usize) -> Option<&str> {
1375        self.words.get(self.pos + offset).map(|w| w.word.as_str())
1376    }
1377
1378    /// Check if the previous word is a determiner (every, each, some, all, any, no, the, a, an).
1379    fn prev_token_is_determiner(&self) -> bool {
1380        if self.pos == 0 { return false; }
1381        if let Some(prev) = self.words.get(self.pos - 1) {
1382            matches!(prev.word.to_lowercase().as_str(),
1383                "every" | "each" | "some" | "all" | "any" | "no" | "the" | "a" | "an")
1384        } else {
1385            false
1386        }
1387    }
1388
1389    /// Whether the previous word closes a sentence (ends with `.`/`!`/`?`), so the
1390    /// current word is sentence-initial. Used to tell a capitalized modal that opens a
1391    /// question ("Will Alice win?") from a capitalized proper name mid-clause ("started
1392    /// by Will Waters") — a function word never capitalizes mid-sentence.
1393    fn prev_word_ends_sentence(&self) -> bool {
1394        if self.pos == 0 {
1395            return true;
1396        }
1397        self.words
1398            .get(self.pos - 1)
1399            .and_then(|w| w.word.chars().last())
1400            .map_or(true, |c| matches!(c, '.' | '!' | '?' | ':' | ';'))
1401    }
1402
1403    /// Whether the previous word is a bare number ("30 minutes", "1 second"),
1404    /// used to disambiguate the singular clock units "second"/"minute" (which are
1405    /// also an ordinal and an adjective) — they are time units only after a count.
1406    fn prev_word_is_numeric(&self) -> bool {
1407        if self.pos == 0 { return false; }
1408        self.words
1409            .get(self.pos - 1)
1410            .map(|p| {
1411                let w = p.word.trim_start_matches('$').replace(',', "");
1412                !w.is_empty() && w.chars().all(|c| c.is_ascii_digit() || c == '.')
1413            })
1414            .unwrap_or(false)
1415    }
1416
1417    fn next_token_is_copula(&self) -> bool {
1418        if let Some(next) = self.peek_word(1) {
1419            matches!(next.to_lowercase().as_str(), "is" | "are" | "was" | "were")
1420        } else {
1421            false
1422        }
1423    }
1424
1425    fn peek_sequence(&self, expected: &[&str]) -> bool {
1426        for (i, &exp) in expected.iter().enumerate() {
1427            match self.peek_word(i + 1) {
1428                Some(w) if w.to_lowercase() == exp => continue,
1429                _ => return false,
1430            }
1431        }
1432        true
1433    }
1434
1435    fn consume_words(&mut self, count: usize) {
1436        self.pos += count;
1437    }
1438
1439    /// Tokenizes the input text and returns a vector of [`Token`]s.
1440    ///
1441    /// Each token includes its type, the interned lexeme, and the source
1442    /// span for error reporting. Words are classified according to the
1443    /// lexicon database with priority-based ambiguity resolution.
1444    ///
1445    /// # Returns
1446    ///
1447    /// A vector of tokens representing the input. The final token is
1448    /// typically `TokenType::Eof`.
1449    pub fn tokenize(&mut self) -> Vec<Token> {
1450        let mut tokens = Vec::new();
1451
1452        while self.pos < self.words.len() {
1453            let item = &self.words[self.pos];
1454            let word = item.word.clone();
1455            let trailing_punct = item.trailing_punct;
1456            let word_start = item.start;
1457            let word_end = item.end;
1458            let punct_pos = item.punct_pos;
1459
1460            if word.is_empty() {
1461                if let Some(punct) = trailing_punct {
1462                    let kind = match punct {
1463                        '(' => TokenType::LParen,
1464                        ')' => TokenType::RParen,
1465                        '[' => TokenType::LBracket,
1466                        ']' => TokenType::RBracket,
1467                        '{' => TokenType::LBrace,
1468                        '}' => TokenType::RBrace,
1469                        // Bitwise symbols exist only in IMPERATIVE code; in
1470                        // prose they stay dropped (the old behavior).
1471                        '|' if matches!(self.mode, LexerMode::Imperative) => TokenType::VBar,
1472                        '~' if matches!(self.mode, LexerMode::Imperative) => TokenType::Tilde,
1473                        '^' if matches!(self.mode, LexerMode::Imperative) => TokenType::Caret,
1474                        ',' => TokenType::Comma,
1475                        ':' => TokenType::Colon,
1476                        '.' | '?' => {
1477                            self.in_let_context = false;
1478                            TokenType::Period
1479                        }
1480                        '!' => TokenType::Exclamation,
1481                        '+' => TokenType::Plus,
1482                        '-' => TokenType::Minus,
1483                        '*' => TokenType::Star,
1484                        '/' => TokenType::Slash,
1485                        '%' => TokenType::Percent,
1486                        '<' => TokenType::Lt,
1487                        '>' => TokenType::Gt,
1488                        '=' => TokenType::Assign,
1489                        _ => {
1490                            self.pos += 1;
1491                            continue;
1492                        }
1493                    };
1494                    let lexeme = self.interner.intern(&punct.to_string());
1495                    let span = Span::new(word_start, word_end);
1496                    // Collapse consecutive Periods — an abbreviation's own period
1497                    // followed by the sentence period ("Dorsey Assoc.." → "Assoc"
1498                    // + one Period). The extra "." would otherwise strand the parse.
1499                    let dup_period = kind == TokenType::Period
1500                        && tokens.last().map_or(false, |t: &Token| t.kind == TokenType::Period);
1501                    if !dup_period {
1502                        tokens.push(Token::new(kind, lexeme, span));
1503                    }
1504                }
1505                self.pos += 1;
1506                continue;
1507            }
1508
1509            // Check for string literal marker (pre-tokenized in Stage 1)
1510            if word.starts_with("\x00STR:") {
1511                let content = &word[5..]; // Skip the marker prefix
1512                let span = Span::new(word_start, word_end);
1513                if Self::has_unescaped_brace(content) {
1514                    let sym = self.interner.intern(content);
1515                    tokens.push(Token::new(TokenType::InterpolatedString(sym), sym, span));
1516                } else {
1517                    // Collapse {{ → { and }} → } for plain strings
1518                    let normalized = content.replace("{{", "{").replace("}}", "}");
1519                    let sym = self.interner.intern(&normalized);
1520                    tokens.push(Token::new(TokenType::StringLiteral(sym), sym, span));
1521                }
1522                self.pos += 1;
1523                continue;
1524            }
1525
1526            // Check for character literal marker
1527            if word.starts_with("\x00CHAR:") {
1528                let content = &word[6..]; // Skip the marker prefix
1529                let sym = self.interner.intern(content);
1530                let span = Span::new(word_start, word_end);
1531                tokens.push(Token::new(TokenType::CharLiteral(sym), sym, span));
1532                self.pos += 1;
1533                continue;
1534            }
1535
1536            // Check for escape block marker (pre-captured raw foreign code)
1537            if word.starts_with("\x00ESC:") {
1538                let content = &word[5..]; // Skip the "\x00ESC:" prefix
1539                let sym = self.interner.intern(content);
1540                let span = Span::new(word_start, word_end);
1541                tokens.push(Token::new(TokenType::EscapeBlock(sym), sym, span));
1542                self.pos += 1;
1543                continue;
1544            }
1545
1546            // "exactly N" / "precisely N" — a redundant exactness marker on a following
1547            // count or measure. The count/measure is already exact in the FOL (a value,
1548            // not a ≥/≤ bound), so the word adds no constraint; dropping it lets the
1549            // count object parse ("serves exactly 2 people" ≡ "serves 2 people") with
1550            // zero meaning loss. (Approximative "about N" is a Preposition and keeps its
1551            // own About-relation; "at least/most N" are handled separately as bounds.)
1552            if matches!(word.to_lowercase().as_str(), "exactly" | "precisely")
1553                && self.peek_word(1).map_or(false, |w| {
1554                    let w = w.trim_start_matches('$');
1555                    w.chars().next().map_or(false, |c| c.is_ascii_digit())
1556                        || crate::lexicon::word_to_number(&w.to_lowercase()).is_some()
1557                })
1558            {
1559                self.pos += 1;
1560                continue;
1561            }
1562
1563            let kind = self.classify_with_lookahead(&word);
1564            // A subject pronoun or WH-relativizer takes the copula clitic "'s" = "is"
1565            // ("who's going", "he's …"), never the possessive 's (their genitive is the
1566            // dedicated form whose / its / his). Captured from the host's lexical
1567            // CATEGORY before `kind` is moved into the token, so the contraction rule
1568            // below is data-driven, not a word list.
1569            let host_takes_copula_clitic = matches!(
1570                kind,
1571                TokenType::Pronoun { case: crate::lexicon::Case::Subject, .. }
1572                    | TokenType::Who
1573                    | TokenType::That
1574                    | TokenType::What
1575                    | TokenType::Where
1576            );
1577            let lexeme = self.interner.intern(&word);
1578            let span = Span::new(word_start, word_end);
1579            tokens.push(Token::new(kind, lexeme, span));
1580
1581            if let Some(punct) = trailing_punct {
1582                if punct == '\'' {
1583                    if let Some(next_item) = self.words.get(self.pos + 1) {
1584                        if next_item.word.to_lowercase() == "s" {
1585                            // The contraction "'s" = "is" iff the host takes the copula
1586                            // clitic AND the next word is not a past-participle/non-
1587                            // progressive verb — that case is the perfect "has"
1588                            // ("he's found/been"), a separate unsupported feature, and
1589                            // emitting "is" there would misread it as the passive "is
1590                            // found". Verb aspect comes from the lexicon (data-driven).
1591                            let next_is_past_verb = self
1592                                .words
1593                                .get(self.pos + 2)
1594                                .map(|a| a.word.to_lowercase())
1595                                .and_then(|w| self.lexicon.lookup_verb(&w))
1596                                .map_or(false, |v| v.aspect != crate::lexicon::Aspect::Progressive);
1597                            let (poss_kind, poss_text) = if host_takes_copula_clitic && !next_is_past_verb {
1598                                (TokenType::Is, "is")
1599                            } else {
1600                                (TokenType::Possessive, "'s")
1601                            };
1602                            let poss_lexeme = self.interner.intern(poss_text);
1603                            let poss_start = punct_pos.unwrap_or(word_end);
1604                            let poss_end = next_item.end;
1605                            tokens.push(Token::new(poss_kind, poss_lexeme, Span::new(poss_start, poss_end)));
1606                            self.pos += 1;
1607                            if let Some(s_punct) = next_item.trailing_punct {
1608                                let kind = match s_punct {
1609                                    '(' => TokenType::LParen,
1610                                    ')' => TokenType::RParen,
1611                                    '[' => TokenType::LBracket,
1612                                    ']' => TokenType::RBracket,
1613                                    '{' => TokenType::LBrace,
1614                                    '}' => TokenType::RBrace,
1615                                    '|' if matches!(self.mode, LexerMode::Imperative) => TokenType::VBar,
1616                                    '~' if matches!(self.mode, LexerMode::Imperative) => TokenType::Tilde,
1617                                    '^' if matches!(self.mode, LexerMode::Imperative) => TokenType::Caret,
1618                                    ',' => TokenType::Comma,
1619                                    ':' => TokenType::Colon,
1620                                    '.' | '?' => TokenType::Period,
1621                                    '!' => TokenType::Exclamation,
1622                                    '+' => TokenType::Plus,
1623                                    '-' => TokenType::Minus,
1624                                    '*' => TokenType::Star,
1625                                    '/' => TokenType::Slash,
1626                                    '%' => TokenType::Percent,
1627                                    '<' => TokenType::Lt,
1628                                    '>' => TokenType::Gt,
1629                                    '=' => TokenType::Assign,
1630                                    _ => {
1631                                        self.pos += 1;
1632                                        continue;
1633                                    }
1634                                };
1635                                let s_punct_pos = next_item.punct_pos.unwrap_or(next_item.end);
1636                                let lexeme = self.interner.intern(&s_punct.to_string());
1637                                tokens.push(Token::new(kind, lexeme, Span::new(s_punct_pos, s_punct_pos + 1)));
1638                            }
1639                            self.pos += 1;
1640                            continue;
1641                        }
1642                    }
1643                    self.pos += 1;
1644                    continue;
1645                }
1646
1647                // An abbreviation's own dot ("Mr.", "Dr.", "153 ft.", "Mt.") is NOT a
1648                // sentence terminator when more text follows — emitting a Period there
1649                // strands the rest of the clause. Which words abbreviate is lexical
1650                // (lexicon `abbreviations`); suppress the dot only mid-clue, so a
1651                // genuine clue-final abbreviation ("…on Main St.") keeps its terminator.
1652                if punct == '.'
1653                    && lexicon::is_abbreviation(&word.to_lowercase())
1654                    && self.words.get(self.pos + 1).is_some()
1655                {
1656                    self.pos += 1;
1657                    continue;
1658                }
1659                let kind = match punct {
1660                    '(' => TokenType::LParen,
1661                    ')' => TokenType::RParen,
1662                    '[' => TokenType::LBracket,
1663                    ']' => TokenType::RBracket,
1664                    '{' => TokenType::LBrace,
1665                    '}' => TokenType::RBrace,
1666                    '|' if matches!(self.mode, LexerMode::Imperative) => TokenType::VBar,
1667                    '~' if matches!(self.mode, LexerMode::Imperative) => TokenType::Tilde,
1668                    '^' if matches!(self.mode, LexerMode::Imperative) => TokenType::Caret,
1669                    ',' => TokenType::Comma,
1670                    ':' => TokenType::Colon,
1671                    '.' | '?' => {
1672                        self.in_let_context = false;
1673                        TokenType::Period
1674                    }
1675                    '!' => TokenType::Exclamation,
1676                    '+' => TokenType::Plus,
1677                    '-' => TokenType::Minus,
1678                    '*' => TokenType::Star,
1679                    '/' => TokenType::Slash,
1680                    '%' => TokenType::Percent,
1681                    '<' => TokenType::Lt,
1682                    '>' => TokenType::Gt,
1683                    '=' => TokenType::Assign,
1684                    _ => {
1685                        self.pos += 1;
1686                        continue;
1687                    }
1688                };
1689                let p_start = punct_pos.unwrap_or(word_end);
1690                let lexeme = self.interner.intern(&punct.to_string());
1691                tokens.push(Token::new(kind, lexeme, Span::new(p_start, p_start + 1)));
1692            }
1693
1694            self.pos += 1;
1695        }
1696
1697        let eof_lexeme = self.interner.intern("");
1698        let eof_span = Span::new(self.input_len, self.input_len);
1699        tokens.push(Token::new(TokenType::EOF, eof_lexeme, eof_span));
1700
1701        self.insert_indentation_tokens(tokens)
1702    }
1703
1704    /// Insert Indent/Dedent tokens using LineLexer's two-pass architecture (Spec §2.5.2).
1705    ///
1706    /// Phase 1: LineLexer determines the structural layout (where indents/dedents occur)
1707    /// Phase 2: We correlate these with word token positions
1708    fn insert_indentation_tokens(&mut self, tokens: Vec<Token>) -> Vec<Token> {
1709        let mut result = Vec::new();
1710        let empty_sym = self.interner.intern("");
1711
1712        // Phase 1: Run LineLexer to determine structural positions
1713        let line_lexer = LineLexer::new(&self.source);
1714        let line_tokens: Vec<LineToken> = line_lexer.collect();
1715
1716        // Build a list of (byte_position, is_indent) for structural tokens
1717        // Position is where the NEXT Content starts after the Indent/Dedent
1718        let mut structural_events: Vec<(usize, bool)> = Vec::new(); // (byte_pos, true=Indent, false=Dedent)
1719        let mut pending_indents = 0usize;
1720        let mut pending_dedents = 0usize;
1721
1722        for line_token in &line_tokens {
1723            match line_token {
1724                LineToken::Indent => {
1725                    pending_indents += 1;
1726                }
1727                LineToken::Dedent => {
1728                    pending_dedents += 1;
1729                }
1730                LineToken::Content { start, .. } => {
1731                    // Emit pending dedents first (they come BEFORE the content)
1732                    for _ in 0..pending_dedents {
1733                        structural_events.push((*start, false)); // false = Dedent
1734                    }
1735                    pending_dedents = 0;
1736
1737                    // Emit pending indents (they also come BEFORE the content)
1738                    for _ in 0..pending_indents {
1739                        structural_events.push((*start, true)); // true = Indent
1740                    }
1741                    pending_indents = 0;
1742                }
1743                LineToken::Newline => {}
1744            }
1745        }
1746
1747        // Handle any remaining dedents at EOF
1748        for _ in 0..pending_dedents {
1749            structural_events.push((self.input_len, false));
1750        }
1751
1752        // Filter out structural events from within escape block bodies.
1753        // The LineLexer sees raw Rust code lines and generates spurious Indent/Dedent
1754        // events for their indentation changes. We keep exactly the boundary events
1755        // (Indent at body start, Dedent at body end) but remove internal ones.
1756        if !self.escape_body_ranges.is_empty() {
1757            // For each escape body range, find the first Indent at the body start and
1758            // track that we're inside the range. Filter out all events strictly inside
1759            // the range except for the first Indent and events at/after the end.
1760            let mut filtered = Vec::new();
1761            for &(pos, is_indent) in &structural_events {
1762                let is_inside_escape_body = self.escape_body_ranges.iter().any(|(start, end)| {
1763                    // Strictly inside the body (not at start boundary and not at/after end)
1764                    pos > *start && pos < *end
1765                });
1766                if !is_inside_escape_body {
1767                    filtered.push((pos, is_indent));
1768                }
1769            }
1770            structural_events = filtered;
1771        }
1772
1773        // Filter out structural events from within multi-line string literals.
1774        // Triple-quote strings span multiple lines; their internal indentation
1775        // must not generate Indent/Dedent tokens.
1776        {
1777            let string_spans: Vec<(usize, usize)> = tokens.iter()
1778                .filter(|t| matches!(t.kind, TokenType::StringLiteral(_) | TokenType::InterpolatedString(_)))
1779                .filter(|t| t.span.end.saturating_sub(t.span.start) > 6) // only multi-line strings (""" adds >=6 chars)
1780                .map(|t| (t.span.start, t.span.end))
1781                .collect();
1782            if !string_spans.is_empty() {
1783                structural_events.retain(|&(pos, _)| {
1784                    !string_spans.iter().any(|(start, end)| pos > *start && pos < *end)
1785                });
1786            }
1787        }
1788
1789        // Bracket line-continuation: a collection literal / call / parenthesised expression may span
1790        // lines with the continuation lines INDENTED (`[\n    1,\n    2,\n]`). The LineLexer sees that
1791        // indentation and emits Indent/Dedent, which would break element parsing. Drop every
1792        // structural event that falls strictly inside an unclosed `(`/`[`/`{` … `)`/`]`/`}` span. Both
1793        // the opening Indent and the matching Dedent lie inside the span, so they are dropped as a
1794        // balanced pair — the enclosing block level is preserved.
1795        {
1796            let mut bracket_ranges: Vec<(usize, usize)> = Vec::new();
1797            let mut open_stack: Vec<usize> = Vec::new();
1798            for t in &tokens {
1799                match t.kind {
1800                    TokenType::LParen | TokenType::LBracket | TokenType::LBrace => {
1801                        open_stack.push(t.span.start);
1802                    }
1803                    TokenType::RParen | TokenType::RBracket | TokenType::RBrace => {
1804                        if let Some(open) = open_stack.pop() {
1805                            bracket_ranges.push((open, t.span.end));
1806                        }
1807                    }
1808                    _ => {}
1809                }
1810            }
1811            if !bracket_ranges.is_empty() {
1812                structural_events.retain(|&(pos, _)| {
1813                    !bracket_ranges.iter().any(|(start, end)| pos > *start && pos < *end)
1814                });
1815            }
1816        }
1817
1818        // Sort events by position, with dedents before indents at same position
1819        structural_events.sort_by(|a, b| {
1820            if a.0 != b.0 {
1821                a.0.cmp(&b.0)
1822            } else {
1823                // Dedents (false) before Indents (true) at same position
1824                a.1.cmp(&b.1)
1825            }
1826        });
1827
1828        // Phase 2: Insert structural tokens at the right positions
1829        // Strategy: For each word token, check if any structural events should be inserted
1830        // before it (based on byte position)
1831
1832        let mut event_idx = 0;
1833        let mut last_colon_pos: Option<usize> = None;
1834
1835        for token in tokens.iter() {
1836            let token_start = token.span.start;
1837
1838            // Insert any structural tokens that should come BEFORE this token
1839            while event_idx < structural_events.len() {
1840                let (event_pos, is_indent) = structural_events[event_idx];
1841
1842                // Insert structural tokens before this token if the event position <= token start
1843                if event_pos <= token_start {
1844                    let span = if is_indent {
1845                        // Indent is inserted after the preceding Colon
1846                        Span::new(last_colon_pos.unwrap_or(event_pos), last_colon_pos.unwrap_or(event_pos))
1847                    } else {
1848                        Span::new(event_pos, event_pos)
1849                    };
1850                    let kind = if is_indent { TokenType::Indent } else { TokenType::Dedent };
1851                    result.push(Token::new(kind, empty_sym, span));
1852                    event_idx += 1;
1853                } else {
1854                    break;
1855                }
1856            }
1857
1858            result.push(token.clone());
1859
1860            // Track colon positions for Indent span calculation
1861            if token.kind == TokenType::Colon && self.is_end_of_line(token.span.end) {
1862                last_colon_pos = Some(token.span.end);
1863            }
1864        }
1865
1866        // Insert any remaining structural tokens (typically Dedents at EOF)
1867        while event_idx < structural_events.len() {
1868            let (event_pos, is_indent) = structural_events[event_idx];
1869            let span = Span::new(event_pos, event_pos);
1870            let kind = if is_indent { TokenType::Indent } else { TokenType::Dedent };
1871            result.push(Token::new(kind, empty_sym, span));
1872            event_idx += 1;
1873        }
1874
1875        // Ensure EOF is at the end
1876        let eof_pos = result.iter().position(|t| t.kind == TokenType::EOF);
1877        if let Some(pos) = eof_pos {
1878            let eof = result.remove(pos);
1879            result.push(eof);
1880        }
1881
1882        result
1883    }
1884
1885    /// Check if position is at end of line (only whitespace until newline)
1886    fn is_end_of_line(&self, from_pos: usize) -> bool {
1887        let bytes = self.source.as_bytes();
1888        let mut pos = from_pos;
1889        while pos < bytes.len() {
1890            match bytes[pos] {
1891                b' ' | b'\t' => pos += 1,
1892                b'\n' => return true,
1893                _ => return false,
1894            }
1895        }
1896        true // End of input is also end of line
1897    }
1898
1899    fn measure_next_line_indent(&self, from_pos: usize) -> Option<usize> {
1900        let bytes = self.source.as_bytes();
1901        let mut pos = from_pos;
1902
1903        while pos < bytes.len() && bytes[pos] != b'\n' {
1904            pos += 1;
1905        }
1906
1907        if pos >= bytes.len() {
1908            return None;
1909        }
1910
1911        pos += 1;
1912
1913        let mut indent = 0;
1914        while pos < bytes.len() {
1915            match bytes[pos] {
1916                b' ' => indent += 1,
1917                b'\t' => indent += 4,
1918                b'\n' => {
1919                    indent = 0;
1920                }
1921                _ => break,
1922            }
1923            pos += 1;
1924        }
1925
1926        if pos >= bytes.len() {
1927            return None;
1928        }
1929
1930        Some(indent)
1931    }
1932
1933    fn word_to_number(word: &str) -> Option<u32> {
1934        lexicon::word_to_number(&word.to_lowercase())
1935    }
1936
1937    /// Check if a hyphen at the current position is part of an ISO-8601 date.
1938    ///
1939    /// Detects patterns like:
1940    /// - "2026-" followed by "05-20" → first hyphen of date
1941    /// - "2026-05-" followed by "20" → second hyphen of date
1942    fn is_date_hyphen(current_word: &str, chars: &[char], char_idx: usize) -> bool {
1943        // Current word must be all digits (year or year-month)
1944        let word_chars: Vec<char> = current_word.chars().collect();
1945
1946        // Check for first hyphen pattern: YYYY- followed by MM-DD
1947        if word_chars.len() == 4 && word_chars.iter().all(|c| c.is_ascii_digit()) {
1948            // Check if followed by exactly 2 digits, hyphen, 2 digits
1949            if char_idx + 5 < chars.len()
1950                && chars[char_idx + 1].is_ascii_digit()
1951                && chars[char_idx + 2].is_ascii_digit()
1952                && chars[char_idx + 3] == '-'
1953                && chars[char_idx + 4].is_ascii_digit()
1954                && chars[char_idx + 5].is_ascii_digit()
1955            {
1956                return true;
1957            }
1958        }
1959
1960        // Check for second hyphen pattern: YYYY-MM- followed by DD
1961        if word_chars.len() == 7
1962            && word_chars[0..4].iter().all(|c| c.is_ascii_digit())
1963            && word_chars[4] == '-'
1964            && word_chars[5..7].iter().all(|c| c.is_ascii_digit())
1965        {
1966            // Check if followed by exactly 2 digits
1967            if char_idx + 2 < chars.len()
1968                && chars[char_idx + 1].is_ascii_digit()
1969                && chars[char_idx + 2].is_ascii_digit()
1970            {
1971                // Make sure we're not followed by more digits (would be a longer number)
1972                let next_not_digit = char_idx + 3 >= chars.len()
1973                    || !chars[char_idx + 3].is_ascii_digit();
1974                if next_not_digit {
1975                    return true;
1976                }
1977            }
1978        }
1979
1980        false
1981    }
1982
1983    /// Check if a colon is part of a time literal (e.g., 9:30am, 11:45pm).
1984    ///
1985    /// Detects patterns like:
1986    /// - "9:" followed by "30am" or "30pm"
1987    /// - "11:" followed by "45pm"
1988    fn is_time_colon(current_word: &str, chars: &[char], char_idx: usize) -> bool {
1989        // Current word must be 1-2 digits (hour)
1990        let word_chars: Vec<char> = current_word.chars().collect();
1991        if word_chars.is_empty() || word_chars.len() > 2 {
1992            return false;
1993        }
1994        if !word_chars.iter().all(|c| c.is_ascii_digit()) {
1995            return false;
1996        }
1997
1998        // Followed by exactly 2 digits (the minutes), then "am"/"pm" — which may
1999        // come immediately ("9:30am") or after a space ("8:15 pm").
2000        if char_idx + 3 < chars.len()
2001            && chars[char_idx + 1].is_ascii_digit()
2002            && chars[char_idx + 2].is_ascii_digit()
2003        {
2004            let suffix_start = if chars.get(char_idx + 3) == Some(&' ') {
2005                char_idx + 4
2006            } else {
2007                char_idx + 3
2008            };
2009            if suffix_start + 1 < chars.len() {
2010                let next_two: String = chars[suffix_start..suffix_start + 2].iter().collect();
2011                let lower = next_two.to_lowercase();
2012                if (lower == "am" || lower == "pm")
2013                    && (suffix_start + 2 >= chars.len()
2014                        || !chars[suffix_start + 2].is_alphabetic())
2015                {
2016                    return true;
2017                }
2018            }
2019        }
2020
2021        false
2022    }
2023
2024    /// Whether a space separates a `H:MM` clock time from a trailing "am"/"pm"
2025    /// ("8:15 pm") — the space is part of the time literal, so the tokenizer keeps
2026    /// "am"/"pm" attached to the time rather than splitting on it.
2027    fn is_time_space_before_ampm(current_word: &str, chars: &[char], char_idx: usize) -> bool {
2028        let valid_time = match current_word.find(':') {
2029            Some(p) => {
2030                let hour = &current_word[..p];
2031                let min = &current_word[p + 1..];
2032                (1..=2).contains(&hour.len())
2033                    && hour.chars().all(|c| c.is_ascii_digit())
2034                    && min.len() == 2
2035                    && min.chars().all(|c| c.is_ascii_digit())
2036            }
2037            None => false,
2038        };
2039        if !valid_time {
2040            return false;
2041        }
2042        let next_two: String = chars
2043            .get(char_idx + 1..char_idx + 3)
2044            .map(|s| s.iter().collect())
2045            .unwrap_or_default();
2046        let lower = next_two.to_lowercase();
2047        (lower == "am" || lower == "pm")
2048            && chars.get(char_idx + 3).map_or(true, |c| !c.is_alphabetic())
2049    }
2050
2051    /// Check if a string contains an unescaped `{` (i.e., not part of `{{`).
2052    /// Used to distinguish `InterpolatedString` from `StringLiteral`.
2053    fn has_unescaped_brace(content: &str) -> bool {
2054        let bytes = content.as_bytes();
2055        let mut i = 0;
2056        while i < bytes.len() {
2057            if bytes[i] == b'{' {
2058                if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
2059                    i += 2;
2060                } else {
2061                    return true;
2062                }
2063            } else {
2064                i += 1;
2065            }
2066        }
2067        false
2068    }
2069
2070    /// Check if a `+` or `-` at the current position is the sign of a scientific notation exponent.
2071    ///
2072    /// Detects patterns like:
2073    /// - "4.84e+" followed by "00" → exponent sign in `4.84e+00`
2074    /// - "2.5e-" followed by "2"  → exponent sign in `2.5e-2`
2075    fn is_exponent_sign(current_word: &str, chars: &[char], char_idx: usize) -> bool {
2076        // Word must end with e/E
2077        if !current_word.ends_with('e') && !current_word.ends_with('E') {
2078            return false;
2079        }
2080        // Before e/E must contain a digit (ensures it's a number, not a bare "e")
2081        let before_e = &current_word[..current_word.len() - 1];
2082        if before_e.is_empty() || !before_e.chars().next().unwrap().is_ascii_digit() {
2083            return false;
2084        }
2085        // Next char must be a digit (the exponent value)
2086        char_idx + 1 < chars.len() && chars[char_idx + 1].is_ascii_digit()
2087    }
2088
2089    /// Dedent a triple-quoted string: strip the common leading whitespace from each line.
2090    /// Joins lines with literal newline characters (not escape sequences).
2091    fn dedent_triple_quote(raw: &str) -> String {
2092        let lines: Vec<&str> = raw.lines().collect();
2093        if lines.is_empty() {
2094            return String::new();
2095        }
2096        // Find minimum indentation of non-empty lines
2097        let min_indent = lines.iter()
2098            .filter(|l| !l.trim().is_empty())
2099            .map(|l| l.len() - l.trim_start().len())
2100            .min()
2101            .unwrap_or(0);
2102        // Strip that indentation and join with actual newlines
2103        lines.iter()
2104            .map(|l| {
2105                if l.len() >= min_indent {
2106                    &l[min_indent..]
2107                } else {
2108                    l.trim()
2109                }
2110            })
2111            .collect::<Vec<_>>()
2112            .join("\n")
2113    }
2114
2115    fn is_numeric_literal(word: &str) -> bool {
2116        if word.is_empty() {
2117            return false;
2118        }
2119        let chars: Vec<char> = word.chars().collect();
2120        let first = chars[0];
2121        if first.is_ascii_digit() {
2122            // Numeric literal: starts with digit (may have underscore separators like 1_000)
2123            return true;
2124        }
2125        // Symbolic numbers: only recognize known mathematical symbols
2126        // (aleph, omega, beth) followed by underscore and digits
2127        if let Some(underscore_pos) = word.rfind('_') {
2128            let before_underscore = &word[..underscore_pos];
2129            let after_underscore = &word[underscore_pos + 1..];
2130            // Must be a known mathematical symbol prefix AND digits after underscore
2131            let is_math_symbol = matches!(
2132                before_underscore.to_lowercase().as_str(),
2133                "aleph" | "omega" | "beth"
2134            );
2135            if is_math_symbol
2136                && !after_underscore.is_empty()
2137                && after_underscore.chars().all(|c| c.is_ascii_digit())
2138            {
2139                return true;
2140            }
2141        }
2142        false
2143    }
2144
2145    /// Parse a duration literal with SI suffix.
2146    ///
2147    /// Returns Some((nanoseconds, unit_str)) if the word is a valid duration literal,
2148    /// None otherwise.
2149    ///
2150    /// Supported suffixes:
2151    /// - ns: nanoseconds
2152    /// - us, μs: microseconds
2153    /// - ms: milliseconds
2154    /// - s, sec: seconds
2155    /// - min: minutes
2156    /// - h, hr: hours
2157    fn parse_duration_literal(word: &str) -> Option<(i64, &str)> {
2158        if word.is_empty() || !word.chars().next()?.is_ascii_digit() {
2159            return None;
2160        }
2161
2162        // SI suffix table with multipliers to nanoseconds
2163        const SUFFIXES: &[(&str, i64)] = &[
2164            ("ns", 1),
2165            ("μs", 1_000),
2166            ("us", 1_000),
2167            ("ms", 1_000_000),
2168            ("sec", 1_000_000_000),
2169            ("s", 1_000_000_000),
2170            ("min", 60_000_000_000),
2171            ("hr", 3_600_000_000_000),
2172            ("h", 3_600_000_000_000),
2173        ];
2174
2175        // Try each suffix (longer suffixes first to avoid partial matches)
2176        for (suffix, multiplier) in SUFFIXES {
2177            if word.ends_with(suffix) {
2178                let num_part = &word[..word.len() - suffix.len()];
2179                // Parse the numeric part (may have underscore separators)
2180                let cleaned: String = num_part.chars().filter(|c| *c != '_').collect();
2181                if let Ok(n) = cleaned.parse::<i64>() {
2182                    return Some((n.saturating_mul(*multiplier), *suffix));
2183                }
2184            }
2185        }
2186
2187        None
2188    }
2189
2190    /// Parse an ISO-8601 date literal (YYYY-MM-DD).
2191    ///
2192    /// Returns Some(days_since_epoch) if the word is a valid date literal,
2193    /// None otherwise.
2194    fn parse_date_literal(word: &str) -> Option<i32> {
2195        // Must match pattern: YYYY-MM-DD
2196        if word.len() != 10 {
2197            return None;
2198        }
2199
2200        let bytes = word.as_bytes();
2201
2202        // Check format: 4 digits, hyphen, 2 digits, hyphen, 2 digits
2203        if bytes[4] != b'-' || bytes[7] != b'-' {
2204            return None;
2205        }
2206
2207        // Parse year, month, day
2208        let year: i32 = word[0..4].parse().ok()?;
2209        let month: u32 = word[5..7].parse().ok()?;
2210        let day: u32 = word[8..10].parse().ok()?;
2211
2212        // Reject calendar-impossible dates (e.g. 2026-02-30, 2026-04-31): the
2213        // Howard Hinnant day count below would otherwise silently map them onto
2214        // a real but DIFFERENT day.
2215        if month < 1 || month > 12 || day < 1 {
2216            return None;
2217        }
2218        let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
2219        let max_day = match month {
2220            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2221            4 | 6 | 9 | 11 => 30,
2222            2 => if is_leap { 29 } else { 28 },
2223            _ => return None,
2224        };
2225        if day > max_day {
2226            return None;
2227        }
2228
2229        // Convert to days since Unix epoch using Howard Hinnant's algorithm
2230        // https://howardhinnant.github.io/date_algorithms.html
2231        let y = if month <= 2 { year - 1 } else { year };
2232        let era = if y >= 0 { y / 400 } else { (y - 399) / 400 };
2233        let yoe = (y - era * 400) as u32;
2234        let m = month;
2235        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day - 1;
2236        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2237        let days = era * 146097 + doe as i32 - 719468;
2238
2239        Some(days)
2240    }
2241
2242    /// Parse a time-of-day literal.
2243    ///
2244    /// Supported formats:
2245    /// - 12-hour with am/pm: "4pm", "9am", "12pm"
2246    /// - 12-hour with minutes: "9:30am", "11:45pm"
2247    /// - Special words: "noon" (12:00), "midnight" (00:00)
2248    ///
2249    /// Returns Some(nanos_from_midnight) if valid, None otherwise.
2250    fn parse_time_literal(word: &str) -> Option<i64> {
2251        let lower = word.to_lowercase();
2252
2253        // Handle special time words
2254        if lower == "noon" {
2255            return Some(12i64 * 3600 * 1_000_000_000);
2256        }
2257        if lower == "midnight" {
2258            return Some(0);
2259        }
2260
2261        // Handle 12-hour formats: "4pm", "9am", "9:30am", "11:45pm"
2262        let is_pm = lower.ends_with("pm");
2263        let is_am = lower.ends_with("am");
2264
2265        if !is_pm && !is_am {
2266            return None;
2267        }
2268
2269        // Strip the am/pm suffix
2270        let time_part = &lower[..lower.len() - 2];
2271
2272        // Check for hour:minute format
2273        let (hour, minute): (i64, i64) = if let Some(colon_idx) = time_part.find(':') {
2274            let hour_str = &time_part[..colon_idx];
2275            let min_str = &time_part[colon_idx + 1..];
2276            let h: i64 = hour_str.parse().ok()?;
2277            let m: i64 = min_str.parse().ok()?;
2278            (h, m)
2279        } else {
2280            // Just hour: "4pm", "9am"
2281            let h: i64 = time_part.parse().ok()?;
2282            (h, 0)
2283        };
2284
2285        // Validate ranges
2286        if hour < 1 || hour > 12 || minute < 0 || minute > 59 {
2287            return None;
2288        }
2289
2290        // Convert to 24-hour format
2291        let hour_24 = if is_am {
2292            if hour == 12 { 0 } else { hour }  // 12am = midnight = 0
2293        } else {
2294            if hour == 12 { 12 } else { hour + 12 }  // 12pm = noon = 12, 4pm = 16
2295        };
2296
2297        // Convert to nanoseconds from midnight
2298        let nanos = (hour_24 * 3600 + minute * 60) * 1_000_000_000;
2299        Some(nanos)
2300    }
2301
2302    /// True for the currency symbols that prefix a money literal (`$ € £ ¥`).
2303    fn is_currency_symbol(c: char) -> bool {
2304        matches!(c, '$' | '€' | '£' | '¥')
2305    }
2306
2307    /// The ISO-4217 code a leading currency symbol denotes — `$`→USD, `€`→EUR, `£`→GBP, `¥`→JPY (the
2308    /// dominant reading of each symbol). Unknown symbols yield `None`.
2309    fn currency_for_symbol(c: char) -> Option<&'static str> {
2310        Some(match c {
2311            '$' => "USD",
2312            '€' => "EUR",
2313            '£' => "GBP",
2314            '¥' => "JPY",
2315            _ => return None,
2316        })
2317    }
2318
2319    fn classify_with_lookahead(&mut self, word: &str) -> TokenType {
2320        // The `&` character, deferred by the word-splitter: prose keeps the
2321        // coordination reading (`black & red` → and); imperative code gets
2322        // the bitwise operator.
2323        if word == "\x00AMP" {
2324            return if matches!(self.mode, LexerMode::Imperative) {
2325                TokenType::Amp
2326            } else {
2327                TokenType::And
2328            };
2329        }
2330        if word == "\x00DOT" {
2331            // Imperative: the field-access / UFCS operator. Declarative/prose:
2332            // a plain sentence period (so `e.g.` and abbreviations read as today).
2333            return if matches!(self.mode, LexerMode::Imperative) {
2334                TokenType::Dot
2335            } else {
2336                TokenType::Period
2337            };
2338        }
2339        // Handle block headers (##Theorem, ##Main, etc.)
2340        if word.starts_with("##") {
2341            let block_name = &word[2..];
2342            let block_type = match block_name.to_lowercase().as_str() {
2343                "theorem" => BlockType::Theorem,
2344                "main" => BlockType::Main,
2345                "definition" => BlockType::Definition,
2346                "define" => BlockType::Define,  // Vernacular-logic predicate definition (Rung 0a)
2347                "axiom" => BlockType::Axiom,    // Formal first-order axiom (the seam for Tarski)
2348                "theory" => BlockType::Theory,  // Named development grouping axioms + theorems
2349                "proof" => BlockType::Proof,
2350                "example" => BlockType::Example,
2351                "logic" => BlockType::Logic,
2352                "note" => BlockType::Note,
2353                "to" => BlockType::Function,  // Function definition block
2354                "a" | "an" => BlockType::TypeDef,  // Inline type definitions: ## A Point has:
2355                "policy" => BlockType::Policy,  // Security policy definitions
2356                "requires" => BlockType::Requires,  // External crate dependencies
2357                "hardware" => BlockType::Hardware,  // Signal declarations
2358                "property" => BlockType::Property,  // Temporal assertions
2359                "no" => BlockType::No,  // Optimization annotation: ## No Memo, ## No TCO, etc.
2360                "tier" => BlockType::Tier,  // Tiered-optimizer pin: ## Tier specialize eager, etc.
2361                other => {
2362                    // A near-miss of a CONSEQUENTIAL header is a probable
2363                    // typo — `## Mian` silently becoming prose is the bug
2364                    // class where a whole program runs to empty output.
2365                    // Prose-type names (note/example/logic) and the short
2366                    // forms are excluded, so ordinary literate headings
2367                    // (`## Notes`, `## Design`) keep working.
2368                    const CONSEQUENTIAL: &[&str] = &[
2369                        "main", "theorem", "definition", "define", "axiom",
2370                        "theory", "proof", "policy", "requires", "hardware",
2371                        "property", "tier",
2372                    ];
2373                    if let Some(similar) =
2374                        crate::suggest::find_similar(other, CONSEQUENTIAL, 2)
2375                    {
2376                        let found = self.interner.intern(block_name);
2377                        let suggestion = self.interner.intern(similar);
2378                        BlockType::SuspectedTypo { found, suggestion }
2379                    } else {
2380                        BlockType::Note // Unknown block types stay literate prose
2381                    }
2382                }
2383            };
2384
2385            // Update lexer mode based on block type
2386            self.mode = match block_type {
2387                BlockType::Main | BlockType::Function => LexerMode::Imperative,
2388                _ => LexerMode::Declarative,
2389            };
2390
2391
2392            return TokenType::BlockHeader { block_type };
2393        }
2394
2395        let lower = word.to_lowercase();
2396
2397        if lower == "each" && self.peek_sequence(&["other"]) {
2398            self.consume_words(1);
2399            return TokenType::Reciprocal;
2400        }
2401
2402        if lower == "to" {
2403            if let Some(next) = self.peek_word(1) {
2404                if self.is_verb_like(next) {
2405                    return TokenType::To;
2406                }
2407            }
2408            let sym = self.interner.intern("to");
2409            return TokenType::Preposition(sym);
2410        }
2411
2412        if lower == "at" {
2413            if let Some(next) = self.peek_word(1) {
2414                let next_lower = next.to_lowercase();
2415                if next_lower == "least" {
2416                    if let Some(num_word) = self.peek_word(2) {
2417                        if let Some(n) = Self::word_to_number(num_word) {
2418                            self.consume_words(2);
2419                            return TokenType::AtLeast(n);
2420                        }
2421                    }
2422                }
2423                if next_lower == "most" {
2424                    if let Some(num_word) = self.peek_word(2) {
2425                        if let Some(n) = Self::word_to_number(num_word) {
2426                            self.consume_words(2);
2427                            return TokenType::AtMost(n);
2428                        }
2429                    }
2430                }
2431            }
2432        }
2433
2434        // "Exactly N" → Cardinal(N) — same as bare number but explicit
2435        if lower == "exactly" {
2436            if let Some(num_word) = self.peek_word(1) {
2437                if let Some(n) = Self::word_to_number(num_word) {
2438                    self.consume_words(1);
2439                    return TokenType::Cardinal(n);
2440                }
2441            }
2442        }
2443
2444        if let Some(n) = Self::word_to_number(&lower) {
2445            return TokenType::Cardinal(n);
2446        }
2447
2448        // Check for duration literal first (e.g., "500ms", "2s", "50ns")
2449        if let Some((nanos, unit)) = Self::parse_duration_literal(word) {
2450            let unit_sym = self.interner.intern(unit);
2451            return TokenType::DurationLiteral {
2452                nanos,
2453                original_unit: unit_sym,
2454            };
2455        }
2456
2457        // Check for ISO-8601 date literal (e.g., "2026-05-20")
2458        if let Some(days) = Self::parse_date_literal(word) {
2459            return TokenType::DateLiteral { days };
2460        }
2461
2462        // Check for time-of-day literal (e.g., "4pm", "9:30am", "noon", "midnight")
2463        if let Some(nanos_from_midnight) = Self::parse_time_literal(word) {
2464            return TokenType::TimeLiteral { nanos_from_midnight };
2465        }
2466
2467        // Currency-symbol money literal: `$19.99`, `€5`, `£10`, `¥100`, `$1,250.50`. The symbol
2468        // resolves to its ISO-4217 code; the magnitude keeps its digits and decimal point (thousands
2469        // separators stripped). A `MoneyLiteral` carries both, so the parser builds an exact
2470        // currency-tagged value rather than dropping the symbol. ONLY in imperative code — in
2471        // natural-language clauses `$25,000` is a bare magnitude (a logic-grid constraint), handled
2472        // by the magnitude-stripping block below; the symbol there is just orthography.
2473        if self.mode == LexerMode::Imperative {
2474            if let Some(first) = word.chars().next() {
2475                if let Some(code) = Self::currency_for_symbol(first) {
2476                    let cleaned: String =
2477                        word.chars().skip(1).filter(|c| c.is_ascii_digit() || *c == '.').collect();
2478                    if cleaned.starts_with(|c: char| c.is_ascii_digit()) {
2479                        let amount = self.interner.intern(&cleaned);
2480                        let currency = self.interner.intern(code);
2481                        return TokenType::MoneyLiteral { amount, currency };
2482                    }
2483                }
2484            }
2485        }
2486
2487        // Currency / comma-grouped numerals: "$125,000", "€2.60", "1,234". Strip the currency
2488        // marker and thousands separators; in a natural-language clause the puzzle constraint is the
2489        // bare magnitude (125000, 2.60). (Imperative code took the `MoneyLiteral` path above.)
2490        if word.starts_with(|c: char| Self::is_currency_symbol(c))
2491            || (word.starts_with(|c: char| c.is_ascii_digit()) && word.contains(','))
2492        {
2493            let cleaned: String = word
2494                .chars()
2495                .filter(|c| c.is_ascii_digit() || *c == '.')
2496                .collect();
2497            if cleaned.starts_with(|c: char| c.is_ascii_digit()) {
2498                let sym = self.interner.intern(&cleaned);
2499                return TokenType::Number(sym);
2500            }
2501        }
2502
2503        if Self::is_numeric_literal(word) {
2504            let sym = self.interner.intern(word);
2505            return TokenType::Number(sym);
2506        }
2507
2508        if lower == "if" && self.peek_sequence(&["and", "only", "if"]) {
2509            self.consume_words(3);
2510            return TokenType::Iff;
2511        }
2512
2513        if lower == "is" {
2514            if self.peek_sequence(&["equal", "to"]) {
2515                self.consume_words(2);
2516                return TokenType::Identity;
2517            }
2518            if self.peek_sequence(&["identical", "to"]) {
2519                self.consume_words(2);
2520                return TokenType::Identity;
2521            }
2522        }
2523
2524        if (lower == "a" || lower == "an") && word.chars().next().unwrap().is_uppercase() {
2525            // Capitalized "A" or "An" - disambiguate article vs proper name
2526            // Heuristic: articles are followed by nouns/adjectives, not verbs or keywords
2527            if let Some(next) = self.peek_word(1) {
2528                let next_lower = next.to_lowercase();
2529                let next_starts_lowercase = next.chars().next().map(|c| c.is_lowercase()).unwrap_or(false);
2530
2531                // If followed by logical keyword, treat as proper name (propositional variable)
2532                if matches!(next_lower.as_str(), "if" | "and" | "or" | "implies" | "iff") {
2533                    let sym = self.interner.intern(word);
2534                    return TokenType::ProperName(sym);
2535                }
2536
2537                // If next word is ONLY a verb (like "has", "is", "ran"), A is likely a name
2538                // Exception: gerunds (like "running") can follow articles
2539                // Exception: words in disambiguation_not_verbs (like "red") are not verbs
2540                // Exception: words that are also nouns/adjectives (like "fire") can follow articles
2541                let is_verb = self.lexicon.lookup_verb(&next_lower).is_some()
2542                    && !lexicon::is_disambiguation_not_verb(&next_lower);
2543                let is_gerund = next_lower.ends_with("ing");
2544                let is_also_noun_or_adj = self.is_noun_like(&next_lower) || self.is_adjective_like(&next_lower);
2545                if is_verb && !is_gerund && !is_also_noun_or_adj {
2546                    let sym = self.interner.intern(word);
2547                    return TokenType::ProperName(sym);
2548                }
2549
2550                // Definition pattern: "A [TypeName] is a..." or "A [TypeName] has:" - treat A as article
2551                // even when TypeName is capitalized and unknown
2552                if let Some(third) = self.peek_word(2) {
2553                    let third_lower = third.to_lowercase();
2554                    // "has" for struct definitions: "A Point has:"
2555                    if third_lower == "is" || third_lower == "are" || third_lower == "has" {
2556                        return TokenType::Article(Definiteness::Indefinite);
2557                    }
2558                }
2559
2560                // It's an article if next word is:
2561                // - A known noun or adjective, or
2562                // - Lowercase (likely a common word we don't recognize)
2563                let is_content_word = self.is_noun_like(&next_lower) || self.is_adjective_like(&next_lower);
2564                if is_content_word || next_starts_lowercase {
2565                    return TokenType::Article(Definiteness::Indefinite);
2566                }
2567            }
2568            let sym = self.interner.intern(word);
2569            return TokenType::ProperName(sym);
2570        }
2571
2572        self.classify_word(word)
2573    }
2574
2575    fn is_noun_like(&self, word: &str) -> bool {
2576        if lexicon::is_noun_pattern(word) || lexicon::is_common_noun(word) {
2577            return true;
2578        }
2579        if word.ends_with("er") || word.ends_with("ian") || word.ends_with("ist") {
2580            return true;
2581        }
2582        false
2583    }
2584
2585    fn is_adjective_like(&self, word: &str) -> bool {
2586        lexicon::is_adjective(word) || lexicon::is_non_intersective(word)
2587    }
2588
2589    fn classify_word(&mut self, word: &str) -> TokenType {
2590        let lower = word.to_lowercase();
2591        let first_char = word.chars().next().unwrap();
2592
2593        // Disambiguate "that" as determiner vs complementizer/relativizer.
2594        // "that dog" → Article(Distal); "I know that he ran" → That.
2595        // After a nominal antecedent ("the vessel that saw …"), "that" heads a
2596        // relative clause even when the clause verb is also noun-like ("saw"),
2597        // so it must NOT collapse to a demonstrative there.
2598        if lower == "that" {
2599            if let Some(next) = self.peek_word(1) {
2600                let next_lower = next.to_lowercase();
2601                // A verb-capable next word ("that saw …", "that played …") makes
2602                // "that" a relativizer/complementizer even when that word is also
2603                // noun-like — the demonstrative reading needs a pure-noun head.
2604                let next_is_verb = self.lexicon.lookup_verb(&next_lower).is_some();
2605                if !next_is_verb
2606                    && (self.is_noun_like(&next_lower) || self.is_adjective_like(&next_lower))
2607                {
2608                    return TokenType::Article(Definiteness::Distal);
2609                }
2610            }
2611        }
2612
2613        // Arrow token for return type syntax
2614        if word == "->" {
2615            return TokenType::Arrow;
2616        }
2617
2618        // Grand Challenge: Comparison operator tokens
2619        if word == "<=" {
2620            return TokenType::LtEq;
2621        }
2622        if word == ">=" {
2623            return TokenType::GtEq;
2624        }
2625        match word {
2626            "+=" => return TokenType::PlusEq,
2627            "-=" => return TokenType::MinusEq,
2628            "*=" => return TokenType::StarEq,
2629            "/=" => return TokenType::SlashEq,
2630            "%=" => return TokenType::PercentEq,
2631            "**" => return TokenType::StarStar,
2632            "//" => return TokenType::SlashSlash,
2633            _ => {}
2634        }
2635        if word == "==" {
2636            return TokenType::EqEq;
2637        }
2638        if word == "!=" {
2639            return TokenType::NotEq;
2640        }
2641        if word == "<" {
2642            return TokenType::Lt;
2643        }
2644        if word == ">" {
2645            return TokenType::Gt;
2646        }
2647        // Single = for assignment (must come after == check)
2648        if word == "=" {
2649            return TokenType::Assign;
2650        }
2651
2652        if let Some(kind) = lexicon::lookup_keyword(&lower) {
2653            // A capitalized modal/auxiliary MID-sentence is a proper name, not the
2654            // function word ("the startup started by Will Waters", "the May deadline").
2655            // Modals/auxiliaries never capitalize mid-clause, so the keyword reading is
2656            // spurious there; sentence-initial stays the keyword (a question "Will Alice
2657            // win?"). This is the same name/keyword collision resolved for item/native.
2658            let is_modal = matches!(
2659                kind,
2660                TokenType::Must
2661                    | TokenType::Shall
2662                    | TokenType::Should
2663                    | TokenType::Can
2664                    | TokenType::May
2665                    | TokenType::Cannot
2666                    | TokenType::Would
2667                    | TokenType::Could
2668                    | TokenType::Might
2669            );
2670            if is_modal && first_char.is_uppercase() && !self.prev_word_ends_sentence() {
2671                return TokenType::ProperName(self.interner.intern(word));
2672            }
2673            // In Declarative (NL) mode, "from" and "for" are prepositions, not Logos keywords.
2674            // They are listed in the prepositions section of the lexicon and will be
2675            // correctly re-classified by the is_preposition() check below if we skip here.
2676            let kind = match (kind, self.mode) {
2677                (TokenType::From, LexerMode::Declarative) => {
2678                    // A QUALIFIED IMPORT "Type from Module" — both neighbours are
2679                    // capitalised identifiers — keeps the `From` keyword even in
2680                    // declarative text (there are zero "Capitalised from
2681                    // Capitalised" sequences in NL clues). Otherwise "from" is an
2682                    // ordinary NL preposition ("the species FROM Australia").
2683                    let prev_cap = self.pos > 0
2684                        && self
2685                            .words
2686                            .get(self.pos - 1)
2687                            .and_then(|w| w.word.chars().next())
2688                            .map_or(false, |c| c.is_uppercase());
2689                    let next_cap = self
2690                        .words
2691                        .get(self.pos + 1)
2692                        .and_then(|w| w.word.chars().next())
2693                        .map_or(false, |c| c.is_uppercase());
2694                    if prev_cap && next_cap {
2695                        TokenType::From
2696                    } else {
2697                        let sym = self.interner.intern("from");
2698                        TokenType::Preposition(sym)
2699                    }
2700                }
2701                (TokenType::For, LexerMode::Declarative) => {
2702                    let sym = self.interner.intern("for");
2703                    TokenType::Preposition(sym)
2704                }
2705                (other, _) => other,
2706            };
2707            return kind;
2708        }
2709
2710        if let Some(kind) = lexicon::lookup_pronoun(&lower) {
2711            return kind;
2712        }
2713
2714        if let Some(def) = lexicon::lookup_article(&lower) {
2715            return TokenType::Article(def);
2716        }
2717
2718        if let Some(time) = lexicon::lookup_auxiliary(&lower) {
2719            // A capitalized auxiliary MID-sentence is a proper name ("started by Will
2720            // Waters"), not the function word — auxiliaries never capitalize mid-clause.
2721            if first_char.is_uppercase() && !self.prev_word_ends_sentence() {
2722                return TokenType::ProperName(self.interner.intern(word));
2723            }
2724            return TokenType::Auxiliary(time);
2725        }
2726
2727        // Handle imperative keywords that might conflict with prepositions
2728        match lower.as_str() {
2729            "call" => return TokenType::Call,
2730            "in" if self.mode == LexerMode::Imperative => return TokenType::In,
2731            // Zone keywords (must come before is_preposition check)
2732            "inside" if self.mode == LexerMode::Imperative => return TokenType::Inside,
2733            // "at" for chunk access (must come before is_preposition check)
2734            "at" if self.mode == LexerMode::Imperative => return TokenType::At,
2735            // "into" for pipe send (must come before is_preposition check)
2736            "into" if self.mode == LexerMode::Imperative => return TokenType::Into,
2737            // Temporal span operator (must come before is_preposition check)
2738            "before" => return TokenType::Before,
2739            _ => {}
2740        }
2741
2742        // "per" is the rate preposition ("$2.50 per pound", "10 miles per
2743        // hour"). It is not in the general preposition lexicon, and must not be
2744        // mistaken for a measure unit or an unknown noun.
2745        if lower == "per" {
2746            let sym = self.interner.intern("per");
2747            return TokenType::Preposition(sym);
2748        }
2749
2750        // A word that is BOTH a verb and a preposition ("like") stays
2751        // ambiguous — the dual-class block below emits the alternatives.
2752        // Lexicon-disambiguated NON-verbs ("during") are pure prepositions
2753        // even when morphology over-derives a verb reading.
2754        if lexicon::is_preposition(&lower)
2755            && (self.lexicon.lookup_verb(&lower).is_none()
2756                || lexicon::is_disambiguation_not_verb(&lower))
2757        {
2758            let sym = self.interner.intern(&lower);
2759            return TokenType::Preposition(sym);
2760        }
2761
2762        match lower.as_str() {
2763            "equals" => return TokenType::Equals,
2764            // "item"/"items" is the indexing keyword in imperative code (where a
2765            // variable index like "item i of arr" is the whole point and prose nouns
2766            // never appear), and in declarative text ONLY when an index number follows
2767            // ("item 1 of list", "items 2 through 5"). Otherwise, in declarative text,
2768            // it is the ordinary English noun ("the blue item", "the item made of
2769            // gold"), which leaked as the keyword before and stranded the noun after an
2770            // adjective. Mode separates code from prose; the following number is the
2771            // finer signal within prose.
2772            "item" | "items"
2773                if self.mode == LexerMode::Imperative
2774                    || self.peek_word(1).map_or(false, |w| {
2775                        w.chars().next().map_or(false, |c| c.is_ascii_digit())
2776                            || crate::lexicon::word_to_number(&w.to_lowercase()).is_some()
2777                    }) =>
2778            {
2779                return if lower == "item" {
2780                    TokenType::Item
2781                } else {
2782                    TokenType::Items
2783                };
2784            }
2785            // Mutability keyword for `mut x = 5` syntax
2786            "mut" if self.mode == LexerMode::Imperative => return TokenType::Mut,
2787            "let" => {
2788                self.in_let_context = true;
2789                return TokenType::Let;
2790            }
2791            "set" => {
2792                // Check if "set" is used as a type (followed by "of") - "Set of Int"
2793                // This takes priority over the assignment keyword
2794                if self.peek_word(1).map_or(false, |w| w.to_lowercase() == "of") {
2795                    // It's a type like "Set of Int" - don't return keyword, let it be a noun
2796                } else if self.mode == LexerMode::Imperative {
2797                    // In Imperative mode, treat "set" as the assignment keyword
2798                    return TokenType::Set;
2799                } else {
2800                    // In Declarative mode, check positions 2-5 for "to"
2801                    // (handles field access like "set p's x to")
2802                    for offset in 2..=5 {
2803                        if self.peek_word(offset).map_or(false, |w| w.to_lowercase() == "to") {
2804                            return TokenType::Set;
2805                        }
2806                    }
2807                }
2808            }
2809            "return" => return TokenType::Return,
2810            "break" => return TokenType::Break,
2811            "xor" => return TokenType::Xor,
2812            "shifted" => return TokenType::Shifted,
2813            "be" if self.in_let_context => {
2814                self.in_let_context = false;
2815                return TokenType::Be;
2816            }
2817            "while" => return TokenType::While,
2818            "assert" => return TokenType::Assert,
2819            "trust" => return TokenType::Trust,
2820            // Imperative-only: these are common English words ("the proof requires
2821            // …", "this ensures …"), so keep them as plain words in declarative mode.
2822            "require" if self.mode == LexerMode::Imperative => return TokenType::Require,
2823            "requires" if self.mode == LexerMode::Imperative => return TokenType::Requires,
2824            "ensures" if self.mode == LexerMode::Imperative => return TokenType::Ensures,
2825            "check" => return TokenType::Check,
2826            // Theorem keywords (Declarative mode - for theorem blocks)
2827            "given" if self.mode == LexerMode::Declarative => return TokenType::Given,
2828            "prove" if self.mode == LexerMode::Declarative => return TokenType::Prove,
2829            "auto" if self.mode == LexerMode::Declarative => return TokenType::Auto,
2830            // P2P Networking keywords (Imperative mode only)
2831            "listen" if self.mode == LexerMode::Imperative => return TokenType::Listen,
2832            "connect" if self.mode == LexerMode::Imperative => return TokenType::NetConnect,
2833            "sleep" if self.mode == LexerMode::Imperative => return TokenType::Sleep,
2834            // GossipSub keywords (Imperative mode only)
2835            "sync" if self.mode == LexerMode::Imperative => return TokenType::Sync,
2836            // Persistence keywords
2837            "mount" if self.mode == LexerMode::Imperative => return TokenType::Mount,
2838            "persistent" => return TokenType::Persistent,  // Works in type expressions
2839            "combined" if self.mode == LexerMode::Imperative => return TokenType::Combined,
2840            "followed" if self.mode == LexerMode::Imperative => return TokenType::Followed,
2841            // Go-like Concurrency keywords (Imperative mode only)
2842            // Note: "first" and "after" are NOT keywords - they're checked via lookahead in parser
2843            // to avoid conflicting with their use as variable names
2844            "launch" if self.mode == LexerMode::Imperative => return TokenType::Launch,
2845            "task" if self.mode == LexerMode::Imperative => return TokenType::Task,
2846            "pipe" if self.mode == LexerMode::Imperative => return TokenType::Pipe,
2847            "receive" if self.mode == LexerMode::Imperative => return TokenType::Receive,
2848            "stop" if self.mode == LexerMode::Imperative => return TokenType::Stop,
2849            "try" if self.mode == LexerMode::Imperative => return TokenType::Try,
2850            "into" if self.mode == LexerMode::Imperative => return TokenType::Into,
2851            "native" if self.mode == LexerMode::Imperative => return TokenType::Native,
2852            "escape" if self.mode == LexerMode::Imperative => return TokenType::Escape,
2853            "from" => return TokenType::From,
2854            "otherwise" => return TokenType::Otherwise,
2855            // Phase 30c: Else/elif as aliases for Otherwise/Otherwise If
2856            "else" => return TokenType::Else,
2857            "elif" => return TokenType::Elif,
2858            // Sum type definition (Declarative mode only - for enum "either...or...")
2859            "either" if self.mode == LexerMode::Declarative => return TokenType::Either,
2860            // Pattern matching statement
2861            "inspect" if self.mode == LexerMode::Imperative => return TokenType::Inspect,
2862            // Constructor keyword (Imperative mode only)
2863            "new" if self.mode == LexerMode::Imperative => return TokenType::New,
2864            // Only emit Give/Show as keywords in Imperative mode
2865            // In Declarative mode, they fall through to lexicon lookup as verbs
2866            "give" if self.mode == LexerMode::Imperative => return TokenType::Give,
2867            "show" if self.mode == LexerMode::Imperative => return TokenType::Show,
2868            // Collection operation keywords (Imperative mode only)
2869            "push" if self.mode == LexerMode::Imperative => return TokenType::Push,
2870            "pop" if self.mode == LexerMode::Imperative => return TokenType::Pop,
2871            "copy" if self.mode == LexerMode::Imperative => return TokenType::Copy,
2872            "through" if self.mode == LexerMode::Imperative => return TokenType::Through,
2873            "length" if self.mode == LexerMode::Imperative => return TokenType::Length,
2874            "at" if self.mode == LexerMode::Imperative => return TokenType::At,
2875            // Set operation keywords (Imperative mode only)
2876            "add" if self.mode == LexerMode::Imperative => return TokenType::Add,
2877            "remove" if self.mode == LexerMode::Imperative => return TokenType::Remove,
2878            "contains" if self.mode == LexerMode::Imperative => return TokenType::Contains,
2879            "union" if self.mode == LexerMode::Imperative => return TokenType::Union,
2880            "intersection" if self.mode == LexerMode::Imperative => return TokenType::Intersection,
2881            // Zone keywords (Imperative mode only)
2882            "inside" if self.mode == LexerMode::Imperative => return TokenType::Inside,
2883            "zone" if self.mode == LexerMode::Imperative => return TokenType::Zone,
2884            "called" if self.mode == LexerMode::Imperative => return TokenType::Called,
2885            "size" if self.mode == LexerMode::Imperative => return TokenType::Size,
2886            "mapped" if self.mode == LexerMode::Imperative => return TokenType::Mapped,
2887            // Structured Concurrency keywords (Imperative mode only)
2888            "attempt" if self.mode == LexerMode::Imperative => return TokenType::Attempt,
2889            "following" if self.mode == LexerMode::Imperative => return TokenType::Following,
2890            "simultaneously" if self.mode == LexerMode::Imperative => return TokenType::Simultaneously,
2891            // IO keywords (Imperative mode only)
2892            "read" if self.mode == LexerMode::Imperative => return TokenType::Read,
2893            "write" if self.mode == LexerMode::Imperative => return TokenType::Write,
2894            "console" if self.mode == LexerMode::Imperative => return TokenType::Console,
2895            "file" if self.mode == LexerMode::Imperative => return TokenType::File,
2896            // Agent System keywords (Imperative mode only)
2897            "spawn" if self.mode == LexerMode::Imperative => return TokenType::Spawn,
2898            "send" if self.mode == LexerMode::Imperative => return TokenType::Send,
2899            "await" if self.mode == LexerMode::Imperative => return TokenType::Await,
2900            // Serialization keyword (works in Definition blocks too)
2901            "portable" => return TokenType::Portable,
2902            // Sipping Protocol keywords (Imperative mode only)
2903            "manifest" if self.mode == LexerMode::Imperative => return TokenType::Manifest,
2904            "chunk" if self.mode == LexerMode::Imperative => return TokenType::Chunk,
2905            // CRDT keywords
2906            "shared" => return TokenType::Shared,  // Works in Definition blocks like Portable
2907            "merge" if self.mode == LexerMode::Imperative => return TokenType::Merge,
2908            "increase" if self.mode == LexerMode::Imperative => return TokenType::Increase,
2909            // Extended CRDT keywords
2910            "decrease" if self.mode == LexerMode::Imperative => return TokenType::Decrease,
2911            "append" if self.mode == LexerMode::Imperative => return TokenType::Append,
2912            "resolve" if self.mode == LexerMode::Imperative => return TokenType::Resolve,
2913            "values" if self.mode == LexerMode::Imperative => return TokenType::Values,
2914            // Type keywords (work in both modes like "Shared"):
2915            "tally" => return TokenType::Tally,
2916            "sharedset" => return TokenType::SharedSet,
2917            "sharedsequence" => return TokenType::SharedSequence,
2918            "collaborativesequence" => return TokenType::CollaborativeSequence,
2919            "sharedmap" => return TokenType::SharedMap,
2920            "divergent" => return TokenType::Divergent,
2921            "removewins" => return TokenType::RemoveWins,
2922            "addwins" => return TokenType::AddWins,
2923            "yata" => return TokenType::YATA,
2924            // Calendar / clock time unit words (Span expressions). The singular
2925            // "second"/"minute" are ambiguous (ordinal "second base", adjective
2926            // "minute detail") so they are time units only after a count; the
2927            // plurals and "hour" are unambiguous time units.
2928            "seconds" => return TokenType::CalendarUnit(CalendarUnit::Second),
2929            "minutes" => return TokenType::CalendarUnit(CalendarUnit::Minute),
2930            "second" if self.prev_word_is_numeric() => {
2931                return TokenType::CalendarUnit(CalendarUnit::Second)
2932            }
2933            "minute" if self.prev_word_is_numeric() => {
2934                return TokenType::CalendarUnit(CalendarUnit::Minute)
2935            }
2936            "hour" | "hours" => return TokenType::CalendarUnit(CalendarUnit::Hour),
2937            "day" | "days" => return TokenType::CalendarUnit(CalendarUnit::Day),
2938            "week" | "weeks" => return TokenType::CalendarUnit(CalendarUnit::Week),
2939            "month" | "months" => return TokenType::CalendarUnit(CalendarUnit::Month),
2940            "year" | "years" => return TokenType::CalendarUnit(CalendarUnit::Year),
2941            // Span-related keywords (note: "before" is handled earlier to avoid preposition conflict)
2942            "ago" => return TokenType::Ago,
2943            "hence" => return TokenType::Hence,
2944            "if" => return TokenType::If,
2945            "only" => return TokenType::Focus(FocusKind::Only),
2946            "even" => return TokenType::Focus(FocusKind::Even),
2947            "just" if self.peek_word(1).map_or(false, |w| {
2948                !self.is_verb_like(w) || w.to_lowercase() == "john" || w.chars().next().map_or(false, |c| c.is_uppercase())
2949            }) => return TokenType::Focus(FocusKind::Just),
2950            "much" => return TokenType::Measure(MeasureKind::Much),
2951            "little" => return TokenType::Measure(MeasureKind::Little),
2952            _ => {}
2953        }
2954
2955        if lexicon::is_scopal_adverb(&lower) {
2956            let sym = self.interner.intern(&Self::capitalize(&lower));
2957            return TokenType::ScopalAdverb(sym);
2958        }
2959
2960        if lexicon::is_temporal_adverb(&lower) && !self.prev_token_is_determiner() {
2961            let sym = self.interner.intern(&Self::capitalize(&lower));
2962            return TokenType::TemporalAdverb(sym);
2963        }
2964
2965        if lexicon::is_non_intersective(&lower) {
2966            let sym = self.interner.intern(&Self::capitalize(&lower));
2967            return TokenType::NonIntersectiveAdjective(sym);
2968        }
2969
2970        if lexicon::is_adverb(&lower) {
2971            let sym = self.interner.intern(&Self::capitalize(&lower));
2972            return TokenType::Adverb(sym);
2973        }
2974        // A capitalized "-ly" word is normally a proper name ("Billy", "Holly",
2975        // "Kelly's stamp"), not an adverb — UNLESS it is a sentence-initial
2976        // manner adverb modifying a following pronoun ("Quickly he ran").
2977        let cap_ly_is_name = word.chars().next().map_or(false, |c| c.is_uppercase())
2978            && !self.peek_word(1).map_or(false, |w| {
2979                matches!(
2980                    w.to_lowercase().as_str(),
2981                    "he" | "she" | "it" | "they" | "we" | "i" | "you" | "who"
2982                )
2983            });
2984        if lower.ends_with("ly")
2985            && !lexicon::is_not_adverb(&lower)
2986            && !lexicon::is_common_noun(&lower)
2987            && lower.len() > 4
2988            && !cap_ly_is_name
2989        {
2990            let sym = self.interner.intern(&Self::capitalize(&lower));
2991            return TokenType::Adverb(sym);
2992        }
2993
2994        if let Some(base) = self.try_parse_superlative(&lower) {
2995            let sym = self.interner.intern(&base);
2996            return TokenType::Superlative(sym);
2997        }
2998
2999        // Handle irregular comparatives (less, more, better, worse). "fewer" is
3000        // the count counterpart of "less" — same decreasing direction — and maps
3001        // to the same Decreasing base so it grades like "less" ("received fewer
3002        // votes than Y" → Less(Vote(X), Vote(Y))) without turning bare "few" into
3003        // an adjective (which would break its quantifier reading, "few cookies").
3004        let irregular_comparative = match lower.as_str() {
3005            "less" => Some("Little"),
3006            "fewer" => Some("Little"),
3007            "more" => Some("Much"),
3008            "better" => Some("Good"),
3009            "worse" => Some("Bad"),
3010            _ => None,
3011        };
3012        if let Some(base) = irregular_comparative {
3013            let sym = self.interner.intern(base);
3014            return TokenType::Comparative(sym);
3015        }
3016
3017        if let Some(base) = self.try_parse_comparative(&lower) {
3018            // A word that is ALSO a common noun ("stranger" — the noun vs the
3019            // comparative of "strange") is a comparative only in comparative
3020            // position, signalled by a following "than" ("is stranger than").
3021            // Elsewhere ("the stranger", "Let stranger be …") it is the noun, so
3022            // defer to noun classification below — mirroring the performative /
3023            // base-verb noun-deference above.
3024            let next_is_than = self
3025                .peek_word(1)
3026                .map_or(false, |w| w.eq_ignore_ascii_case("than"));
3027            if !lexicon::is_common_noun(&lower) || next_is_than {
3028                let sym = self.interner.intern(&base);
3029                return TokenType::Comparative(sym);
3030            }
3031            // else fall through to noun classification
3032        }
3033
3034        if lexicon::is_performative(&lower) {
3035            // If the word is also a common noun AND follows a determiner or precedes a copula,
3036            // don't force performative reading.
3037            // "every request holds" → request is a noun, not a performative verb.
3038            // "If request is asserted" → request is a noun (subject before copula).
3039            // "I promise to come" → promise IS a performative verb.
3040            let after_determiner = self.prev_token_is_determiner();
3041            let before_copula = self.next_token_is_copula();
3042            if !lexicon::is_common_noun(&lower) || (!after_determiner && !before_copula) {
3043                let sym = self.interner.intern(&Self::capitalize(&lower));
3044                return TokenType::Performative(sym);
3045            }
3046            // Fall through to noun/verb disambiguation below
3047        }
3048
3049        if lexicon::is_base_verb_early(&lower) {
3050            // If the word is also a common noun AND follows a determiner or precedes a copula,
3051            // don't force verb reading.
3052            // "every grant holds" → grant is a noun, not a verb.
3053            // "If grant is low" → grant is a noun (subject before copula).
3054            let after_determiner = self.prev_token_is_determiner();
3055            let before_copula = self.next_token_is_copula();
3056            if !lexicon::is_common_noun(&lower) || (!after_determiner && !before_copula) {
3057                let sym = self.interner.intern(&Self::capitalize(&lower));
3058                let class = lexicon::lookup_verb_class(&lower);
3059                return TokenType::Verb {
3060                    lemma: sym,
3061                    time: Time::Present,
3062                    aspect: Aspect::Simple,
3063                    class,
3064                };
3065            }
3066            // Fall through to noun/verb disambiguation below
3067        }
3068
3069        // Check for gerunds/progressive verbs BEFORE ProperName check
3070        // "Running" at start of sentence should be Verb, not ProperName
3071        if lower.ends_with("ing") && lower.len() > 4 {
3072            // Participial ADJECTIVE in attributive position ("the
3073            // corresponding output line"): the word has an adjective entry
3074            // and a content word follows it. -ing PREPOSITIONS ("during")
3075            // skip the early return too — the dual-class block below emits
3076            // their Ambiguous alternatives.
3077            let attributive = self.is_adjective_like(&lower)
3078                && self
3079                    .peek_word(1)
3080                    .map(|next| {
3081                        let next_lower = next.to_lowercase();
3082                        self.is_noun_like(&next_lower) || self.is_adjective_like(&next_lower)
3083                    })
3084                    .unwrap_or(false);
3085            if !attributive && !lexicon::is_preposition(&lower) {
3086                if let Some(entry) = self.lexicon.lookup_verb(&lower) {
3087                    let sym = self.interner.intern(&entry.lemma);
3088                    return TokenType::Verb {
3089                        lemma: sym,
3090                        time: entry.time,
3091                        aspect: entry.aspect,
3092                        class: entry.class,
3093                    };
3094                }
3095            }
3096        }
3097
3098        if first_char.is_uppercase() {
3099            // Smart Lexicon: Check if this capitalized word is actually a common noun
3100            // Only apply for sentence-initial words (followed by verb) to avoid
3101            // breaking type definitions like "A Point has:"
3102            //
3103            // Pattern: "Farmers walk." → Farmers is plural of Farmer (common noun)
3104            // Pattern: "A Point has:" → Point is a type name (proper name)
3105            if let Some(next) = self.peek_word(1) {
3106                let next_lower = next.to_lowercase();
3107                // If next word is a verb, this capitalized word is likely a subject noun
3108                let is_followed_by_verb = self.lexicon.lookup_verb(&next_lower).is_some()
3109                    || matches!(next_lower.as_str(), "is" | "are" | "was" | "were" | "has" | "have" | "had");
3110
3111                if is_followed_by_verb {
3112                    // Check if lowercase version is a derivable common noun
3113                    if let Some(analysis) = lexicon::analyze_word(&lower) {
3114                        match analysis {
3115                            lexicon::WordAnalysis::Noun(meta) if meta.number == lexicon::Number::Plural => {
3116                                // It's a plural noun - definitely a common noun
3117                                let sym = self.interner.intern(&lower);
3118                                return TokenType::Noun(sym);
3119                            }
3120                            lexicon::WordAnalysis::DerivedNoun { number: lexicon::Number::Plural, .. } => {
3121                                // Derived plural agentive noun (e.g., "Bloggers")
3122                                let sym = self.interner.intern(&lower);
3123                                return TokenType::Noun(sym);
3124                            }
3125                            _ => {
3126                                // Singular nouns at sentence start could still be proper names
3127                                // e.g., "John walks." vs "Farmer walks."
3128                            }
3129                        }
3130                    }
3131                }
3132            }
3133
3134            let sym = self.interner.intern(word);
3135            return TokenType::ProperName(sym);
3136        }
3137
3138        let verb_entry = self.lexicon.lookup_verb(&lower);
3139        // Irregular plurals ("children", "mice") are not in the common-noun
3140        // table; morphological analysis identifies them. Restricted to
3141        // non-verb words so "rains" stays a pure verb.
3142        let is_noun = lexicon::is_common_noun(&lower)
3143            || (verb_entry.is_none()
3144                && matches!(
3145                    lexicon::analyze_word(&lower),
3146                    Some(lexicon::WordAnalysis::Noun(_))
3147                ));
3148        let is_adj = self.is_adjective_like(&lower);
3149        let is_disambiguated = lexicon::is_disambiguation_not_verb(&lower);
3150
3151        // Ambiguous: word is Verb AND (Noun OR Adjective OR Preposition),
3152        // not disambiguated
3153        let is_prep = lexicon::is_preposition(&lower);
3154        if verb_entry.is_some() && (is_noun || is_adj || is_prep) && !is_disambiguated {
3155            let entry = verb_entry.unwrap();
3156            let verb_token = TokenType::Verb {
3157                lemma: self.interner.intern(&entry.lemma),
3158                time: entry.time,
3159                aspect: entry.aspect,
3160                class: entry.class,
3161            };
3162
3163            let mut alternatives = Vec::new();
3164            if is_noun {
3165                alternatives.push(TokenType::Noun(self.interner.intern(word)));
3166            }
3167            if is_adj {
3168                alternatives.push(TokenType::Adjective(self.interner.intern(word)));
3169            }
3170            if is_prep {
3171                alternatives.push(TokenType::Preposition(self.interner.intern(&lower)));
3172            }
3173
3174            return TokenType::Ambiguous {
3175                primary: Box::new(verb_token),
3176                alternatives,
3177            };
3178        }
3179
3180        // Disambiguated away from verb — only when there is an alternate reading.
3181        // If the word has no noun or adjective reading, the lexicon verb entry wins.
3182        if let Some(entry) = &verb_entry {
3183            if is_disambiguated {
3184                let sym = self.interner.intern(word);
3185                if is_noun {
3186                    return TokenType::Noun(sym);
3187                }
3188                if is_adj {
3189                    return TokenType::Adjective(sym);
3190                }
3191                // No alternate reading: honour the lexicon (e.g. "led" = past of "lead").
3192                return TokenType::Verb {
3193                    lemma: self.interner.intern(&entry.lemma),
3194                    time: entry.time,
3195                    aspect: entry.aspect,
3196                    class: entry.class,
3197                };
3198            }
3199        }
3200
3201        // Pure verb
3202        if let Some(entry) = verb_entry {
3203            let sym = self.interner.intern(&entry.lemma);
3204            return TokenType::Verb {
3205                lemma: sym,
3206                time: entry.time,
3207                aspect: entry.aspect,
3208                class: entry.class,
3209            };
3210        }
3211
3212        // Pure noun
3213        if is_noun {
3214            let sym = self.interner.intern(word);
3215            return TokenType::Noun(sym);
3216        }
3217
3218        // Pure adjective — a word the lexicon knows only as an adjective
3219        // ("happy", "dangerous", "tall"). Without this the word falls through
3220        // to the Noun fallback and predicative/attributive adjective readings
3221        // are lost (e.g. "John seems happy." strands "happy").
3222        if is_adj {
3223            let sym = self.interner.intern(word);
3224            return TokenType::Adjective(sym);
3225        }
3226
3227        if lexicon::is_base_verb(&lower) {
3228            let sym = self.interner.intern(&Self::capitalize(&lower));
3229            let class = lexicon::lookup_verb_class(&lower);
3230            return TokenType::Verb {
3231                lemma: sym,
3232                time: Time::Present,
3233                aspect: Aspect::Simple,
3234                class,
3235            };
3236        }
3237
3238        if lower.ends_with("ian")
3239            || lower.ends_with("er")
3240            || lower == "logic"
3241            || lower == "time"
3242            || lower == "men"
3243            || lower == "book"
3244            || lower == "house"
3245            || lower == "code"
3246            || lower == "user"
3247        {
3248            let sym = self.interner.intern(word);
3249            return TokenType::Noun(sym);
3250        }
3251
3252        if lexicon::is_particle(&lower) {
3253            let sym = self.interner.intern(&lower);
3254            return TokenType::Particle(sym);
3255        }
3256
3257        // Unknown "-ed" word → regular past-tense verb ("aired", "studied",
3258        // "debuted"). English marks the past tense morphologically, so an
3259        // otherwise-unrecognized "-ed" content word is overwhelmingly a verb.
3260        // Kept Ambiguous with a Noun reading so it can still head an NP where a
3261        // noun is expected; the parser picks the verb reading in VP position.
3262        if lower.len() >= 4 && lower.ends_with("ed") {
3263            let stem = if lower.ends_with("ied") {
3264                format!("{}y", &lower[..lower.len() - 3])
3265            } else {
3266                lower[..lower.len() - 2].to_string()
3267            };
3268            let verb_token = TokenType::Verb {
3269                lemma: self.interner.intern(&Self::capitalize(&stem)),
3270                time: Time::Past,
3271                aspect: Aspect::Simple,
3272                class: lexicon::lookup_verb_class(&stem),
3273            };
3274            return TokenType::Ambiguous {
3275                primary: Box::new(verb_token),
3276                alternatives: vec![TokenType::Noun(self.interner.intern(word))],
3277            };
3278        }
3279
3280        // Unknown lowercase words default to Noun. In natural language contexts
3281        // (puzzle clues, prose) unknown content words are overwhelmingly nouns —
3282        // domain-specific items like dance styles, place names, object names, etc.
3283        // Genuine adjectives and adverbs are caught by earlier checks.
3284        let sym = self.interner.intern(word);
3285        TokenType::Noun(sym)
3286    }
3287
3288    fn capitalize(s: &str) -> String {
3289        let mut chars = s.chars();
3290        match chars.next() {
3291            None => String::new(),
3292            Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
3293        }
3294    }
3295
3296    pub fn is_collective_verb(lemma: &str) -> bool {
3297        lexicon::is_collective_verb(&lemma.to_lowercase())
3298    }
3299
3300    pub fn is_mixed_verb(lemma: &str) -> bool {
3301        lexicon::is_mixed_verb(&lemma.to_lowercase())
3302    }
3303
3304    pub fn is_distributive_verb(lemma: &str) -> bool {
3305        lexicon::is_distributive_verb(&lemma.to_lowercase())
3306    }
3307
3308    pub fn is_intensional_predicate(lemma: &str) -> bool {
3309        lexicon::is_intensional_predicate(&lemma.to_lowercase())
3310    }
3311
3312    pub fn is_opaque_verb(lemma: &str) -> bool {
3313        lexicon::is_opaque_verb(&lemma.to_lowercase())
3314    }
3315
3316    pub fn is_ditransitive_verb(lemma: &str) -> bool {
3317        lexicon::is_ditransitive_verb(&lemma.to_lowercase())
3318    }
3319
3320    fn is_verb_like(&self, word: &str) -> bool {
3321        let lower = word.to_lowercase();
3322        if lexicon::is_infinitive_verb(&lower) {
3323            return true;
3324        }
3325        if let Some(entry) = self.lexicon.lookup_verb(&lower) {
3326            return entry.lemma.len() > 0;
3327        }
3328        false
3329    }
3330
3331    pub fn is_subject_control_verb(lemma: &str) -> bool {
3332        lexicon::is_subject_control_verb(&lemma.to_lowercase())
3333    }
3334
3335    pub fn is_raising_verb(lemma: &str) -> bool {
3336        lexicon::is_raising_verb(&lemma.to_lowercase())
3337    }
3338
3339    pub fn is_object_control_verb(lemma: &str) -> bool {
3340        lexicon::is_object_control_verb(&lemma.to_lowercase())
3341    }
3342
3343    pub fn is_weather_verb(lemma: &str) -> bool {
3344        matches!(
3345            lemma.to_lowercase().as_str(),
3346            "rain" | "snow" | "hail" | "thunder" | "pour"
3347        )
3348    }
3349
3350    fn try_parse_superlative(&self, word: &str) -> Option<String> {
3351        if !word.ends_with("est") || word.len() < 5 {
3352            return None;
3353        }
3354
3355        let base = &word[..word.len() - 3];
3356
3357        if base.len() >= 2 {
3358            let chars: Vec<char> = base.chars().collect();
3359            let last = chars[chars.len() - 1];
3360            let second_last = chars[chars.len() - 2];
3361            if last == second_last && !"aeiou".contains(last) {
3362                let stem = &base[..base.len() - 1];
3363                if lexicon::is_gradable_adjective(stem) {
3364                    return Some(Self::capitalize(stem));
3365                }
3366            }
3367        }
3368
3369        if base.ends_with("i") {
3370            let stem = format!("{}y", &base[..base.len() - 1]);
3371            if lexicon::is_gradable_adjective(&stem) {
3372                return Some(Self::capitalize(&stem));
3373            }
3374        }
3375
3376        if lexicon::is_gradable_adjective(base) {
3377            return Some(Self::capitalize(base));
3378        }
3379
3380        None
3381    }
3382
3383    fn try_parse_comparative(&self, word: &str) -> Option<String> {
3384        if !word.ends_with("er") || word.len() < 4 {
3385            return None;
3386        }
3387
3388        let base = &word[..word.len() - 2];
3389
3390        if base.len() >= 2 {
3391            let chars: Vec<char> = base.chars().collect();
3392            let last = chars[chars.len() - 1];
3393            let second_last = chars[chars.len() - 2];
3394            if last == second_last && !"aeiou".contains(last) {
3395                let stem = &base[..base.len() - 1];
3396                if lexicon::is_gradable_adjective(stem) {
3397                    return Some(Self::capitalize(stem));
3398                }
3399            }
3400        }
3401
3402        if base.ends_with("i") {
3403            let stem = format!("{}y", &base[..base.len() - 1]);
3404            if lexicon::is_gradable_adjective(&stem) {
3405                return Some(Self::capitalize(&stem));
3406            }
3407        }
3408
3409        if lexicon::is_gradable_adjective(base) {
3410            return Some(Self::capitalize(base));
3411        }
3412
3413        // Silent-e adjectives drop the 'e' before -er: "wide"→"wider",
3414        // "large"→"larger", "late"→"later". Recover the base by restoring it.
3415        let with_e = format!("{}e", base);
3416        if lexicon::is_gradable_adjective(&with_e) {
3417            return Some(Self::capitalize(&with_e));
3418        }
3419
3420        None
3421    }
3422}
3423
3424#[cfg(test)]
3425mod tests {
3426    use super::*;
3427
3428    #[test]
3429    fn lexer_handles_apostrophe() {
3430        let mut interner = Interner::new();
3431        let mut lexer = Lexer::new("it's raining", &mut interner);
3432        let tokens = lexer.tokenize();
3433        assert!(!tokens.is_empty());
3434    }
3435
3436    #[test]
3437    fn lexer_handles_question_mark() {
3438        let mut interner = Interner::new();
3439        let mut lexer = Lexer::new("Is it raining?", &mut interner);
3440        let tokens = lexer.tokenize();
3441        assert!(!tokens.is_empty());
3442    }
3443
3444    #[test]
3445    fn ring_is_not_verb() {
3446        let mut interner = Interner::new();
3447        let mut lexer = Lexer::new("ring", &mut interner);
3448        let tokens = lexer.tokenize();
3449        assert!(matches!(tokens[0].kind, TokenType::Noun(_)));
3450    }
3451
3452    /// Rung 0a, Stride 0: `## Define` introduces a vernacular-logic predicate
3453    /// definition. It must lex to its OWN block type — never collapse into the
3454    /// pre-existing `## Definition` (which `DiscoveryPass` consumes for type
3455    /// defs). This pins both: `## Define` → `Define`, `## Definition` unchanged.
3456    #[test]
3457    fn define_block_header_is_distinct_from_definition() {
3458        let mut interner = Interner::new();
3459        let mut lexer = Lexer::new("## Define\n", &mut interner);
3460        let tokens = lexer.tokenize();
3461        let header = tokens
3462            .iter()
3463            .find(|t| matches!(t.kind, TokenType::BlockHeader { .. }))
3464            .expect("## Define should produce a block header token");
3465        assert!(
3466            matches!(
3467                header.kind,
3468                TokenType::BlockHeader { block_type: BlockType::Define }
3469            ),
3470            "## Define must tokenize to BlockType::Define, got {:?}",
3471            header.kind
3472        );
3473
3474        // Regression guard: the long-standing `## Definition` block stays itself.
3475        let mut interner2 = Interner::new();
3476        let mut lexer2 = Lexer::new("## Definition\n", &mut interner2);
3477        let tokens2 = lexer2.tokenize();
3478        let header2 = tokens2
3479            .iter()
3480            .find(|t| matches!(t.kind, TokenType::BlockHeader { .. }))
3481            .expect("## Definition should produce a block header token");
3482        assert!(
3483            matches!(
3484                header2.kind,
3485                TokenType::BlockHeader { block_type: BlockType::Definition }
3486            ),
3487            "## Definition must still tokenize to BlockType::Definition, got {:?}",
3488            header2.kind
3489        );
3490    }
3491
3492    #[test]
3493    fn debug_that_token() {
3494        let mut interner = Interner::new();
3495        let mut lexer = Lexer::new("The cat that runs", &mut interner);
3496        let tokens = lexer.tokenize();
3497        for (i, t) in tokens.iter().enumerate() {
3498            let lex = interner.resolve(t.lexeme);
3499            eprintln!("Token[{}]: {:?} -> {:?}", i, lex, t.kind);
3500        }
3501        let that_token = tokens.iter().find(|t| interner.resolve(t.lexeme) == "that");
3502        if let Some(t) = that_token {
3503            // Verify discriminant comparison works
3504            let check = std::mem::discriminant(&t.kind) == std::mem::discriminant(&TokenType::That);
3505            eprintln!("Discriminant check for That: {}", check);
3506            assert!(matches!(t.kind, TokenType::That), "'that' should be TokenType::That, got {:?}", t.kind);
3507        } else {
3508            panic!("No 'that' token found");
3509        }
3510    }
3511
3512    #[test]
3513    fn bus_is_not_verb() {
3514        let mut interner = Interner::new();
3515        let mut lexer = Lexer::new("bus", &mut interner);
3516        let tokens = lexer.tokenize();
3517        assert!(matches!(tokens[0].kind, TokenType::Noun(_)));
3518    }
3519
3520    #[test]
3521    fn lowercase_a_is_article() {
3522        let mut interner = Interner::new();
3523        let mut lexer = Lexer::new("a car", &mut interner);
3524        let tokens = lexer.tokenize();
3525        for (i, t) in tokens.iter().enumerate() {
3526            let lex = interner.resolve(t.lexeme);
3527            eprintln!("Token[{}]: {:?} -> {:?}", i, lex, t.kind);
3528        }
3529        assert_eq!(tokens[0].kind, TokenType::Article(Definiteness::Indefinite));
3530        assert!(matches!(tokens[1].kind, TokenType::Noun(_)), "Expected Noun, got {:?}", tokens[1].kind);
3531    }
3532
3533    #[test]
3534    fn open_is_ambiguous() {
3535        let mut interner = Interner::new();
3536        let mut lexer = Lexer::new("open", &mut interner);
3537        let tokens = lexer.tokenize();
3538
3539        if let TokenType::Ambiguous { primary, alternatives } = &tokens[0].kind {
3540            assert!(matches!(**primary, TokenType::Verb { .. }), "Primary should be Verb");
3541            assert!(alternatives.iter().any(|t| matches!(t, TokenType::Adjective(_))),
3542                "Should have Adjective alternative");
3543        } else {
3544            panic!("Expected Ambiguous token for 'open', got {:?}", tokens[0].kind);
3545        }
3546    }
3547
3548    #[test]
3549    fn basic_tokenization() {
3550        let mut interner = Interner::new();
3551        let mut lexer = Lexer::new("All men are mortal.", &mut interner);
3552        let tokens = lexer.tokenize();
3553        assert_eq!(tokens[0].kind, TokenType::All);
3554        assert!(matches!(tokens[1].kind, TokenType::Noun(_)));
3555        assert_eq!(tokens[2].kind, TokenType::Are);
3556    }
3557
3558    #[test]
3559    fn iff_tokenizes_as_single_token() {
3560        let mut interner = Interner::new();
3561        let mut lexer = Lexer::new("A if and only if B", &mut interner);
3562        let tokens = lexer.tokenize();
3563        assert!(
3564            tokens.iter().any(|t| t.kind == TokenType::Iff),
3565            "should contain Iff token: got {:?}",
3566            tokens
3567        );
3568    }
3569
3570    #[test]
3571    fn is_equal_to_tokenizes_as_identity() {
3572        let mut interner = Interner::new();
3573        let mut lexer = Lexer::new("Socrates is equal to Socrates", &mut interner);
3574        let tokens = lexer.tokenize();
3575        assert!(
3576            tokens.iter().any(|t| t.kind == TokenType::Identity),
3577            "should contain Identity token: got {:?}",
3578            tokens
3579        );
3580    }
3581
3582    #[test]
3583    fn is_identical_to_tokenizes_as_identity() {
3584        let mut interner = Interner::new();
3585        let mut lexer = Lexer::new("Clark is identical to Superman", &mut interner);
3586        let tokens = lexer.tokenize();
3587        assert!(
3588            tokens.iter().any(|t| t.kind == TokenType::Identity),
3589            "should contain Identity token: got {:?}",
3590            tokens
3591        );
3592    }
3593
3594    #[test]
3595    fn itself_tokenizes_as_reflexive() {
3596        let mut interner = Interner::new();
3597        let mut lexer = Lexer::new("John loves itself", &mut interner);
3598        let tokens = lexer.tokenize();
3599        assert!(
3600            tokens.iter().any(|t| t.kind == TokenType::Reflexive),
3601            "should contain Reflexive token: got {:?}",
3602            tokens
3603        );
3604    }
3605
3606    #[test]
3607    fn himself_tokenizes_as_reflexive() {
3608        let mut interner = Interner::new();
3609        let mut lexer = Lexer::new("John sees himself", &mut interner);
3610        let tokens = lexer.tokenize();
3611        assert!(
3612            tokens.iter().any(|t| t.kind == TokenType::Reflexive),
3613            "should contain Reflexive token: got {:?}",
3614            tokens
3615        );
3616    }
3617
3618    #[test]
3619    fn to_stay_tokenizes_correctly() {
3620        let mut interner = Interner::new();
3621        let mut lexer = Lexer::new("to stay", &mut interner);
3622        let tokens = lexer.tokenize();
3623        assert!(
3624            tokens.iter().any(|t| t.kind == TokenType::To),
3625            "should contain To token: got {:?}",
3626            tokens
3627        );
3628        assert!(
3629            tokens.iter().any(|t| matches!(t.kind, TokenType::Verb { .. })),
3630            "should contain Verb token for stay: got {:?}",
3631            tokens
3632        );
3633    }
3634
3635    #[test]
3636    fn possessive_apostrophe_s() {
3637        let mut interner = Interner::new();
3638        let mut lexer = Lexer::new("John's dog", &mut interner);
3639        let tokens = lexer.tokenize();
3640        assert!(
3641            tokens.iter().any(|t| t.kind == TokenType::Possessive),
3642            "should contain Possessive token: got {:?}",
3643            tokens
3644        );
3645        assert!(
3646            tokens.iter().any(|t| matches!(&t.kind, TokenType::ProperName(_))),
3647            "should have John as proper name: got {:?}",
3648            tokens
3649        );
3650    }
3651
3652    #[test]
3653    fn lexer_produces_valid_spans() {
3654        let input = "All men are mortal.";
3655        let mut interner = Interner::new();
3656        let mut lexer = Lexer::new(input, &mut interner);
3657        let tokens = lexer.tokenize();
3658
3659        // "All" at 0..3
3660        assert_eq!(tokens[0].span.start, 0);
3661        assert_eq!(tokens[0].span.end, 3);
3662        assert_eq!(&input[tokens[0].span.start..tokens[0].span.end], "All");
3663
3664        // "men" at 4..7
3665        assert_eq!(tokens[1].span.start, 4);
3666        assert_eq!(tokens[1].span.end, 7);
3667        assert_eq!(&input[tokens[1].span.start..tokens[1].span.end], "men");
3668
3669        // "are" at 8..11
3670        assert_eq!(tokens[2].span.start, 8);
3671        assert_eq!(tokens[2].span.end, 11);
3672        assert_eq!(&input[tokens[2].span.start..tokens[2].span.end], "are");
3673
3674        // "mortal" at 12..18
3675        assert_eq!(tokens[3].span.start, 12);
3676        assert_eq!(tokens[3].span.end, 18);
3677        assert_eq!(&input[tokens[3].span.start..tokens[3].span.end], "mortal");
3678
3679        // "." at 18..19
3680        assert_eq!(tokens[4].span.start, 18);
3681        assert_eq!(tokens[4].span.end, 19);
3682
3683        // EOF at end
3684        assert_eq!(tokens[5].span.start, input.len());
3685        assert_eq!(tokens[5].kind, TokenType::EOF);
3686    }
3687
3688    #[test]
3689    fn triple_quote_produces_string_token() {
3690        let mut interner = Interner::new();
3691        let source = "## Main\nLet msg be \"\"\"\n    Hello\n    World\n\"\"\".\nShow msg.";
3692        let mut lexer = Lexer::new(source, &mut interner);
3693        let tokens = lexer.tokenize();
3694        // Dump all tokens for debugging
3695        for (i, t) in tokens.iter().enumerate() {
3696            let lex = interner.resolve(t.lexeme);
3697            eprintln!("Token[{}]: {:?} lex={:?} span={}..{}", i, t.kind, lex, t.span.start, t.span.end);
3698        }
3699        // Find the string token
3700        let str_token = tokens.iter().find(|t| matches!(t.kind, TokenType::StringLiteral(_) | TokenType::InterpolatedString(_)));
3701        assert!(str_token.is_some(), "Should have a string token. Tokens: {:?}", tokens.iter().map(|t| format!("{:?}", t.kind)).collect::<Vec<_>>());
3702        if let Some(tok) = str_token {
3703            let content = interner.resolve(tok.lexeme);
3704            eprintln!("Triple-quote content: {:?}", content);
3705            assert!(content.contains("Hello"), "Should contain Hello, got: {:?}", content);
3706        }
3707    }
3708
3709    /// BUG-015: a string-literal span must stay BYTE-indexed even when multibyte
3710    /// text precedes it; a byte-start / char-end span underflows in
3711    /// `insert_indentation_tokens` and panics on valid Unicode input.
3712    #[test]
3713    fn string_literal_span_stays_byte_indexed_after_leading_multibyte_text() {
3714        // 7 leading 2-byte Greek letters, a space, then a quoted string.
3715        let src = "\u{3b1}\u{3b1}\u{3b1}\u{3b1}\u{3b1}\u{3b1}\u{3b1} \"x\"";
3716        let mut interner = Interner::new();
3717        let mut lexer = Lexer::new(src, &mut interner);
3718        let tokens = lexer.tokenize(); // previously panicked: 'attempt to subtract with overflow'
3719
3720        let s = tokens
3721            .iter()
3722            .find(|t| matches!(t.kind, TokenType::StringLiteral(_) | TokenType::InterpolatedString(_)))
3723            .expect("should produce a string literal token");
3724
3725        assert!(s.span.end >= s.span.start, "span end {} must be >= start {}", s.span.end, s.span.start);
3726        assert!(
3727            src.is_char_boundary(s.span.start) && src.is_char_boundary(s.span.end),
3728            "span [{}, {}) must lie on char boundaries",
3729            s.span.start, s.span.end
3730        );
3731        assert_eq!(&src[s.span.start..s.span.end], "\"x\"");
3732    }
3733
3734    /// BUG-030: calendar-impossible dates (Feb 30, Apr 31, Feb 29 in a non-leap
3735    /// year) must NOT tokenize to a DateLiteral — they would otherwise silently
3736    /// map onto a real, different day.
3737    #[test]
3738    fn date_literal_rejects_impossible_day_of_month() {
3739        let impossible = [
3740            "2026-02-30", "2026-02-31", "2026-02-29", // 2026 is not a leap year
3741            "2026-04-31", "2026-06-31", "2026-09-31", "2026-11-31",
3742        ];
3743        for input in impossible {
3744            let mut interner = Interner::new();
3745            let mut lexer = Lexer::new(input, &mut interner);
3746            let tokens = lexer.tokenize();
3747            assert!(
3748                !tokens.iter().any(|t| matches!(t.kind, TokenType::DateLiteral { .. })),
3749                "{input} is not a real calendar date and must not become a DateLiteral; got {tokens:?}"
3750            );
3751        }
3752        // Control: a real leap-day must still tokenize.
3753        let mut interner = Interner::new();
3754        let mut lexer = Lexer::new("2024-02-29", &mut interner);
3755        let tokens = lexer.tokenize();
3756        assert!(
3757            tokens.iter().any(|t| matches!(t.kind, TokenType::DateLiteral { .. })),
3758            "2024-02-29 is a valid leap-day and must still tokenize; got {tokens:?}"
3759        );
3760    }
3761}