Skip to main content

ridl_syntax/
lexer.rs

1//! The family lexer (typl reference §1.4 and §2).
2//!
3//! One lexer serves the whole RIDL family. It recognises the shared token set —
4//! the reserved-word registry (§1.4), identifiers, numeric, string, regex and
5//! duration literals, punctuation, and the comment forms — and it is total:
6//! concatenating the text of every returned token reproduces the input byte for
7//! byte, so a rowan tree built from these tokens round-trips (ADR-0004 §2).
8//!
9//! `logos` lexes the raw stream; two source-driven refinements sit on top,
10//! because they depend on more than a single token's bytes:
11//!
12//! - **Regex literals** (§2.7, §5.3). A `/` whose previous significant token is
13//!   `=` (an init or const value) or the `match` keyword (a string constraint)
14//!   begins a regex literal. It is scanned from the source directly and the raw
15//!   stream inside it is discarded by re-lexing from the literal's end, so the
16//!   way `logos` happened to tokenise the pattern bytes does not matter.
17//! - **Duration literals** (§2.8). A number immediately followed by a UCUM time
18//!   atom (`us`, `ms`, `s`, `min`, `h`) is merged into one `Duration` token. The
19//!   typl profile rejects durations later (TYPL-302); the family lexer still
20//!   recognises them, because timing belongs to the other profiles.
21//!
22//! Identifiers are mapped against the family registry ([`crate::keywords`])
23//! after lexing: a used keyword becomes its keyword kind, any other registry
24//! word becomes [`SyntaxKind::ReservedWord`], everything else stays
25//! [`SyntaxKind::Ident`].
26
27use logos::Logos;
28
29use crate::keywords::{self, Profile};
30use crate::syntax_kind::SyntaxKind;
31
32/// A lexed token: its [`SyntaxKind`] and the exact source slice it covers.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Token<'a> {
35    pub kind: SyntaxKind,
36    pub text: &'a str,
37}
38
39/// The `logos`-derived token set, private to this module. [`lex`] maps each
40/// variant into [`SyntaxKind`] — the family lexer's stable public interface —
41/// and applies the regex, duration, and keyword refinements on top.
42#[derive(Logos, Debug, Clone, Copy, PartialEq, Eq)]
43enum RawToken {
44    // Identifiers: start with a letter, digits permitted after the first
45    // character, underscore only inside SCREAMING_SNAKE names (typl reference
46    // §2.3). One token kind covers CamelCase, camelCase, and SCREAMING_SNAKE;
47    // keyword mapping (typl reference §1.4) happens after lexing.
48    #[regex("[A-Za-z][A-Za-z0-9_]*")]
49    Ident,
50
51    // Floats must contain a decimal point (typl reference §2.5); no scientific
52    // notation. The longer match (digits '.' digits) wins over the bare-digits
53    // `IntNumber` pattern, and `0.0..250.0` lexes as float–dotdot–float because
54    // the pattern needs a digit after the dot.
55    #[regex(r"[0-9]+\.[0-9]+")]
56    FloatNumber,
57
58    // Integers: decimal digits (typl reference §2.4). Leading zeros lex as one
59    // token here; the parser flags them (FORM-005). A negative literal is a
60    // separate `Minus` token followed by digits — the parser combines them.
61    #[regex("[0-9]+")]
62    IntNumber,
63
64    // String literals (typl reference §2.6): RFC 8259 escapes, no raw newline.
65    // The callback scans to the closing quote; an unterminated string is an
66    // error token to end of line.
67    #[token("\"", lex_string)]
68    Str,
69
70    // Doc comments (typl reference §14): `///` line form and `/** ... */` block
71    // form. The line form is the longer token, so it outranks the ordinary `//`
72    // line comment. The callback scans to the end of the line.
73    #[token("///", lex_line_comment)]
74    DocCommentLine,
75
76    // Line comment `//` to end of line (typl reference §13).
77    #[token("//", lex_line_comment)]
78    LineComment,
79
80    // Block comment `/* ... */`, non-nesting (typl reference §13). The callback
81    // reports whether it is a doc comment (`/**`) and stops at the first `*/`.
82    #[token("/*", lex_block_comment)]
83    BlockComment(bool),
84
85    #[token(":")]
86    Colon,
87    #[token("=")]
88    Eq,
89    #[token("[")]
90    LBracket,
91    #[token("]")]
92    RBracket,
93    #[token("{")]
94    LBrace,
95    #[token("}")]
96    RBrace,
97    #[token("(")]
98    LParen,
99    #[token(")")]
100    RParen,
101    #[token("..")]
102    DotDot,
103    #[token(".")]
104    Dot,
105    #[token("/")]
106    Slash,
107    #[token(",")]
108    Comma,
109    #[token("?")]
110    Question,
111    #[token(";")]
112    Semicolon,
113    #[token("@")]
114    At,
115    #[token("|")]
116    Pipe,
117    #[token("%")]
118    Percent,
119    #[token("-")]
120    Minus,
121
122    // Expression operators (ridl reference §13, expr core). Family tokens
123    // like durations: recognised under every profile, rejected by the typl
124    // grammar. `logos` picks the longest match, so `<=` outranks `<`, `==`
125    // outranks two `=`, and `||` outranks two `|`.
126    #[token("<")]
127    Lt,
128    #[token("<=")]
129    Le,
130    #[token(">")]
131    Gt,
132    #[token(">=")]
133    Ge,
134    #[token("==")]
135    EqEq,
136    #[token("!=")]
137    Neq,
138    #[token("&&")]
139    AmpAmp,
140    #[token("||")]
141    PipePipe,
142    #[token("!")]
143    Bang,
144    #[token("+")]
145    Plus,
146    #[token("*")]
147    Star,
148
149    #[regex(r"[ \t\r\n]+")]
150    Whitespace,
151}
152
153impl From<RawToken> for SyntaxKind {
154    fn from(token: RawToken) -> Self {
155        match token {
156            RawToken::Ident => SyntaxKind::Ident,
157            RawToken::FloatNumber => SyntaxKind::FloatNumber,
158            RawToken::IntNumber => SyntaxKind::IntNumber,
159            RawToken::Str => SyntaxKind::String,
160            RawToken::DocCommentLine => SyntaxKind::DocComment,
161            RawToken::LineComment => SyntaxKind::LineComment,
162            RawToken::BlockComment(is_doc) => {
163                if is_doc {
164                    SyntaxKind::DocComment
165                } else {
166                    SyntaxKind::BlockComment
167                }
168            }
169            RawToken::Colon => SyntaxKind::Colon,
170            RawToken::Eq => SyntaxKind::Eq,
171            RawToken::LBracket => SyntaxKind::LBracket,
172            RawToken::RBracket => SyntaxKind::RBracket,
173            RawToken::LBrace => SyntaxKind::LBrace,
174            RawToken::RBrace => SyntaxKind::RBrace,
175            RawToken::LParen => SyntaxKind::LParen,
176            RawToken::RParen => SyntaxKind::RParen,
177            RawToken::DotDot => SyntaxKind::DotDot,
178            RawToken::Dot => SyntaxKind::Dot,
179            RawToken::Slash => SyntaxKind::Slash,
180            RawToken::Comma => SyntaxKind::Comma,
181            RawToken::Question => SyntaxKind::Question,
182            RawToken::Semicolon => SyntaxKind::Semicolon,
183            RawToken::At => SyntaxKind::At,
184            RawToken::Pipe => SyntaxKind::Pipe,
185            RawToken::Percent => SyntaxKind::Percent,
186            RawToken::Minus => SyntaxKind::Minus,
187            RawToken::Lt => SyntaxKind::Lt,
188            RawToken::Le => SyntaxKind::Le,
189            RawToken::Gt => SyntaxKind::Gt,
190            RawToken::Ge => SyntaxKind::Ge,
191            RawToken::EqEq => SyntaxKind::EqEq,
192            RawToken::Neq => SyntaxKind::Neq,
193            RawToken::AmpAmp => SyntaxKind::AmpAmp,
194            RawToken::PipePipe => SyntaxKind::PipePipe,
195            RawToken::Bang => SyntaxKind::Bang,
196            RawToken::Plus => SyntaxKind::Plus,
197            RawToken::Star => SyntaxKind::Star,
198            RawToken::Whitespace => SyntaxKind::Whitespace,
199        }
200    }
201}
202
203/// A raw token with its byte span, before duration and keyword refinement.
204struct Raw {
205    kind: SyntaxKind,
206    start: usize,
207    end: usize,
208}
209
210/// Lexes `input` into a flat token stream under `profile`.
211///
212/// Total: concatenating `token.text` for every returned token reproduces
213/// `input` byte for byte. Input the family lexer does not recognise becomes
214/// [`SyntaxKind::Error`] tokens rather than aborting the lex.
215///
216/// The profile decides only which registry words are active keywords: under
217/// [`Profile::Typl`] the stream is identical to the E1 lexer's; under
218/// [`Profile::Ridl`] the nine ridl words lex to their keyword variants; under
219/// [`Profile::Rsdl`] the eight rsdl words do.
220/// Everything else — durations, `@`, the expr operators — is a family token
221/// in both profiles; the parser draws the profile boundaries.
222pub fn lex(input: &str, profile: Profile) -> Vec<Token<'_>> {
223    let raws = raw_tokens(input);
224    refine(input, &raws, profile)
225}
226
227/// Runs `logos` and lifts regex literals out of the raw stream (typl reference
228/// §2.7). On a regex literal the scan is driven from the source and lexing
229/// resumes from the literal's end, so the pattern bytes are never mis-tokenised
230/// as slashes, identifiers, or a stray line comment.
231fn raw_tokens(input: &str) -> Vec<Raw> {
232    let mut out: Vec<Raw> = Vec::new();
233    // The previous significant (non-trivia) token: its kind and its source
234    // text. The text is needed to spot the `match` keyword, which is still a
235    // raw `Ident` at this stage — keyword mapping happens after the regex lift.
236    let mut prev_kind: Option<SyntaxKind> = None;
237    let mut prev_text: &str = "";
238    let mut base = 0usize;
239    'relex: loop {
240        let mut lexer = RawToken::lexer(&input[base..]);
241        while let Some(result) = lexer.next() {
242            let span = lexer.span();
243            let start = base + span.start;
244            let end = base + span.end;
245            let kind = result.map_or(SyntaxKind::Error, SyntaxKind::from);
246
247            if kind == SyntaxKind::Slash && starts_regex(prev_kind, prev_text) {
248                let (regex_kind, regex_end) = scan_regex(input, start);
249                out.push(Raw {
250                    kind: regex_kind,
251                    start,
252                    end: regex_end,
253                });
254                prev_kind = Some(regex_kind);
255                prev_text = &input[start..regex_end];
256                base = regex_end;
257                continue 'relex;
258            }
259
260            out.push(Raw { kind, start, end });
261            if !kind.is_trivia() {
262                prev_kind = Some(kind);
263                prev_text = &input[start..end];
264            }
265        }
266        break;
267    }
268    out
269}
270
271/// Whether a `/` following this previous significant token begins a regex
272/// literal (typl reference §2.7, §5.3): after `=` — an init or const value — or
273/// the `match` keyword — a string constraint. `match` is still a raw `Ident` at
274/// lexing time, so it is matched by its source text; it is a reserved word, so
275/// the text is unambiguous, and typl has no division operator, so neither `= /`
276/// nor `match /` can be anything but a regex.
277fn starts_regex(prev_kind: Option<SyntaxKind>, prev_text: &str) -> bool {
278    prev_kind == Some(SyntaxKind::Eq)
279        || (prev_kind == Some(SyntaxKind::Ident) && prev_text == "match")
280}
281
282/// Scans a regex literal that begins at the `/` at byte `start`. Returns
283/// [`SyntaxKind::Regex`] and the byte just past the closing `/`, or
284/// [`SyntaxKind::Error`] and the end of the line when the literal does not
285/// terminate on its own line (typl reference §2.7 — a regex cannot contain a
286/// raw newline).
287fn scan_regex(input: &str, start: usize) -> (SyntaxKind, usize) {
288    let bytes = input.as_bytes();
289    let mut i = start + 1; // past the opening '/'
290    while i < bytes.len() {
291        match bytes[i] {
292            b'\\' => {
293                if i + 1 >= bytes.len() {
294                    return (SyntaxKind::Error, bytes.len());
295                }
296                if is_line_break(bytes, i + 1) {
297                    return (SyntaxKind::Error, i + 1);
298                }
299                i += 2;
300            }
301            b'/' => return (SyntaxKind::Regex, i + 1),
302            _ if is_line_break(bytes, i) => return (SyntaxKind::Error, i),
303            _ => i += 1,
304        }
305    }
306    (SyntaxKind::Error, bytes.len())
307}
308
309/// Merges duration literals and maps identifiers against the family registry:
310/// a keyword `profile` uses becomes its keyword kind, any other registry word
311/// becomes [`SyntaxKind::ReservedWord`], everything else stays an `Ident`.
312fn refine<'a>(input: &'a str, raws: &[Raw], profile: Profile) -> Vec<Token<'a>> {
313    let mut out = Vec::with_capacity(raws.len());
314    let mut i = 0;
315    while i < raws.len() {
316        let raw = &raws[i];
317
318        // Duration: a number immediately followed by a UCUM time atom (typl
319        // reference §2.8). "Immediately" means the very next token — whitespace
320        // would appear as a separate trivia token in between.
321        if matches!(raw.kind, SyntaxKind::IntNumber | SyntaxKind::FloatNumber)
322            && let Some(next) = raws.get(i + 1)
323            && next.kind == SyntaxKind::Ident
324            && is_time_atom(&input[next.start..next.end])
325        {
326            out.push(Token {
327                kind: SyntaxKind::Duration,
328                text: &input[raw.start..next.end],
329            });
330            i += 2;
331            continue;
332        }
333
334        let text = &input[raw.start..raw.end];
335        let kind = if raw.kind == SyntaxKind::Ident {
336            keywords::keyword_in(profile, text).unwrap_or(if keywords::is_reserved(text) {
337                SyntaxKind::ReservedWord
338            } else {
339                SyntaxKind::Ident
340            })
341        } else {
342            raw.kind
343        };
344        out.push(Token { kind, text });
345        i += 1;
346    }
347    out
348}
349
350/// The UCUM time atoms a duration literal may use (typl reference §2.8).
351fn is_time_atom(text: &str) -> bool {
352    matches!(text, "us" | "ms" | "s" | "min" | "h")
353}
354
355/// Scans a string literal after the opening `"` (typl reference §2.6). Returns
356/// `Ok` on the closing quote; an unterminated string (a raw newline or the end
357/// of input before the close) consumes to end of line and becomes an error
358/// token.
359fn lex_string(lex: &mut logos::Lexer<RawToken>) -> Result<(), ()> {
360    let bytes = lex.remainder().as_bytes();
361    let mut i = 0;
362    while i < bytes.len() {
363        match bytes[i] {
364            _ if is_line_break(bytes, i) => {
365                lex.bump(i);
366                return Err(());
367            }
368            b'\\' => {
369                if i + 1 >= bytes.len() || is_line_break(bytes, i + 1) {
370                    // Dangling backslash at end of line or input: unterminated.
371                    lex.bump(i + 1);
372                    return Err(());
373                }
374                i += 2;
375            }
376            b'"' => {
377                lex.bump(i + 1);
378                return Ok(());
379            }
380            _ => i += 1,
381        }
382    }
383    lex.bump(bytes.len());
384    Err(())
385}
386
387/// Whether the byte at `i` ends a line (typl reference §2.2): an LF, or a CR
388/// that is not followed by an LF. In a CRLF pair the LF ends the line, so the
389/// CR stays part of the text before it.
390fn is_line_break(bytes: &[u8], i: usize) -> bool {
391    match bytes[i] {
392        b'\n' => true,
393        b'\r' => bytes.get(i + 1) != Some(&b'\n'),
394        _ => false,
395    }
396}
397
398/// Scans a line comment or a doc comment line after its opening slashes
399/// (typl reference §13, §14) to the end of the line, which it does not
400/// include.
401fn lex_line_comment(lex: &mut logos::Lexer<RawToken>) {
402    let bytes = lex.remainder().as_bytes();
403    let end = (0..bytes.len())
404        .find(|&i| is_line_break(bytes, i))
405        .unwrap_or(bytes.len());
406    lex.bump(end);
407}
408
409/// Scans a block comment after the opening `/*` (typl reference §13). Non-
410/// nesting: stops at the first `*/`. Returns whether it is a doc comment
411/// (`/** ... */`, typl reference §14); an unterminated block comment consumes
412/// the rest of the input and becomes an error token.
413fn lex_block_comment(lex: &mut logos::Lexer<RawToken>) -> Result<bool, ()> {
414    let rest = lex.remainder();
415    // A doc comment starts `/**` but the empty comment `/**/` does not.
416    let is_doc = rest.starts_with('*') && !rest.starts_with("*/");
417    match rest.find("*/") {
418        Some(idx) => {
419            lex.bump(idx + 2);
420            Ok(is_doc)
421        }
422        None => {
423            lex.bump(rest.len());
424            Err(())
425        }
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn concat(tokens: &[Token<'_>]) -> String {
434        tokens.iter().map(|t| t.text).collect()
435    }
436
437    /// The kinds of the significant (non-trivia) tokens under `profile`.
438    fn kinds_in(profile: Profile, input: &str) -> Vec<SyntaxKind> {
439        lex(input, profile)
440            .iter()
441            .filter(|t| !t.kind.is_trivia())
442            .map(|t| t.kind)
443            .collect()
444    }
445
446    // The nine ridl words lex to their keyword variants under `Profile::Ridl`
447    // and stay `ReservedWord` under `Profile::Typl`.
448    #[test]
449    fn ridl_words_lex_by_profile() {
450        for (word, kind) in [
451            ("interface", SyntaxKind::InterfaceKw),
452            ("service", SyntaxKind::ServiceKw),
453            ("signal", SyntaxKind::SignalKw),
454            ("event", SyntaxKind::EventKw),
455            ("command", SyntaxKind::CommandKw),
456            ("query", SyntaxKind::QueryKw),
457            ("fixed", SyntaxKind::FixedKw),
458            ("require", SyntaxKind::RequireKw),
459            ("ensure", SyntaxKind::EnsureKw),
460        ] {
461            assert_eq!(
462                kinds_in(Profile::Ridl, word),
463                vec![kind],
464                "`{word}` must lex to its keyword variant under Ridl",
465            );
466            assert_eq!(
467                kinds_in(Profile::Typl, word),
468                vec![SyntaxKind::ReservedWord],
469                "`{word}` must stay ReservedWord under Typl",
470            );
471        }
472    }
473
474    #[test]
475    fn other_profile_words_stay_reserved_in_both_profiles() {
476        for word in ["states", "model", "component", "automaton"] {
477            for profile in [Profile::Typl, Profile::Ridl] {
478                assert_eq!(
479                    kinds_in(profile, word),
480                    vec![SyntaxKind::ReservedWord],
481                    "`{word}` must stay ReservedWord under {profile:?}",
482                );
483            }
484        }
485    }
486
487    /// The rsdl v0.2 keyword line (rsdl reference §2), paired with the token
488    /// kind each word lexes to under `Profile::Rsdl`.
489    const RSDL_WORDS: [(&str, SyntaxKind); 8] = [
490        ("system", SyntaxKind::SystemKw),
491        ("component", SyntaxKind::ComponentKw),
492        ("distribution", SyntaxKind::DistributionKw),
493        ("deployment", SyntaxKind::DeploymentKw),
494        ("machine", SyntaxKind::MachineKw),
495        ("offers", SyntaxKind::OffersKw),
496        ("requires", SyntaxKind::RequiresKw),
497        ("for", SyntaxKind::ForKw),
498    ];
499
500    /// The rsdl v0.1 words the v0.2 reference retires (rsdl reference §2). No
501    /// other profile uses one, so each leaves the registry.
502    const RETIRED_RSDL_WORDS: [&str; 13] = [
503        "provides",
504        "instance",
505        "assurance",
506        "target",
507        "place",
508        "on",
509        "transport",
510        "bundle",
511        "time",
512        "base",
513        "redundant",
514        "supervise",
515        "degraded",
516    ];
517
518    // Every word of the rsdl keyword line is reserved in every profile: an
519    // active keyword under `Profile::Rsdl`, a `ReservedWord` under the other
520    // two. `offers`, `distribution` and `machine` are the three the registry
521    // gained with rsdl v0.2.
522    #[test]
523    fn rsdl_words_lex_by_profile() {
524        for (word, kind) in RSDL_WORDS {
525            assert_eq!(
526                kinds_in(Profile::Rsdl, word),
527                vec![kind],
528                "`{word}` must lex to its keyword variant under Rsdl",
529            );
530            for profile in [Profile::Typl, Profile::Ridl] {
531                assert_eq!(
532                    kinds_in(profile, word),
533                    vec![SyntaxKind::ReservedWord],
534                    "`{word}` must stay ReservedWord under {profile:?}",
535                );
536            }
537        }
538    }
539
540    // A retired rsdl word is an ordinary identifier in every profile.
541    #[test]
542    fn retired_rsdl_words_are_identifiers_in_every_profile() {
543        for word in RETIRED_RSDL_WORDS {
544            for profile in [Profile::Typl, Profile::Ridl, Profile::Rsdl] {
545                assert_eq!(
546                    kinds_in(profile, word),
547                    vec![SyntaxKind::Ident],
548                    "`{word}` must lex to Ident under {profile:?}",
549                );
550            }
551        }
552    }
553
554    // The typl words stay active under `Profile::Rsdl`, and a ridl word is a
555    // reserved word there.
556    #[test]
557    fn rsdl_profile_keeps_the_typl_keywords_and_reserves_the_ridl_words() {
558        assert_eq!(
559            kinds_in(Profile::Rsdl, "package"),
560            vec![SyntaxKind::PackageKw]
561        );
562        assert_eq!(
563            kinds_in(Profile::Rsdl, "internal"),
564            vec![SyntaxKind::InternalKw]
565        );
566        assert_eq!(kinds_in(Profile::Rsdl, "true"), vec![SyntaxKind::TrueKw]);
567        assert_eq!(
568            kinds_in(Profile::Rsdl, "service"),
569            vec![SyntaxKind::ReservedWord]
570        );
571        assert_eq!(
572            kinds_in(Profile::Rsdl, "require"),
573            vec![SyntaxKind::ReservedWord]
574        );
575    }
576
577    // Durations and `@` are ordinary tokens under Ridl (the parser draws no
578    // TYPL-302 there — see the parser tests).
579    #[test]
580    fn durations_and_at_lex_clean_under_ridl() {
581        assert_eq!(kinds_in(Profile::Ridl, "10ms"), vec![SyntaxKind::Duration]);
582        assert_eq!(kinds_in(Profile::Ridl, "@"), vec![SyntaxKind::At]);
583    }
584
585    // The expr operator tokens.
586    #[test]
587    fn expr_operators_lex_under_both_profiles() {
588        for profile in [Profile::Typl, Profile::Ridl] {
589            assert_eq!(
590                kinds_in(profile, "a >= b && !c"),
591                vec![
592                    SyntaxKind::Ident,
593                    SyntaxKind::Ge,
594                    SyntaxKind::Ident,
595                    SyntaxKind::AmpAmp,
596                    SyntaxKind::Bang,
597                    SyntaxKind::Ident,
598                ],
599            );
600        }
601    }
602
603    #[test]
604    fn every_expr_operator_token_lexes() {
605        assert_eq!(
606            kinds_in(Profile::Ridl, "< <= > >= == != && || ! + *"),
607            vec![
608                SyntaxKind::Lt,
609                SyntaxKind::Le,
610                SyntaxKind::Gt,
611                SyntaxKind::Ge,
612                SyntaxKind::EqEq,
613                SyntaxKind::Neq,
614                SyntaxKind::AmpAmp,
615                SyntaxKind::PipePipe,
616                SyntaxKind::Bang,
617                SyntaxKind::Plus,
618                SyntaxKind::Star,
619            ],
620        );
621    }
622
623    // `T | E` still lexes with the single `Pipe`.
624    #[test]
625    fn union_pipe_lexes_unchanged() {
626        for profile in [Profile::Typl, Profile::Ridl] {
627            assert_eq!(
628                kinds_in(profile, "T | E"),
629                vec![SyntaxKind::Ident, SyntaxKind::Pipe, SyntaxKind::Ident],
630            );
631        }
632    }
633
634    #[test]
635    fn fixture_round_trips_with_no_errors() {
636        let input = include_str!("../fixtures/walking_skeleton.typl");
637        let tokens = lex(input, Profile::Typl);
638        assert_eq!(concat(&tokens), input, "token texts must reproduce input");
639        assert!(
640            tokens.iter().all(|t| t.kind != SyntaxKind::Error),
641            "fixture must lex with zero Error tokens, got: {tokens:?}"
642        );
643    }
644
645    #[test]
646    fn range_dots_do_not_get_swallowed_by_float_regex() {
647        let tokens = lex("0.0..250.0", Profile::Typl);
648        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
649        assert_eq!(
650            kinds,
651            vec![
652                SyntaxKind::FloatNumber,
653                SyntaxKind::DotDot,
654                SyntaxKind::FloatNumber,
655            ]
656        );
657    }
658
659    #[test]
660    fn slash_separated_unit_lexes_as_ident_slash_ident() {
661        let tokens = lex("km/h", Profile::Typl);
662        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
663        assert_eq!(
664            kinds,
665            vec![SyntaxKind::Ident, SyntaxKind::Slash, SyntaxKind::Ident]
666        );
667    }
668
669    #[test]
670    fn line_comment_is_one_token() {
671        let tokens = lex("// x", Profile::Typl);
672        assert_eq!(tokens.len(), 1);
673        assert_eq!(tokens[0].kind, SyntaxKind::LineComment);
674        assert_eq!(tokens[0].text, "// x");
675    }
676
677    #[test]
678    fn unknown_byte_yields_error_and_still_round_trips() {
679        let input = "$";
680        let tokens = lex(input, Profile::Typl);
681        assert_eq!(concat(&tokens), input);
682        assert_eq!(tokens.len(), 1);
683        assert_eq!(tokens[0].kind, SyntaxKind::Error);
684    }
685
686    #[test]
687    fn keywords_lex_distinctly_from_ident() {
688        let tokens = lex("type const step notAKeyword", Profile::Typl);
689        let kinds: Vec<_> = tokens
690            .iter()
691            .filter(|t| t.kind != SyntaxKind::Whitespace)
692            .map(|t| t.kind)
693            .collect();
694        assert_eq!(
695            kinds,
696            vec![
697                SyntaxKind::TypeKw,
698                SyntaxKind::ConstKw,
699                SyntaxKind::StepKw,
700                SyntaxKind::Ident,
701            ]
702        );
703    }
704
705    /// The kinds of the significant (non-trivia) tokens under `Profile::Typl`,
706    /// in order — the E1 tests below all pin the typl profile's behavior.
707    fn significant_kinds(input: &str) -> Vec<SyntaxKind> {
708        kinds_in(Profile::Typl, input)
709    }
710
711    // Step (a): every used keyword lexes to its variant; every reserved word to
712    // ReservedWord.
713    #[test]
714    fn every_used_keyword_lexes_to_its_variant() {
715        for word in keywords::FAMILY_RESERVED {
716            let tokens = lex(word, Profile::Typl);
717            assert_eq!(tokens.len(), 1, "`{word}` must lex to one token");
718            let expected = keywords::typl_keyword(word).unwrap_or(SyntaxKind::ReservedWord);
719            assert_eq!(tokens[0].kind, expected, "`{word}` lexed to the wrong kind");
720        }
721    }
722
723    #[test]
724    fn reserved_word_is_not_an_identifier() {
725        assert_eq!(significant_kinds("signal"), vec![SyntaxKind::ReservedWord]);
726        assert_eq!(significant_kinds("model"), vec![SyntaxKind::ReservedWord]);
727        assert_eq!(significant_kinds("Speed"), vec![SyntaxKind::Ident]);
728    }
729
730    // Step (c): `0.0..250.0` lexes float–dotdot–float (kept from E0, extended).
731    #[test]
732    fn float_range_lexes_as_float_dotdot_float() {
733        assert_eq!(
734            significant_kinds("0.0..250.0"),
735            vec![
736                SyntaxKind::FloatNumber,
737                SyntaxKind::DotDot,
738                SyntaxKind::FloatNumber,
739            ]
740        );
741    }
742
743    // Step (d): `km/h` is ident–slash–ident; a regex literal after `=` is one
744    // Regex token.
745    #[test]
746    fn slash_in_unit_is_not_a_regex() {
747        assert_eq!(
748            significant_kinds("km/h"),
749            vec![SyntaxKind::Ident, SyntaxKind::Slash, SyntaxKind::Ident]
750        );
751    }
752
753    #[test]
754    fn regex_literal_after_eq_is_one_token() {
755        let tokens = lex(r"const V = /a\/b/", Profile::Typl);
756        let regex: Vec<_> = tokens
757            .iter()
758            .filter(|t| t.kind == SyntaxKind::Regex)
759            .collect();
760        assert_eq!(regex.len(), 1, "one regex token expected: {tokens:?}");
761        assert_eq!(regex[0].text, r"/a\/b/");
762    }
763
764    #[test]
765    fn unterminated_regex_is_error_to_end_of_line() {
766        let tokens = lex("const V = /abc\n", Profile::Typl);
767        let last_significant = tokens
768            .iter()
769            .rev()
770            .find(|t| !t.kind.is_trivia())
771            .expect("a significant token");
772        assert_eq!(last_significant.kind, SyntaxKind::Error);
773        assert_eq!(last_significant.text, "/abc");
774    }
775
776    #[test]
777    fn inline_match_regex_is_one_token() {
778        // The exact `ridl.std` Date declaration (typl reference Appendix A,
779        // §5.3): the regex literal follows the `match` keyword, not `=`.
780        let line = r"type Date : string [10 match /^\d{4}-\d{2}-\d{2}$/]";
781        let tokens = lex(line, Profile::Typl);
782        assert!(
783            tokens.iter().all(|t| t.kind != SyntaxKind::Error),
784            "no error tokens expected: {tokens:?}",
785        );
786        assert_eq!(
787            significant_kinds(line),
788            vec![
789                SyntaxKind::TypeKw,
790                SyntaxKind::Ident,
791                SyntaxKind::Colon,
792                SyntaxKind::StringKw,
793                SyntaxKind::LBracket,
794                SyntaxKind::IntNumber,
795                SyntaxKind::MatchKw,
796                SyntaxKind::Regex,
797                SyntaxKind::RBracket,
798            ],
799        );
800        let regex: Vec<_> = tokens
801            .iter()
802            .filter(|t| t.kind == SyntaxKind::Regex)
803            .collect();
804        assert_eq!(regex.len(), 1, "one regex token expected: {tokens:?}");
805        assert_eq!(regex[0].text, r"/^\d{4}-\d{2}-\d{2}$/");
806    }
807
808    // Step (e): durations lex as one Duration; a bare atom is an Ident.
809    #[test]
810    fn durations_lex_as_one_token() {
811        assert_eq!(significant_kinds("10ms"), vec![SyntaxKind::Duration]);
812        assert_eq!(significant_kinds("500us"), vec![SyntaxKind::Duration]);
813        assert_eq!(significant_kinds("1s"), vec![SyntaxKind::Duration]);
814        assert_eq!(significant_kinds("1min"), vec![SyntaxKind::Duration]);
815        assert_eq!(significant_kinds("1.5h"), vec![SyntaxKind::Duration]);
816    }
817
818    #[test]
819    fn bare_time_atom_is_an_identifier() {
820        assert_eq!(significant_kinds("min"), vec![SyntaxKind::Ident]);
821        // A space breaks the adjacency a duration needs.
822        assert_eq!(
823            significant_kinds("10 ms"),
824            vec![SyntaxKind::IntNumber, SyntaxKind::Ident]
825        );
826    }
827
828    // Step (f): strings with escapes, and unterminated strings.
829    #[test]
830    fn string_with_escape_is_one_token() {
831        let tokens = lex(r#""a\nb""#, Profile::Typl);
832        assert_eq!(tokens.len(), 1);
833        assert_eq!(tokens[0].kind, SyntaxKind::String);
834        assert_eq!(tokens[0].text, r#""a\nb""#);
835    }
836
837    #[test]
838    fn unterminated_string_is_error_to_end_of_line() {
839        let tokens = lex("\"abc\n", Profile::Typl);
840        assert_eq!(tokens[0].kind, SyntaxKind::Error);
841        assert_eq!(tokens[0].text, "\"abc");
842        // The newline survives as its own trivia token (losslessness).
843        assert_eq!(tokens[1].kind, SyntaxKind::Whitespace);
844    }
845
846    // Step (g): block comments do not nest.
847    #[test]
848    fn block_comment_is_one_token() {
849        let tokens = lex("/* block */", Profile::Typl);
850        assert_eq!(tokens.len(), 1);
851        assert_eq!(tokens[0].kind, SyntaxKind::BlockComment);
852    }
853
854    #[test]
855    fn block_comment_ends_at_first_close() {
856        let tokens = lex("/* /* */", Profile::Typl);
857        assert_eq!(tokens.len(), 1, "no nesting: {tokens:?}");
858        assert_eq!(tokens[0].kind, SyntaxKind::BlockComment);
859        assert_eq!(tokens[0].text, "/* /* */");
860    }
861
862    #[test]
863    fn doc_comments_are_distinct_from_plain_comments() {
864        assert_eq!(significant_kinds("/// doc"), Vec::<SyntaxKind>::new());
865        let doc_line = lex("/// doc", Profile::Typl);
866        assert_eq!(doc_line[0].kind, SyntaxKind::DocComment);
867        let doc_block = lex("/** doc */", Profile::Typl);
868        assert_eq!(doc_block[0].kind, SyntaxKind::DocComment);
869        let plain = lex("// doc", Profile::Typl);
870        assert_eq!(plain[0].kind, SyntaxKind::LineComment);
871    }
872
873    // Step (h): leading zeros lex as one IntNumber; flagging is the parser's job.
874    #[test]
875    fn leading_zeros_lex_as_one_integer() {
876        let tokens = lex("042", Profile::Typl);
877        assert_eq!(tokens.len(), 1);
878        assert_eq!(tokens[0].kind, SyntaxKind::IntNumber);
879        assert_eq!(tokens[0].text, "042");
880    }
881
882    #[test]
883    fn minus_is_its_own_token() {
884        assert_eq!(
885            significant_kinds("-40"),
886            vec![SyntaxKind::Minus, SyntaxKind::IntNumber]
887        );
888    }
889
890    #[test]
891    fn all_new_punctuation_lexes() {
892        assert_eq!(
893            significant_kinds("{}()?;@|%"),
894            vec![
895                SyntaxKind::LBrace,
896                SyntaxKind::RBrace,
897                SyntaxKind::LParen,
898                SyntaxKind::RParen,
899                SyntaxKind::Question,
900                SyntaxKind::Semicolon,
901                SyntaxKind::At,
902                SyntaxKind::Pipe,
903                SyntaxKind::Percent,
904            ]
905        );
906    }
907
908    /// The kinds and texts of every token, trivia included.
909    fn pairs(input: &str) -> Vec<(SyntaxKind, &str)> {
910        lex(input, Profile::Typl)
911            .iter()
912            .map(|t| (t.kind, t.text))
913            .collect()
914    }
915
916    // A lone CR is a line break (typl reference §2.2), so a line comment, a
917    // doc comment line, an unterminated string and an unterminated regex all
918    // end before it, as they do before an LF.
919    #[test]
920    fn a_lone_cr_ends_a_line_comment() {
921        assert_eq!(
922            pairs("// a\rx"),
923            vec![
924                (SyntaxKind::LineComment, "// a"),
925                (SyntaxKind::Whitespace, "\r"),
926                (SyntaxKind::Ident, "x"),
927            ],
928        );
929    }
930
931    #[test]
932    fn a_lone_cr_at_the_end_of_input_ends_a_line_comment() {
933        assert_eq!(
934            pairs("// a\r"),
935            vec![
936                (SyntaxKind::LineComment, "// a"),
937                (SyntaxKind::Whitespace, "\r"),
938            ],
939        );
940    }
941
942    #[test]
943    fn a_lone_cr_ends_a_doc_comment_line() {
944        assert_eq!(
945            pairs("/// a\rx"),
946            vec![
947                (SyntaxKind::DocComment, "/// a"),
948                (SyntaxKind::Whitespace, "\r"),
949                (SyntaxKind::Ident, "x"),
950            ],
951        );
952    }
953
954    #[test]
955    fn a_lone_cr_ends_an_unterminated_string() {
956        assert_eq!(
957            pairs("\"abc\rx"),
958            vec![
959                (SyntaxKind::Error, "\"abc"),
960                (SyntaxKind::Whitespace, "\r"),
961                (SyntaxKind::Ident, "x"),
962            ],
963        );
964        // A backslash before a lone CR does not escape the line break.
965        assert_eq!(
966            pairs("\"a\\\rx"),
967            vec![
968                (SyntaxKind::Error, "\"a\\"),
969                (SyntaxKind::Whitespace, "\r"),
970                (SyntaxKind::Ident, "x"),
971            ],
972        );
973    }
974
975    #[test]
976    fn a_backslash_and_a_final_lone_cr_end_an_unterminated_string() {
977        assert_eq!(
978            pairs("\"a\\\r"),
979            vec![(SyntaxKind::Error, "\"a\\"), (SyntaxKind::Whitespace, "\r"),],
980        );
981    }
982
983    #[test]
984    fn a_lone_cr_ends_an_unterminated_regex() {
985        let tokens = pairs("const V = /abc\rx");
986        assert_eq!(
987            tokens[tokens.len() - 3..],
988            [
989                (SyntaxKind::Error, "/abc"),
990                (SyntaxKind::Whitespace, "\r"),
991                (SyntaxKind::Ident, "x"),
992            ],
993        );
994        let tokens = pairs("const V = /a\\\rx");
995        assert_eq!(
996            tokens[tokens.len() - 3..],
997            [
998                (SyntaxKind::Error, "/a\\"),
999                (SyntaxKind::Whitespace, "\r"),
1000                (SyntaxKind::Ident, "x"),
1001            ],
1002        );
1003    }
1004
1005    // A CRLF pair is one line break, and the tokens around it are the same as
1006    // before a lone CR became a line break: the CR stays inside the token that
1007    // precedes the LF.
1008    #[test]
1009    fn a_crlf_pair_still_ends_a_line_at_its_lf() {
1010        assert_eq!(
1011            pairs("// a\r\nx"),
1012            vec![
1013                (SyntaxKind::LineComment, "// a\r"),
1014                (SyntaxKind::Whitespace, "\n"),
1015                (SyntaxKind::Ident, "x"),
1016            ],
1017        );
1018        assert_eq!(
1019            pairs("/// a\r\nx"),
1020            vec![
1021                (SyntaxKind::DocComment, "/// a\r"),
1022                (SyntaxKind::Whitespace, "\n"),
1023                (SyntaxKind::Ident, "x"),
1024            ],
1025        );
1026        assert_eq!(
1027            pairs("\"abc\r\nx"),
1028            vec![
1029                (SyntaxKind::Error, "\"abc\r"),
1030                (SyntaxKind::Whitespace, "\n"),
1031                (SyntaxKind::Ident, "x"),
1032            ],
1033        );
1034        let tokens = pairs("const V = /abc\r\nx");
1035        assert_eq!(
1036            tokens[tokens.len() - 3..],
1037            [
1038                (SyntaxKind::Error, "/abc\r"),
1039                (SyntaxKind::Whitespace, "\n"),
1040                (SyntaxKind::Ident, "x"),
1041            ],
1042        );
1043    }
1044}