Skip to main content

step_io/parser/
lexer.rs

1use logos::Logos;
2
3/// Position information for a lexed token.
4///
5/// `start`/`end` are byte offsets into the source. `line` is 1-indexed by
6/// `\n` occurrences. `column` is 1-indexed and counts Unicode scalar values
7/// (chars), so multibyte characters advance the column by one.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct Span {
10    pub start: usize,
11    pub end: usize,
12    pub line: u32,
13    pub column: u32,
14}
15
16impl Span {
17    /// Return the slice of `source` that produced this token.
18    #[must_use]
19    pub fn slice<'a>(&self, source: &'a str) -> &'a str {
20        &source[self.start..self.end]
21    }
22}
23
24/// A lexed token carrying its kind and position.
25#[derive(Debug, Clone, PartialEq)]
26pub struct Token {
27    pub kind: TokenKind,
28    pub span: Span,
29}
30
31/// Lexical error categories.
32///
33/// `UnexpectedCharacter` (a token that does not match any rule) and
34/// `InvalidNumber` (a numeric/ref literal that is out of range or non-finite —
35/// classified from the offending snippet) are produced by the lexer. The
36/// remaining variants are declared for forward compatibility.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum LexErrorKind {
39    UnexpectedCharacter,
40    UnterminatedString,
41    InvalidNumber,
42    InvalidBinary,
43}
44
45impl LexErrorKind {
46    fn as_message(&self) -> &'static str {
47        match self {
48            Self::UnexpectedCharacter => "unexpected character",
49            Self::UnterminatedString => "unterminated string literal",
50            Self::InvalidNumber => "numeric literal out of range",
51            Self::InvalidBinary => "invalid binary literal",
52        }
53    }
54}
55
56/// Self-contained lexical error including the offending span and a short
57/// snippet of the source text.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct LexError {
60    pub kind: LexErrorKind,
61    pub span: Span,
62    pub snippet: String,
63}
64
65impl std::fmt::Display for LexError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(
68            f,
69            "lex error at line {}, column {}: {} (snippet: {:?})",
70            self.span.line,
71            self.span.column,
72            self.kind.as_message(),
73            self.snippet,
74        )
75    }
76}
77
78impl std::error::Error for LexError {}
79
80/// Tokenize a source string into a `Vec<Token>`, halting at the first
81/// lexical error. Callers that need streaming or custom recovery should
82/// construct a [`Lexer`] directly.
83///
84/// # Errors
85///
86/// Returns the first [`LexError`] encountered while scanning `source`.
87pub fn tokenize(source: &str) -> Result<Vec<Token>, LexError> {
88    Lexer::new(source).collect()
89}
90
91/// Stateful Part 21 lexer that tracks line/column positions and wraps
92/// logos's byte-level lexer.
93///
94/// `Lexer` owns its own single-token lookahead buffer (`peeked`) rather than
95/// relying on the standard `Peekable` adapter. This avoids the awkward
96/// `Option<&Result<Token, LexError>>` return type that `Peekable::peek`
97/// would produce and lets the parser stage use a simple `Option<&Token>`
98/// style API.
99pub struct Lexer<'src> {
100    source: &'src str,
101    inner: logos::Lexer<'src, TokenKind>,
102    prev_end: usize,
103    line: u32,
104    column: u32,
105    peeked: Option<Result<Token, LexError>>,
106}
107
108impl<'src> Lexer<'src> {
109    #[must_use]
110    pub fn new(source: &'src str) -> Self {
111        Self {
112            source,
113            inner: TokenKind::lexer(source),
114            prev_end: 0,
115            line: 1,
116            column: 1,
117            peeked: None,
118        }
119    }
120
121    /// Return a reference to the next token without consuming it. Repeated
122    /// calls return the same token until [`Lexer::next`] is called.
123    pub fn peek(&mut self) -> Option<&Result<Token, LexError>> {
124        if self.peeked.is_none() {
125            self.peeked = self.next_from_inner();
126        }
127        self.peeked.as_ref()
128    }
129
130    /// Walk through `source[start..end]` updating `line`/`column`.
131    fn advance_over(&mut self, start: usize, end: usize) {
132        if start >= end {
133            return;
134        }
135        for ch in self.source[start..end].chars() {
136            if ch == '\n' {
137                self.line += 1;
138                self.column = 1;
139            } else {
140                self.column += 1;
141            }
142        }
143    }
144
145    /// Pull the next token directly from logos, bypassing the peek buffer.
146    ///
147    /// This is the raw implementation used by both [`Lexer::next`] and
148    /// [`Lexer::peek`]; callers that want the buffered behavior should use
149    /// those public entry points instead.
150    fn next_from_inner(&mut self) -> Option<Result<Token, LexError>> {
151        let kind_result = self.inner.next()?;
152        let range = self.inner.span();
153
154        // Whitespace and comments skipped since the previous token.
155        self.advance_over(self.prev_end, range.start);
156
157        // Position recorded on the span is the token's *start*.
158        let token_line = self.line;
159        let token_column = self.column;
160
161        // Walk through the token body so that the next iteration begins at
162        // the correct line/column. This is important for multiline string
163        // literals which may contain embedded newlines.
164        self.advance_over(range.start, range.end);
165        self.prev_end = range.end;
166
167        let span = Span {
168            start: range.start,
169            end: range.end,
170            line: token_line,
171            column: token_column,
172        };
173
174        let Ok(kind) = kind_result else {
175            let raw = span.slice(self.source);
176            let snippet = truncate_to_chars(raw, 40);
177            // A rejected token starting with a digit (or sign/`#` then a digit)
178            // came from the Integer/Real/EntityRef callbacks returning None on an
179            // out-of-range / non-finite literal — classify it as InvalidNumber.
180            let mut cs = raw.chars();
181            let kind = match cs.next() {
182                Some(c) if c.is_ascii_digit() => LexErrorKind::InvalidNumber,
183                Some('#' | '+' | '-') if cs.next().is_some_and(|c| c.is_ascii_digit()) => {
184                    LexErrorKind::InvalidNumber
185                }
186                _ => LexErrorKind::UnexpectedCharacter,
187            };
188            return Some(Err(LexError {
189                kind,
190                span,
191                snippet,
192            }));
193        };
194        Some(Ok(Token { kind, span }))
195    }
196}
197
198impl Iterator for Lexer<'_> {
199    type Item = Result<Token, LexError>;
200
201    fn next(&mut self) -> Option<Self::Item> {
202        if let Some(buffered) = self.peeked.take() {
203            return Some(buffered);
204        }
205        self.next_from_inner()
206    }
207}
208
209/// Truncate `s` to at most `max_chars` Unicode scalar values, never slicing
210/// through a multibyte character.
211fn truncate_to_chars(s: &str, max_chars: usize) -> String {
212    match s.char_indices().nth(max_chars) {
213        Some((byte_idx, _)) => s[..byte_idx].to_string(),
214        None => s.to_string(),
215    }
216}
217
218/// Part 21 token kinds.
219///
220/// Variants cover every lexical construct defined in ISO 10303-21.
221#[derive(Logos, Debug, Clone, PartialEq)]
222#[logos(skip r"[ \t\r\n]+")]
223#[logos(skip r"/\*([^*]|\*+[^*/])*\*+/")]
224pub enum TokenKind {
225    // --- Section keywords (priority 10 protects them from `Keyword`) ---
226    //
227    // Part 21 is case insensitive for keywords, so every section marker is
228    // matched with a case-insensitive regex via the `(?i)` flag. The hyphen
229    // in `ISO-10303-21` / `END-ISO-10303-21` keeps them outside the
230    // `Keyword` regex, which matches `[A-Za-z_][A-Za-z0-9_]*` only.
231    #[regex(r"(?i)ISO-10303-21", priority = 10)]
232    IsoStart,
233
234    #[regex(r"(?i)END-ISO-10303-21", priority = 10)]
235    IsoEnd,
236
237    #[regex(r"(?i)HEADER", priority = 10)]
238    Header,
239
240    #[regex(r"(?i)DATA", priority = 10)]
241    Data,
242
243    #[regex(r"(?i)ENDSEC", priority = 10)]
244    EndSec,
245
246    #[token("(")]
247    LParen,
248
249    #[token(")")]
250    RParen,
251
252    #[token(",")]
253    Comma,
254
255    #[token(";")]
256    Semicolon,
257
258    #[token("=")]
259    Equals,
260
261    /// Derived attribute marker.
262    #[token("*")]
263    Asterisk,
264
265    /// Unset / optional attribute marker.
266    #[token("$")]
267    Dollar,
268
269    /// Real number — must contain a `.`; captures optional sign and exponent.
270    #[regex(
271        r"[+-]?[0-9]+\.[0-9]*([Ee][+-]?[0-9]+)?",
272        // Reject non-finite (e.g. `1.E999` -> inf): the callback returns None, so
273        // the lexer surfaces it as an InvalidNumber error rather than letting inf
274        // into the model.
275        |lex| lex.slice().parse::<f64>().ok().filter(|f| f.is_finite())
276    )]
277    Real(f64),
278
279    /// Integer — no decimal point; may carry a sign.
280    #[regex(
281        r"[+-]?[0-9]+",
282        |lex| lex.slice().parse::<i64>().ok()
283    )]
284    Integer(i64),
285
286    /// String literal enclosed in single quotes. Outer quotes are stripped
287    /// and the `''` escape is decoded to a single `'`. Part 21 wide-char
288    /// escapes (`\X\HH`, `\X2\…\X0\`, `\X4\…\X0\`) remain raw — decoding
289    /// those is deferred to a later stage.
290    #[regex(
291        r"'([^']|'')*'",
292        |lex| {
293            let s = lex.slice();
294            s[1..s.len() - 1].replace("''", "'")
295        }
296    )]
297    String(String),
298
299    /// Entity reference (`#N`). The numeric identifier is stored as `u64`.
300    #[regex(
301        r"#[0-9]+",
302        |lex| lex.slice()[1..].parse::<u64>().ok()
303    )]
304    EntityRef(u64),
305
306    /// Enumeration literal such as `.T.` or `.MILLI.`. The inner name is
307    /// stored without the surrounding dots.
308    #[regex(
309        r"\.[A-Za-z_][A-Za-z0-9_]*\.",
310        |lex| {
311            let s = lex.slice();
312            s[1..s.len() - 1].to_string()
313        }
314    )]
315    Enum(String),
316
317    /// Hex-encoded binary literal, stored without the surrounding quotes.
318    /// The leading hex digit (0–3) encodes the number of unused bits.
319    #[regex(
320        r#""[0-3][0-9A-Fa-f]*""#,
321        |lex| {
322            let s = lex.slice();
323            s[1..s.len() - 1].to_string()
324        }
325    )]
326    Binary(String),
327
328    /// Identifier / entity type name. Does not include `-`, so hyphenated
329    /// section markers (handled above) never match this variant.
330    #[regex(
331        r"[A-Za-z_][A-Za-z0-9_]*",
332        |lex| lex.slice().to_string()
333    )]
334    Keyword(String),
335
336    /// P21 edition 3 anchor reference (`<name>` or `<url#name>`). The
337    /// whole bracketed form is captured as a single token; the parser
338    /// currently discards these (ed.3 sections are skip-only — see
339    /// [`crate::parser::ParseWarning::Ed3SectionDiscarded`]).
340    #[regex(r"<[^>]+>", |lex| lex.slice().to_string())]
341    AnchorRef(String),
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn first_token(src: &str) -> TokenKind {
349        TokenKind::lexer(src)
350            .next()
351            .expect("expected at least one token")
352            .expect("expected Ok token")
353    }
354
355    #[test]
356    fn lex_punctuation() {
357        let mut lex = TokenKind::lexer("(),;=*$");
358        assert_eq!(lex.next(), Some(Ok(TokenKind::LParen)));
359        assert_eq!(lex.next(), Some(Ok(TokenKind::RParen)));
360        assert_eq!(lex.next(), Some(Ok(TokenKind::Comma)));
361        assert_eq!(lex.next(), Some(Ok(TokenKind::Semicolon)));
362        assert_eq!(lex.next(), Some(Ok(TokenKind::Equals)));
363        assert_eq!(lex.next(), Some(Ok(TokenKind::Asterisk)));
364        assert_eq!(lex.next(), Some(Ok(TokenKind::Dollar)));
365        assert_eq!(lex.next(), None);
366    }
367
368    #[test]
369    fn lex_integer_basic() {
370        assert_eq!(first_token("42"), TokenKind::Integer(42));
371    }
372
373    #[test]
374    fn lex_integer_zero() {
375        assert_eq!(first_token("0"), TokenKind::Integer(0));
376    }
377
378    #[test]
379    fn lex_integer_signed() {
380        assert_eq!(first_token("+7"), TokenKind::Integer(7));
381        assert_eq!(first_token("-13"), TokenKind::Integer(-13));
382    }
383
384    #[test]
385    fn lex_real_basic() {
386        assert_eq!(first_token("1.23"), TokenKind::Real(1.23));
387    }
388
389    #[test]
390    fn lex_real_trailing_dot() {
391        // Part 21 allows "1." form (trailing dot, no fractional digits).
392        assert_eq!(first_token("0."), TokenKind::Real(0.0));
393        assert_eq!(first_token("100."), TokenKind::Real(100.0));
394    }
395
396    #[test]
397    fn lex_real_exponent() {
398        assert_eq!(first_token("1.E-07"), TokenKind::Real(1e-7));
399        assert_eq!(first_token("1.23e5"), TokenKind::Real(1.23e5));
400    }
401
402    #[test]
403    fn lex_real_signed_exponent() {
404        assert_eq!(first_token("-9.80E+02"), TokenKind::Real(-9.80e2));
405    }
406
407    #[test]
408    fn lex_real_wins_over_integer() {
409        // Logos should match the longest pattern — "1.23" must not be lexed as Integer(1).
410        let mut lex = TokenKind::lexer("1.23");
411        assert_eq!(lex.next(), Some(Ok(TokenKind::Real(1.23))));
412        assert_eq!(lex.next(), None);
413    }
414
415    #[test]
416    fn lex_integer_when_no_dot() {
417        // A bare "1" has no decimal point, so it is Integer, not Real.
418        let mut lex = TokenKind::lexer("1");
419        assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(1))));
420        assert_eq!(lex.next(), None);
421    }
422
423    #[test]
424    fn lex_string_empty() {
425        assert_eq!(first_token("''"), TokenKind::String(String::new()));
426    }
427
428    #[test]
429    fn lex_string_simple() {
430        assert_eq!(first_token("'abc'"), TokenKind::String("abc".into()));
431    }
432
433    #[test]
434    fn lex_string_escaped_quote() {
435        // Part 21 uses `''` to embed a single quote; the lexer decodes it.
436        assert_eq!(first_token("'a''b'"), TokenKind::String("a'b".into()));
437    }
438
439    #[test]
440    fn lex_string_only_escaped_quote() {
441        // `''''` is a string containing a single `'` (one escape sequence).
442        assert_eq!(first_token("''''"), TokenKind::String("'".into()));
443    }
444
445    #[test]
446    fn lex_string_with_newline() {
447        // Newlines inside string literals are permitted by Part 21.
448        assert_eq!(
449            first_token("'line1\nline2'"),
450            TokenKind::String("line1\nline2".into())
451        );
452    }
453
454    #[test]
455    fn lex_string_multibyte_korean() {
456        // Logos is built on regex-syntax which treats `[^']` as "any UTF-8
457        // codepoint other than `'`", so multibyte characters pass through.
458        assert_eq!(first_token("'한글'"), TokenKind::String("한글".into()));
459    }
460
461    #[test]
462    fn lex_string_multibyte_japanese() {
463        assert_eq!(first_token("'日本語'"), TokenKind::String("日本語".into()));
464    }
465
466    #[test]
467    fn lex_anchor_ref_simple() {
468        assert_eq!(
469            first_token("<TestAnchor>"),
470            TokenKind::AnchorRef("<TestAnchor>".into())
471        );
472    }
473
474    #[test]
475    fn lex_anchor_ref_with_url() {
476        assert_eq!(
477            first_token("<testAnchorAndData.stp#TestAnchor>"),
478            TokenKind::AnchorRef("<testAnchorAndData.stp#TestAnchor>".into())
479        );
480    }
481
482    #[test]
483    fn lex_entity_ref_small() {
484        assert_eq!(first_token("#1"), TokenKind::EntityRef(1));
485    }
486
487    #[test]
488    fn lex_entity_ref_large() {
489        assert_eq!(first_token("#1234567"), TokenKind::EntityRef(1_234_567));
490    }
491
492    #[test]
493    fn lex_enum_bool_true() {
494        assert_eq!(first_token(".T."), TokenKind::Enum("T".into()));
495    }
496
497    #[test]
498    fn lex_enum_unit() {
499        assert_eq!(first_token(".MILLI."), TokenKind::Enum("MILLI".into()));
500    }
501
502    #[test]
503    fn lex_binary_zero() {
504        assert_eq!(first_token("\"0\""), TokenKind::Binary("0".into()));
505    }
506
507    #[test]
508    fn lex_binary_hex() {
509        assert_eq!(first_token("\"3FFA\""), TokenKind::Binary("3FFA".into()));
510    }
511
512    #[test]
513    fn lex_keyword_simple() {
514        assert_eq!(
515            first_token("CARTESIAN_POINT"),
516            TokenKind::Keyword("CARTESIAN_POINT".into())
517        );
518    }
519
520    #[test]
521    fn lex_keyword_leading_underscore() {
522        assert_eq!(first_token("_x1"), TokenKind::Keyword("_x1".into()));
523    }
524
525    #[test]
526    fn lex_keyword_mixed_case() {
527        // Logos preserves the original casing in the captured slice.
528        assert_eq!(first_token("PlAnE"), TokenKind::Keyword("PlAnE".into()));
529    }
530
531    #[test]
532    fn lex_section_iso_start_upper() {
533        assert_eq!(first_token("ISO-10303-21"), TokenKind::IsoStart);
534    }
535
536    #[test]
537    fn lex_section_iso_start_lower() {
538        // Part 21 is case insensitive; hyphenated markers are matched via (?i).
539        assert_eq!(first_token("iso-10303-21"), TokenKind::IsoStart);
540    }
541
542    #[test]
543    fn lex_section_iso_end() {
544        assert_eq!(first_token("END-ISO-10303-21"), TokenKind::IsoEnd);
545    }
546
547    #[test]
548    fn lex_section_header_data_endsec() {
549        assert_eq!(first_token("HEADER"), TokenKind::Header);
550        assert_eq!(first_token("DATA"), TokenKind::Data);
551        assert_eq!(first_token("ENDSEC"), TokenKind::EndSec);
552    }
553
554    #[test]
555    fn lex_section_case_insensitive() {
556        assert_eq!(first_token("header"), TokenKind::Header);
557        assert_eq!(first_token("Data"), TokenKind::Data);
558        assert_eq!(first_token("EndSec"), TokenKind::EndSec);
559    }
560
561    #[test]
562    fn section_keyword_priority_wins_over_keyword() {
563        // "HEADER" must lex as the Header token, not as Keyword("HEADER"),
564        // because the section marker has a higher declared priority.
565        assert_ne!(first_token("HEADER"), TokenKind::Keyword("HEADER".into()));
566    }
567
568    #[test]
569    fn lex_whitespace_skipped_between_tokens() {
570        let mut lex = TokenKind::lexer("HEADER  ;\n\tDATA");
571        assert_eq!(lex.next(), Some(Ok(TokenKind::Header)));
572        assert_eq!(lex.next(), Some(Ok(TokenKind::Semicolon)));
573        assert_eq!(lex.next(), Some(Ok(TokenKind::Data)));
574        assert_eq!(lex.next(), None);
575    }
576
577    #[test]
578    fn lex_comment_skipped_between_tokens() {
579        let mut lex = TokenKind::lexer("1 /* ignored */ 2");
580        assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(1))));
581        assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(2))));
582        assert_eq!(lex.next(), None);
583    }
584
585    #[test]
586    fn lex_multiline_comment_skipped() {
587        let mut lex = TokenKind::lexer("1/* line1\nline2 */2");
588        assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(1))));
589        assert_eq!(lex.next(), Some(Ok(TokenKind::Integer(2))));
590        assert_eq!(lex.next(), None);
591    }
592
593    #[test]
594    fn lex_minimal_entity_line() {
595        // A realistic Part 21 line: `#1=LINE('',#2,#3);`
596        let mut lex = TokenKind::lexer("#1=LINE('',#2,#3);");
597        assert_eq!(lex.next(), Some(Ok(TokenKind::EntityRef(1))));
598        assert_eq!(lex.next(), Some(Ok(TokenKind::Equals)));
599        assert_eq!(lex.next(), Some(Ok(TokenKind::Keyword("LINE".into()))));
600        assert_eq!(lex.next(), Some(Ok(TokenKind::LParen)));
601        assert_eq!(lex.next(), Some(Ok(TokenKind::String(String::new()))));
602        assert_eq!(lex.next(), Some(Ok(TokenKind::Comma)));
603        assert_eq!(lex.next(), Some(Ok(TokenKind::EntityRef(2))));
604        assert_eq!(lex.next(), Some(Ok(TokenKind::Comma)));
605        assert_eq!(lex.next(), Some(Ok(TokenKind::EntityRef(3))));
606        assert_eq!(lex.next(), Some(Ok(TokenKind::RParen)));
607        assert_eq!(lex.next(), Some(Ok(TokenKind::Semicolon)));
608        assert_eq!(lex.next(), None);
609    }
610
611    // --- Lexer wrapper (Span / line / column tracking) ---
612
613    fn collect(src: &str) -> Vec<Token> {
614        Lexer::new(src)
615            .collect::<Result<Vec<_>, _>>()
616            .expect("expected all tokens to lex successfully")
617    }
618
619    #[test]
620    fn span_tracks_single_token() {
621        let tokens = collect("HEADER");
622        assert_eq!(tokens.len(), 1);
623        assert_eq!(tokens[0].kind, TokenKind::Header);
624        assert_eq!(tokens[0].span.start, 0);
625        assert_eq!(tokens[0].span.end, 6);
626        assert_eq!(tokens[0].span.line, 1);
627        assert_eq!(tokens[0].span.column, 1);
628    }
629
630    #[test]
631    fn span_tracks_columns_on_same_line() {
632        // "a b" → Keyword("a") col 1, Keyword("b") col 3.
633        let tokens = collect("a b");
634        assert_eq!(tokens[0].span.column, 1);
635        assert_eq!(tokens[1].span.column, 3);
636        assert_eq!(tokens[0].span.line, 1);
637        assert_eq!(tokens[1].span.line, 1);
638    }
639
640    #[test]
641    fn span_tracks_line_after_newline() {
642        // "a\nb" → second token starts at line 2, column 1.
643        let tokens = collect("a\nb");
644        assert_eq!(tokens[0].span.line, 1);
645        assert_eq!(tokens[0].span.column, 1);
646        assert_eq!(tokens[1].span.line, 2);
647        assert_eq!(tokens[1].span.column, 1);
648    }
649
650    #[test]
651    fn span_tracks_multiple_newlines() {
652        let tokens = collect("a\n\n\nb");
653        assert_eq!(tokens[1].span.line, 4);
654        assert_eq!(tokens[1].span.column, 1);
655    }
656
657    #[test]
658    fn span_advances_past_crlf() {
659        // Windows-style line endings should advance lines by one per `\n`.
660        let tokens = collect("a\r\nb");
661        assert_eq!(tokens[1].span.line, 2);
662        assert_eq!(tokens[1].span.column, 1);
663    }
664
665    #[test]
666    fn span_tracks_column_after_multibyte_char() {
667        // '한글' is one string token; the next token should see column 5
668        // ('한글' = 2 chars + 2 quotes = 4 chars, next char at column 5).
669        let tokens = collect("'한글' a");
670        assert_eq!(tokens.len(), 2);
671        assert!(matches!(tokens[0].kind, TokenKind::String(_)));
672        assert_eq!(tokens[1].span.column, 6); // 4 chars + 1 space → col 6
673        assert_eq!(tokens[1].span.line, 1);
674    }
675
676    #[test]
677    fn span_advances_through_multiline_string() {
678        // Newlines inside a string literal should still bump the line counter
679        // so that the next token's line/column are correct.
680        let tokens = collect("'line1\nline2' x");
681        assert!(matches!(tokens[0].kind, TokenKind::String(_)));
682        // After the string the cursor is at line 2. The `x` follows a space,
683        // so column is 8 on line 2 (|line2'| = 6 chars + space + `x` at 8).
684        assert_eq!(tokens[1].span.line, 2);
685        assert_eq!(tokens[1].span.column, 8);
686    }
687
688    #[test]
689    fn span_slice_roundtrip_matches_source() {
690        let source = "#1=LINE('',#2,#3);";
691        let tokens = collect(source);
692        for tok in &tokens {
693            let slice = tok.span.slice(source);
694            // Slices must line up with byte offsets and decode as valid UTF-8.
695            assert_eq!(&source[tok.span.start..tok.span.end], slice);
696        }
697    }
698
699    // --- peek() ---
700
701    #[test]
702    fn peek_returns_same_token_twice() {
703        let mut lex = Lexer::new("HEADER ; DATA");
704        let first = lex.peek().cloned();
705        let second = lex.peek().cloned();
706        assert_eq!(first, second);
707        assert!(matches!(
708            first,
709            Some(Ok(Token {
710                kind: TokenKind::Header,
711                ..
712            }))
713        ));
714    }
715
716    #[test]
717    fn peek_then_next_returns_buffered_token() {
718        let mut lex = Lexer::new("HEADER ; DATA");
719        let peeked = lex.peek().cloned();
720        let next = lex.next();
721        assert_eq!(peeked, next);
722    }
723
724    #[test]
725    fn peek_does_not_consume_token() {
726        let mut lex = Lexer::new("HEADER ; DATA");
727        let _ = lex.peek();
728        // next() should yield the same token that was peeked, and then a
729        // subsequent next() should yield the *following* token.
730        assert!(matches!(
731            lex.next(),
732            Some(Ok(Token {
733                kind: TokenKind::Header,
734                ..
735            }))
736        ));
737        assert!(matches!(
738            lex.next(),
739            Some(Ok(Token {
740                kind: TokenKind::Semicolon,
741                ..
742            }))
743        ));
744        assert!(matches!(
745            lex.next(),
746            Some(Ok(Token {
747                kind: TokenKind::Data,
748                ..
749            }))
750        ));
751        assert_eq!(lex.next(), None);
752    }
753
754    #[test]
755    fn peek_at_end_returns_none() {
756        let mut lex = Lexer::new("HEADER");
757        let _ = lex.next();
758        assert!(lex.peek().is_none());
759        assert!(lex.next().is_none());
760    }
761
762    // --- tokenize() + LexError ---
763
764    #[test]
765    fn tokenize_returns_vec_of_tokens() {
766        let toks = tokenize("HEADER ; ENDSEC ;").expect("should lex cleanly");
767        let kinds: Vec<_> = toks.iter().map(|t| t.kind.clone()).collect();
768        assert_eq!(
769            kinds,
770            vec![
771                TokenKind::Header,
772                TokenKind::Semicolon,
773                TokenKind::EndSec,
774                TokenKind::Semicolon,
775            ]
776        );
777    }
778
779    #[test]
780    fn tokenize_reports_unexpected_character() {
781        let err = tokenize("#1 = @INVALID").expect_err("expected a lex error");
782        assert_eq!(err.kind, LexErrorKind::UnexpectedCharacter);
783        assert!(err.snippet.contains('@'));
784        assert_eq!(err.span.line, 1);
785    }
786
787    #[test]
788    fn tokenize_reports_unterminated_string_as_error() {
789        // A lone `'` with no closing quote should fail to lex.
790        let err = tokenize("'abc").expect_err("unterminated string must error");
791        // In this stage logos maps the failed match to UnexpectedCharacter.
792        // A later stage may refine this to UnterminatedString.
793        assert!(matches!(
794            err.kind,
795            LexErrorKind::UnexpectedCharacter | LexErrorKind::UnterminatedString
796        ));
797    }
798
799    #[test]
800    fn lex_error_display_has_line_column_and_snippet() {
801        let err = tokenize("\n  @").expect_err("expected a lex error");
802        let msg = err.to_string();
803        assert!(msg.contains("line 2"));
804        assert!(msg.contains("column 3"));
805        assert!(msg.contains("unexpected character"));
806        assert!(msg.contains('@'));
807    }
808
809    #[test]
810    fn lex_error_implements_std_error() {
811        fn assert_error<E: std::error::Error>(_: &E) {}
812        let err = tokenize("@").expect_err("expected a lex error");
813        assert_error(&err);
814    }
815}