Skip to main content

ridl_syntax/
lexer.rs

1//! The family lexer (docs/ROADMAP.md epic E1.1, 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 outranks the ordinary `//` line comment.
72    #[regex(r"///[^\n]*", priority = 5, allow_greedy = true)]
73    DocCommentLine,
74
75    // Line comment `//` to end of line (typl reference §13).
76    #[regex(r"//[^\n]*", priority = 4, allow_greedy = true)]
77    LineComment,
78
79    // Block comment `/* ... */`, non-nesting (typl reference §13). The callback
80    // reports whether it is a doc comment (`/**`) and stops at the first `*/`.
81    #[token("/*", lex_block_comment)]
82    BlockComment(bool),
83
84    #[token(":")]
85    Colon,
86    #[token("=")]
87    Eq,
88    #[token("[")]
89    LBracket,
90    #[token("]")]
91    RBracket,
92    #[token("{")]
93    LBrace,
94    #[token("}")]
95    RBrace,
96    #[token("(")]
97    LParen,
98    #[token(")")]
99    RParen,
100    #[token("..")]
101    DotDot,
102    #[token(".")]
103    Dot,
104    #[token("/")]
105    Slash,
106    #[token(",")]
107    Comma,
108    #[token("?")]
109    Question,
110    #[token(";")]
111    Semicolon,
112    #[token("@")]
113    At,
114    #[token("|")]
115    Pipe,
116    #[token("%")]
117    Percent,
118    #[token("-")]
119    Minus,
120
121    // Expression operators (ridl reference §13, expr core). Family tokens
122    // like durations: recognised under every profile, rejected by the typl
123    // grammar. `logos` picks the longest match, so `<=` outranks `<`, `==`
124    // outranks two `=`, and `||` outranks two `|`.
125    #[token("<")]
126    Lt,
127    #[token("<=")]
128    Le,
129    #[token(">")]
130    Gt,
131    #[token(">=")]
132    Ge,
133    #[token("==")]
134    EqEq,
135    #[token("!=")]
136    Neq,
137    #[token("&&")]
138    AmpAmp,
139    #[token("||")]
140    PipePipe,
141    #[token("!")]
142    Bang,
143    #[token("+")]
144    Plus,
145    #[token("*")]
146    Star,
147
148    #[regex(r"[ \t\r\n]+")]
149    Whitespace,
150}
151
152impl From<RawToken> for SyntaxKind {
153    fn from(token: RawToken) -> Self {
154        match token {
155            RawToken::Ident => SyntaxKind::Ident,
156            RawToken::FloatNumber => SyntaxKind::FloatNumber,
157            RawToken::IntNumber => SyntaxKind::IntNumber,
158            RawToken::Str => SyntaxKind::String,
159            RawToken::DocCommentLine => SyntaxKind::DocComment,
160            RawToken::LineComment => SyntaxKind::LineComment,
161            RawToken::BlockComment(is_doc) => {
162                if is_doc {
163                    SyntaxKind::DocComment
164                } else {
165                    SyntaxKind::BlockComment
166                }
167            }
168            RawToken::Colon => SyntaxKind::Colon,
169            RawToken::Eq => SyntaxKind::Eq,
170            RawToken::LBracket => SyntaxKind::LBracket,
171            RawToken::RBracket => SyntaxKind::RBracket,
172            RawToken::LBrace => SyntaxKind::LBrace,
173            RawToken::RBrace => SyntaxKind::RBrace,
174            RawToken::LParen => SyntaxKind::LParen,
175            RawToken::RParen => SyntaxKind::RParen,
176            RawToken::DotDot => SyntaxKind::DotDot,
177            RawToken::Dot => SyntaxKind::Dot,
178            RawToken::Slash => SyntaxKind::Slash,
179            RawToken::Comma => SyntaxKind::Comma,
180            RawToken::Question => SyntaxKind::Question,
181            RawToken::Semicolon => SyntaxKind::Semicolon,
182            RawToken::At => SyntaxKind::At,
183            RawToken::Pipe => SyntaxKind::Pipe,
184            RawToken::Percent => SyntaxKind::Percent,
185            RawToken::Minus => SyntaxKind::Minus,
186            RawToken::Lt => SyntaxKind::Lt,
187            RawToken::Le => SyntaxKind::Le,
188            RawToken::Gt => SyntaxKind::Gt,
189            RawToken::Ge => SyntaxKind::Ge,
190            RawToken::EqEq => SyntaxKind::EqEq,
191            RawToken::Neq => SyntaxKind::Neq,
192            RawToken::AmpAmp => SyntaxKind::AmpAmp,
193            RawToken::PipePipe => SyntaxKind::PipePipe,
194            RawToken::Bang => SyntaxKind::Bang,
195            RawToken::Plus => SyntaxKind::Plus,
196            RawToken::Star => SyntaxKind::Star,
197            RawToken::Whitespace => SyntaxKind::Whitespace,
198        }
199    }
200}
201
202/// A raw token with its byte span, before duration and keyword refinement.
203struct Raw {
204    kind: SyntaxKind,
205    start: usize,
206    end: usize,
207}
208
209/// Lexes `input` into a flat token stream under `profile`.
210///
211/// Total: concatenating `token.text` for every returned token reproduces
212/// `input` byte for byte. Input the family lexer does not recognise becomes
213/// [`SyntaxKind::Error`] tokens rather than aborting the lex.
214///
215/// The profile decides only which registry words are active keywords: under
216/// [`Profile::Typl`] the stream is identical to the E1 lexer's; under
217/// [`Profile::Ridl`] the nine ridl words lex to their keyword variants; under
218/// [`Profile::Rsdl`] the eight rsdl words do.
219/// Everything else — durations, `@`, the expr operators — is a family token
220/// in both profiles; the parser draws the profile boundaries.
221pub fn lex(input: &str, profile: Profile) -> Vec<Token<'_>> {
222    let raws = raw_tokens(input);
223    refine(input, &raws, profile)
224}
225
226/// Runs `logos` and lifts regex literals out of the raw stream (typl reference
227/// §2.7). On a regex literal the scan is driven from the source and lexing
228/// resumes from the literal's end, so the pattern bytes are never mis-tokenised
229/// as slashes, identifiers, or a stray line comment.
230fn raw_tokens(input: &str) -> Vec<Raw> {
231    let mut out: Vec<Raw> = Vec::new();
232    // The previous significant (non-trivia) token: its kind and its source
233    // text. The text is needed to spot the `match` keyword, which is still a
234    // raw `Ident` at this stage — keyword mapping happens after the regex lift.
235    let mut prev_kind: Option<SyntaxKind> = None;
236    let mut prev_text: &str = "";
237    let mut base = 0usize;
238    'relex: loop {
239        let mut lexer = RawToken::lexer(&input[base..]);
240        while let Some(result) = lexer.next() {
241            let span = lexer.span();
242            let start = base + span.start;
243            let end = base + span.end;
244            let kind = result.map_or(SyntaxKind::Error, SyntaxKind::from);
245
246            if kind == SyntaxKind::Slash && starts_regex(prev_kind, prev_text) {
247                let (regex_kind, regex_end) = scan_regex(input, start);
248                out.push(Raw {
249                    kind: regex_kind,
250                    start,
251                    end: regex_end,
252                });
253                prev_kind = Some(regex_kind);
254                prev_text = &input[start..regex_end];
255                base = regex_end;
256                continue 'relex;
257            }
258
259            out.push(Raw { kind, start, end });
260            if !kind.is_trivia() {
261                prev_kind = Some(kind);
262                prev_text = &input[start..end];
263            }
264        }
265        break;
266    }
267    out
268}
269
270/// Whether a `/` following this previous significant token begins a regex
271/// literal (typl reference §2.7, §5.3): after `=` — an init or const value — or
272/// the `match` keyword — a string constraint. `match` is still a raw `Ident` at
273/// lexing time, so it is matched by its source text; it is a reserved word, so
274/// the text is unambiguous, and typl has no division operator, so neither `= /`
275/// nor `match /` can be anything but a regex.
276fn starts_regex(prev_kind: Option<SyntaxKind>, prev_text: &str) -> bool {
277    prev_kind == Some(SyntaxKind::Eq)
278        || (prev_kind == Some(SyntaxKind::Ident) && prev_text == "match")
279}
280
281/// Scans a regex literal that begins at the `/` at byte `start`. Returns
282/// [`SyntaxKind::Regex`] and the byte just past the closing `/`, or
283/// [`SyntaxKind::Error`] and the end of the line when the literal does not
284/// terminate on its own line (typl reference §2.7 — a regex cannot contain a
285/// raw newline).
286fn scan_regex(input: &str, start: usize) -> (SyntaxKind, usize) {
287    let bytes = input.as_bytes();
288    let mut i = start + 1; // past the opening '/'
289    while i < bytes.len() {
290        match bytes[i] {
291            b'\\' => {
292                if i + 1 >= bytes.len() {
293                    return (SyntaxKind::Error, bytes.len());
294                }
295                if bytes[i + 1] == b'\n' {
296                    return (SyntaxKind::Error, i + 1);
297                }
298                i += 2;
299            }
300            b'/' => return (SyntaxKind::Regex, i + 1),
301            b'\n' => return (SyntaxKind::Error, i),
302            _ => i += 1,
303        }
304    }
305    (SyntaxKind::Error, bytes.len())
306}
307
308/// Merges duration literals and maps identifiers against the family registry:
309/// a keyword `profile` uses becomes its keyword kind, any other registry word
310/// becomes [`SyntaxKind::ReservedWord`], everything else stays an `Ident`.
311fn refine<'a>(input: &'a str, raws: &[Raw], profile: Profile) -> Vec<Token<'a>> {
312    let mut out = Vec::with_capacity(raws.len());
313    let mut i = 0;
314    while i < raws.len() {
315        let raw = &raws[i];
316
317        // Duration: a number immediately followed by a UCUM time atom (typl
318        // reference §2.8). "Immediately" means the very next token — whitespace
319        // would appear as a separate trivia token in between.
320        if matches!(raw.kind, SyntaxKind::IntNumber | SyntaxKind::FloatNumber)
321            && let Some(next) = raws.get(i + 1)
322            && next.kind == SyntaxKind::Ident
323            && is_time_atom(&input[next.start..next.end])
324        {
325            out.push(Token {
326                kind: SyntaxKind::Duration,
327                text: &input[raw.start..next.end],
328            });
329            i += 2;
330            continue;
331        }
332
333        let text = &input[raw.start..raw.end];
334        let kind = if raw.kind == SyntaxKind::Ident {
335            keywords::keyword_in(profile, text).unwrap_or(if keywords::is_reserved(text) {
336                SyntaxKind::ReservedWord
337            } else {
338                SyntaxKind::Ident
339            })
340        } else {
341            raw.kind
342        };
343        out.push(Token { kind, text });
344        i += 1;
345    }
346    out
347}
348
349/// The UCUM time atoms a duration literal may use (typl reference §2.8).
350fn is_time_atom(text: &str) -> bool {
351    matches!(text, "us" | "ms" | "s" | "min" | "h")
352}
353
354/// Scans a string literal after the opening `"` (typl reference §2.6). Returns
355/// `Ok` on the closing quote; an unterminated string (a raw newline or the end
356/// of input before the close) consumes to end of line and becomes an error
357/// token.
358fn lex_string(lex: &mut logos::Lexer<RawToken>) -> Result<(), ()> {
359    let bytes = lex.remainder().as_bytes();
360    let mut i = 0;
361    while i < bytes.len() {
362        match bytes[i] {
363            b'\n' => {
364                lex.bump(i);
365                return Err(());
366            }
367            b'\\' => {
368                if i + 1 >= bytes.len() || bytes[i + 1] == b'\n' {
369                    // Dangling backslash at end of line or input: unterminated.
370                    lex.bump(i + 1);
371                    return Err(());
372                }
373                i += 2;
374            }
375            b'"' => {
376                lex.bump(i + 1);
377                return Ok(());
378            }
379            _ => i += 1,
380        }
381    }
382    lex.bump(bytes.len());
383    Err(())
384}
385
386/// Scans a block comment after the opening `/*` (typl reference §13). Non-
387/// nesting: stops at the first `*/`. Returns whether it is a doc comment
388/// (`/** ... */`, typl reference §14); an unterminated block comment consumes
389/// the rest of the input and becomes an error token.
390fn lex_block_comment(lex: &mut logos::Lexer<RawToken>) -> Result<bool, ()> {
391    let rest = lex.remainder();
392    // A doc comment starts `/**` but the empty comment `/**/` does not.
393    let is_doc = rest.starts_with('*') && !rest.starts_with("*/");
394    match rest.find("*/") {
395        Some(idx) => {
396            lex.bump(idx + 2);
397            Ok(is_doc)
398        }
399        None => {
400            lex.bump(rest.len());
401            Err(())
402        }
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn concat(tokens: &[Token<'_>]) -> String {
411        tokens.iter().map(|t| t.text).collect()
412    }
413
414    /// The kinds of the significant (non-trivia) tokens under `profile`.
415    fn kinds_in(profile: Profile, input: &str) -> Vec<SyntaxKind> {
416        lex(input, profile)
417            .iter()
418            .filter(|t| !t.kind.is_trivia())
419            .map(|t| t.kind)
420            .collect()
421    }
422
423    // E2 task 2 step (a): the nine ridl words lex to their keyword variants
424    // under `Profile::Ridl` and stay `ReservedWord` under `Profile::Typl`.
425    #[test]
426    fn ridl_words_lex_by_profile() {
427        for (word, kind) in [
428            ("interface", SyntaxKind::InterfaceKw),
429            ("service", SyntaxKind::ServiceKw),
430            ("signal", SyntaxKind::SignalKw),
431            ("event", SyntaxKind::EventKw),
432            ("command", SyntaxKind::CommandKw),
433            ("query", SyntaxKind::QueryKw),
434            ("fixed", SyntaxKind::FixedKw),
435            ("require", SyntaxKind::RequireKw),
436            ("ensure", SyntaxKind::EnsureKw),
437        ] {
438            assert_eq!(
439                kinds_in(Profile::Ridl, word),
440                vec![kind],
441                "`{word}` must lex to its keyword variant under Ridl",
442            );
443            assert_eq!(
444                kinds_in(Profile::Typl, word),
445                vec![SyntaxKind::ReservedWord],
446                "`{word}` must stay ReservedWord under Typl",
447            );
448        }
449    }
450
451    #[test]
452    fn other_profile_words_stay_reserved_in_both_profiles() {
453        for word in ["view", "model", "component", "automaton"] {
454            for profile in [Profile::Typl, Profile::Ridl] {
455                assert_eq!(
456                    kinds_in(profile, word),
457                    vec![SyntaxKind::ReservedWord],
458                    "`{word}` must stay ReservedWord under {profile:?}",
459                );
460            }
461        }
462    }
463
464    /// The rsdl v0.2 keyword line (rsdl reference §2), paired with the token
465    /// kind each word lexes to under `Profile::Rsdl`.
466    const RSDL_WORDS: [(&str, SyntaxKind); 8] = [
467        ("system", SyntaxKind::SystemKw),
468        ("component", SyntaxKind::ComponentKw),
469        ("distribution", SyntaxKind::DistributionKw),
470        ("deployment", SyntaxKind::DeploymentKw),
471        ("machine", SyntaxKind::MachineKw),
472        ("offers", SyntaxKind::OffersKw),
473        ("requires", SyntaxKind::RequiresKw),
474        ("for", SyntaxKind::ForKw),
475    ];
476
477    /// The rsdl v0.1 words the v0.2 reference retires (rsdl reference §2). No
478    /// other profile uses one, so each leaves the registry.
479    const RETIRED_RSDL_WORDS: [&str; 13] = [
480        "provides",
481        "instance",
482        "assurance",
483        "target",
484        "place",
485        "on",
486        "transport",
487        "bundle",
488        "time",
489        "base",
490        "redundant",
491        "supervise",
492        "degraded",
493    ];
494
495    // Every word of the rsdl keyword line is reserved in every profile: an
496    // active keyword under `Profile::Rsdl`, a `ReservedWord` under the other
497    // two. `offers`, `distribution` and `machine` are the three the registry
498    // gained with rsdl v0.2.
499    #[test]
500    fn rsdl_words_lex_by_profile() {
501        for (word, kind) in RSDL_WORDS {
502            assert_eq!(
503                kinds_in(Profile::Rsdl, word),
504                vec![kind],
505                "`{word}` must lex to its keyword variant under Rsdl",
506            );
507            for profile in [Profile::Typl, Profile::Ridl] {
508                assert_eq!(
509                    kinds_in(profile, word),
510                    vec![SyntaxKind::ReservedWord],
511                    "`{word}` must stay ReservedWord under {profile:?}",
512                );
513            }
514        }
515    }
516
517    // A retired rsdl word is an ordinary identifier in every profile.
518    #[test]
519    fn retired_rsdl_words_are_identifiers_in_every_profile() {
520        for word in RETIRED_RSDL_WORDS {
521            for profile in [Profile::Typl, Profile::Ridl, Profile::Rsdl] {
522                assert_eq!(
523                    kinds_in(profile, word),
524                    vec![SyntaxKind::Ident],
525                    "`{word}` must lex to Ident under {profile:?}",
526                );
527            }
528        }
529    }
530
531    // The typl words stay active under `Profile::Rsdl`, and a ridl word is a
532    // reserved word there.
533    #[test]
534    fn rsdl_profile_keeps_the_typl_keywords_and_reserves_the_ridl_words() {
535        assert_eq!(
536            kinds_in(Profile::Rsdl, "package"),
537            vec![SyntaxKind::PackageKw]
538        );
539        assert_eq!(
540            kinds_in(Profile::Rsdl, "internal"),
541            vec![SyntaxKind::InternalKw]
542        );
543        assert_eq!(kinds_in(Profile::Rsdl, "true"), vec![SyntaxKind::TrueKw]);
544        assert_eq!(
545            kinds_in(Profile::Rsdl, "service"),
546            vec![SyntaxKind::ReservedWord]
547        );
548        assert_eq!(
549            kinds_in(Profile::Rsdl, "require"),
550            vec![SyntaxKind::ReservedWord]
551        );
552    }
553
554    // E2 task 2 step (b), lexer half: durations and `@` are ordinary tokens
555    // under Ridl (the parser draws no TYPL-302 there — see the parser tests).
556    #[test]
557    fn durations_and_at_lex_clean_under_ridl() {
558        assert_eq!(kinds_in(Profile::Ridl, "10ms"), vec![SyntaxKind::Duration]);
559        assert_eq!(kinds_in(Profile::Ridl, "@"), vec![SyntaxKind::At]);
560    }
561
562    // E2 task 2 step (c): the expr operator tokens.
563    #[test]
564    fn expr_operators_lex_under_both_profiles() {
565        for profile in [Profile::Typl, Profile::Ridl] {
566            assert_eq!(
567                kinds_in(profile, "a >= b && !c"),
568                vec![
569                    SyntaxKind::Ident,
570                    SyntaxKind::Ge,
571                    SyntaxKind::Ident,
572                    SyntaxKind::AmpAmp,
573                    SyntaxKind::Bang,
574                    SyntaxKind::Ident,
575                ],
576            );
577        }
578    }
579
580    #[test]
581    fn every_expr_operator_token_lexes() {
582        assert_eq!(
583            kinds_in(Profile::Ridl, "< <= > >= == != && || ! + *"),
584            vec![
585                SyntaxKind::Lt,
586                SyntaxKind::Le,
587                SyntaxKind::Gt,
588                SyntaxKind::Ge,
589                SyntaxKind::EqEq,
590                SyntaxKind::Neq,
591                SyntaxKind::AmpAmp,
592                SyntaxKind::PipePipe,
593                SyntaxKind::Bang,
594                SyntaxKind::Plus,
595                SyntaxKind::Star,
596            ],
597        );
598    }
599
600    // E2 task 2 step (d): `T | E` still lexes with the single `Pipe`.
601    #[test]
602    fn union_pipe_lexes_unchanged() {
603        for profile in [Profile::Typl, Profile::Ridl] {
604            assert_eq!(
605                kinds_in(profile, "T | E"),
606                vec![SyntaxKind::Ident, SyntaxKind::Pipe, SyntaxKind::Ident],
607            );
608        }
609    }
610
611    #[test]
612    fn fixture_round_trips_with_no_errors() {
613        let input = include_str!("../fixtures/walking_skeleton.typl");
614        let tokens = lex(input, Profile::Typl);
615        assert_eq!(concat(&tokens), input, "token texts must reproduce input");
616        assert!(
617            tokens.iter().all(|t| t.kind != SyntaxKind::Error),
618            "fixture must lex with zero Error tokens, got: {tokens:?}"
619        );
620    }
621
622    #[test]
623    fn range_dots_do_not_get_swallowed_by_float_regex() {
624        let tokens = lex("0.0..250.0", Profile::Typl);
625        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
626        assert_eq!(
627            kinds,
628            vec![
629                SyntaxKind::FloatNumber,
630                SyntaxKind::DotDot,
631                SyntaxKind::FloatNumber,
632            ]
633        );
634    }
635
636    #[test]
637    fn slash_separated_unit_lexes_as_ident_slash_ident() {
638        let tokens = lex("km/h", Profile::Typl);
639        let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
640        assert_eq!(
641            kinds,
642            vec![SyntaxKind::Ident, SyntaxKind::Slash, SyntaxKind::Ident]
643        );
644    }
645
646    #[test]
647    fn line_comment_is_one_token() {
648        let tokens = lex("// x", Profile::Typl);
649        assert_eq!(tokens.len(), 1);
650        assert_eq!(tokens[0].kind, SyntaxKind::LineComment);
651        assert_eq!(tokens[0].text, "// x");
652    }
653
654    #[test]
655    fn unknown_byte_yields_error_and_still_round_trips() {
656        let input = "$";
657        let tokens = lex(input, Profile::Typl);
658        assert_eq!(concat(&tokens), input);
659        assert_eq!(tokens.len(), 1);
660        assert_eq!(tokens[0].kind, SyntaxKind::Error);
661    }
662
663    #[test]
664    fn keywords_lex_distinctly_from_ident() {
665        let tokens = lex("type const step notAKeyword", Profile::Typl);
666        let kinds: Vec<_> = tokens
667            .iter()
668            .filter(|t| t.kind != SyntaxKind::Whitespace)
669            .map(|t| t.kind)
670            .collect();
671        assert_eq!(
672            kinds,
673            vec![
674                SyntaxKind::TypeKw,
675                SyntaxKind::ConstKw,
676                SyntaxKind::StepKw,
677                SyntaxKind::Ident,
678            ]
679        );
680    }
681
682    /// The kinds of the significant (non-trivia) tokens under `Profile::Typl`,
683    /// in order — the E1 tests below all pin the typl profile's behavior.
684    fn significant_kinds(input: &str) -> Vec<SyntaxKind> {
685        kinds_in(Profile::Typl, input)
686    }
687
688    // Step (a): every used keyword lexes to its variant; every reserved word to
689    // ReservedWord.
690    #[test]
691    fn every_used_keyword_lexes_to_its_variant() {
692        for word in keywords::FAMILY_RESERVED {
693            let tokens = lex(word, Profile::Typl);
694            assert_eq!(tokens.len(), 1, "`{word}` must lex to one token");
695            let expected = keywords::typl_keyword(word).unwrap_or(SyntaxKind::ReservedWord);
696            assert_eq!(tokens[0].kind, expected, "`{word}` lexed to the wrong kind");
697        }
698    }
699
700    #[test]
701    fn reserved_word_is_not_an_identifier() {
702        assert_eq!(significant_kinds("signal"), vec![SyntaxKind::ReservedWord]);
703        assert_eq!(significant_kinds("model"), vec![SyntaxKind::ReservedWord]);
704        assert_eq!(significant_kinds("Speed"), vec![SyntaxKind::Ident]);
705    }
706
707    // Step (c): `0.0..250.0` lexes float–dotdot–float (kept from E0, extended).
708    #[test]
709    fn float_range_lexes_as_float_dotdot_float() {
710        assert_eq!(
711            significant_kinds("0.0..250.0"),
712            vec![
713                SyntaxKind::FloatNumber,
714                SyntaxKind::DotDot,
715                SyntaxKind::FloatNumber,
716            ]
717        );
718    }
719
720    // Step (d): `km/h` is ident–slash–ident; a regex literal after `=` is one
721    // Regex token.
722    #[test]
723    fn slash_in_unit_is_not_a_regex() {
724        assert_eq!(
725            significant_kinds("km/h"),
726            vec![SyntaxKind::Ident, SyntaxKind::Slash, SyntaxKind::Ident]
727        );
728    }
729
730    #[test]
731    fn regex_literal_after_eq_is_one_token() {
732        let tokens = lex(r"const V = /a\/b/", Profile::Typl);
733        let regex: Vec<_> = tokens
734            .iter()
735            .filter(|t| t.kind == SyntaxKind::Regex)
736            .collect();
737        assert_eq!(regex.len(), 1, "one regex token expected: {tokens:?}");
738        assert_eq!(regex[0].text, r"/a\/b/");
739    }
740
741    #[test]
742    fn unterminated_regex_is_error_to_end_of_line() {
743        let tokens = lex("const V = /abc\n", Profile::Typl);
744        let last_significant = tokens
745            .iter()
746            .rev()
747            .find(|t| !t.kind.is_trivia())
748            .expect("a significant token");
749        assert_eq!(last_significant.kind, SyntaxKind::Error);
750        assert_eq!(last_significant.text, "/abc");
751    }
752
753    #[test]
754    fn inline_match_regex_is_one_token() {
755        // The exact `ridl.std` Date declaration (typl reference Appendix A,
756        // §5.3): the regex literal follows the `match` keyword, not `=`.
757        let line = r"type Date : string [10 match /^\d{4}-\d{2}-\d{2}$/]";
758        let tokens = lex(line, Profile::Typl);
759        assert!(
760            tokens.iter().all(|t| t.kind != SyntaxKind::Error),
761            "no error tokens expected: {tokens:?}",
762        );
763        assert_eq!(
764            significant_kinds(line),
765            vec![
766                SyntaxKind::TypeKw,
767                SyntaxKind::Ident,
768                SyntaxKind::Colon,
769                SyntaxKind::StringKw,
770                SyntaxKind::LBracket,
771                SyntaxKind::IntNumber,
772                SyntaxKind::MatchKw,
773                SyntaxKind::Regex,
774                SyntaxKind::RBracket,
775            ],
776        );
777        let regex: Vec<_> = tokens
778            .iter()
779            .filter(|t| t.kind == SyntaxKind::Regex)
780            .collect();
781        assert_eq!(regex.len(), 1, "one regex token expected: {tokens:?}");
782        assert_eq!(regex[0].text, r"/^\d{4}-\d{2}-\d{2}$/");
783    }
784
785    // Step (e): durations lex as one Duration; a bare atom is an Ident.
786    #[test]
787    fn durations_lex_as_one_token() {
788        assert_eq!(significant_kinds("10ms"), vec![SyntaxKind::Duration]);
789        assert_eq!(significant_kinds("500us"), vec![SyntaxKind::Duration]);
790        assert_eq!(significant_kinds("1s"), vec![SyntaxKind::Duration]);
791        assert_eq!(significant_kinds("1min"), vec![SyntaxKind::Duration]);
792        assert_eq!(significant_kinds("1.5h"), vec![SyntaxKind::Duration]);
793    }
794
795    #[test]
796    fn bare_time_atom_is_an_identifier() {
797        assert_eq!(significant_kinds("min"), vec![SyntaxKind::Ident]);
798        // A space breaks the adjacency a duration needs.
799        assert_eq!(
800            significant_kinds("10 ms"),
801            vec![SyntaxKind::IntNumber, SyntaxKind::Ident]
802        );
803    }
804
805    // Step (f): strings with escapes, and unterminated strings.
806    #[test]
807    fn string_with_escape_is_one_token() {
808        let tokens = lex(r#""a\nb""#, Profile::Typl);
809        assert_eq!(tokens.len(), 1);
810        assert_eq!(tokens[0].kind, SyntaxKind::String);
811        assert_eq!(tokens[0].text, r#""a\nb""#);
812    }
813
814    #[test]
815    fn unterminated_string_is_error_to_end_of_line() {
816        let tokens = lex("\"abc\n", Profile::Typl);
817        assert_eq!(tokens[0].kind, SyntaxKind::Error);
818        assert_eq!(tokens[0].text, "\"abc");
819        // The newline survives as its own trivia token (losslessness).
820        assert_eq!(tokens[1].kind, SyntaxKind::Whitespace);
821    }
822
823    // Step (g): block comments do not nest.
824    #[test]
825    fn block_comment_is_one_token() {
826        let tokens = lex("/* block */", Profile::Typl);
827        assert_eq!(tokens.len(), 1);
828        assert_eq!(tokens[0].kind, SyntaxKind::BlockComment);
829    }
830
831    #[test]
832    fn block_comment_ends_at_first_close() {
833        let tokens = lex("/* /* */", Profile::Typl);
834        assert_eq!(tokens.len(), 1, "no nesting: {tokens:?}");
835        assert_eq!(tokens[0].kind, SyntaxKind::BlockComment);
836        assert_eq!(tokens[0].text, "/* /* */");
837    }
838
839    #[test]
840    fn doc_comments_are_distinct_from_plain_comments() {
841        assert_eq!(significant_kinds("/// doc"), Vec::<SyntaxKind>::new());
842        let doc_line = lex("/// doc", Profile::Typl);
843        assert_eq!(doc_line[0].kind, SyntaxKind::DocComment);
844        let doc_block = lex("/** doc */", Profile::Typl);
845        assert_eq!(doc_block[0].kind, SyntaxKind::DocComment);
846        let plain = lex("// doc", Profile::Typl);
847        assert_eq!(plain[0].kind, SyntaxKind::LineComment);
848    }
849
850    // Step (h): leading zeros lex as one IntNumber; flagging is the parser's job.
851    #[test]
852    fn leading_zeros_lex_as_one_integer() {
853        let tokens = lex("042", Profile::Typl);
854        assert_eq!(tokens.len(), 1);
855        assert_eq!(tokens[0].kind, SyntaxKind::IntNumber);
856        assert_eq!(tokens[0].text, "042");
857    }
858
859    #[test]
860    fn minus_is_its_own_token() {
861        assert_eq!(
862            significant_kinds("-40"),
863            vec![SyntaxKind::Minus, SyntaxKind::IntNumber]
864        );
865    }
866
867    #[test]
868    fn all_new_punctuation_lexes() {
869        assert_eq!(
870            significant_kinds("{}()?;@|%"),
871            vec![
872                SyntaxKind::LBrace,
873                SyntaxKind::RBrace,
874                SyntaxKind::LParen,
875                SyntaxKind::RParen,
876                SyntaxKind::Question,
877                SyntaxKind::Semicolon,
878                SyntaxKind::At,
879                SyntaxKind::Pipe,
880                SyntaxKind::Percent,
881            ]
882        );
883    }
884}