Skip to main content

gdscript_syntax/
lexer.rs

1//! WS1 — the lexer.
2//!
3//! A `logos` DFA lexer turns source bytes into a flat stream of [`RawToken`]s. It
4//! is **lossless**: every byte of the input lands in exactly one token, including
5//! whitespace, comments, and line continuations (trivia are first-class tokens, never
6//! skipped — see `plans/PHASE-1-IMPLEMENTATION-PLAYBOOK.md` §WS1). The lexer is
7//! indentation-unaware; the pre-pass (WS2) turns physical newlines into the synthetic
8//! `Newline`/`Indent`/`Dedent` markers the parser consumes.
9//!
10//! Invariant (tested): `concat(src[t.range] for t in tokenize(src)) == src`.
11
12use logos::{Lexer, Logos};
13use text_size::{TextRange, TextSize};
14
15use crate::SyntaxKind;
16
17/// A lexed token: its [`SyntaxKind`] and the byte range it covers in the source.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct RawToken {
20    /// The token kind (keywords already reclassified from identifiers).
21    pub kind: SyntaxKind,
22    /// The byte range in the original source (`text-size`, `u32`-based).
23    pub range: TextRange,
24}
25
26/// The lexer's own token alphabet. A subset of [`SyntaxKind`]: keywords are lexed as
27/// [`LexKind::Ident`] and reclassified by text (avoids same-priority keyword/ident
28/// ties), and the several string flavours collapse to one of three kinds.
29#[derive(Logos, Debug, Clone, Copy, PartialEq, Eq)]
30enum LexKind {
31    // ---- trivia ----
32    // A UTF-8 BOM (`U+FEFF`). High priority so it wins over any other rule; lexed
33    // wherever it appears (a leading BOM is the real case — Godot strips it), kept as
34    // trivia for losslessness.
35    #[token("\u{feff}", priority = 10)]
36    Bom,
37    #[regex(r"[ \t]+")]
38    Whitespace,
39    #[regex(r"\r\n|\n|\r")]
40    NewlinePhys,
41    #[regex(r"\\(\r\n|\n|\r)")]
42    LineContinuation,
43    // `allow_greedy`: a comment legitimately consumes to end-of-line; the greedy
44    // `[^\r\n]*` scan is the intended (and O(line)) behavior. logos 0.16 requires the
45    // opt-in for any dot-equivalent repetition.
46    #[regex(r"#region[^\r\n]*", priority = 5, allow_greedy = true)]
47    RegionComment,
48    #[regex(r"#endregion[^\r\n]*", priority = 5, allow_greedy = true)]
49    EndRegionComment,
50    #[regex(r"##[^\r\n]*", priority = 4, allow_greedy = true)]
51    DocComment,
52    #[regex(r"#[^\r\n]*", priority = 2, allow_greedy = true)]
53    LineComment,
54
55    // ---- literals & names ----
56    #[regex(r"0[xX][0-9a-fA-F_]+|0[bB][01_]+|[0-9][0-9_]*")]
57    Int,
58    #[regex(r"[0-9][0-9_]*\.[0-9_]*([eE][+-]?[0-9_]+)?|\.[0-9][0-9_]*([eE][+-]?[0-9_]+)?|[0-9][0-9_]*[eE][+-]?[0-9_]+")]
59    Float,
60    // String flavours: single/triple, raw (`r`), all via one scanning callback that
61    // determines the closer from the matched opener slice. Unterminated → consume to
62    // end-of-line (single) or EOF (triple), still emitting a String (lossless).
63    #[token("\"", lex_string)]
64    #[token("'", lex_string)]
65    #[token("\"\"\"", lex_string)]
66    #[token("'''", lex_string)]
67    #[token("r\"", lex_string)]
68    #[token("r'", lex_string)]
69    #[token("r\"\"\"", lex_string)]
70    #[token("r'''", lex_string)]
71    String,
72    #[token("&\"", lex_string)]
73    #[token("&'", lex_string)]
74    StringName,
75    #[token("^\"", lex_string)]
76    #[token("^'", lex_string)]
77    NodePath,
78    // Unicode identifiers (UAX #31 / Godot's `is_unicode_identifier_*`): a `_` or XID_Start, then
79    // XID_Continue. A strict superset of `[A-Za-z_][A-Za-z0-9_]*` on ASCII (so ASCII tokenization is
80    // byte-identical), now also accepting e.g. `café` / non-Latin names — and letting the analyzer
81    // see (and `CONFUSABLE_IDENTIFIER`-check) the mixed-script identifiers Godot accepts.
82    #[regex(r"[_\p{XID_Start}]\p{XID_Continue}*")]
83    Ident,
84
85    // ---- brackets & punctuation ----
86    #[token("(")]
87    LParen,
88    #[token(")")]
89    RParen,
90    #[token("[")]
91    LBrack,
92    #[token("]")]
93    RBrack,
94    #[token("{")]
95    LBrace,
96    #[token("}")]
97    RBrace,
98    #[token(",")]
99    Comma,
100    #[token(":")]
101    Colon,
102    #[token(";")]
103    Semicolon,
104    #[token(".")]
105    Dot,
106    #[token("..")]
107    DotDot,
108    #[token("...")]
109    Ellipsis,
110    #[token("@")]
111    At,
112    #[token("$")]
113    Dollar,
114    #[token("%")]
115    Percent,
116    #[token("&")]
117    Amp,
118    #[token("->")]
119    Arrow,
120    #[token(":=")]
121    ColonEq,
122
123    // ---- operators ----
124    #[token("+")]
125    Plus,
126    #[token("-")]
127    Minus,
128    #[token("*")]
129    Star,
130    #[token("/")]
131    Slash,
132    #[token("**")]
133    StarStar,
134    #[token("=")]
135    Eq,
136    #[token("==")]
137    EqEq,
138    #[token("!=")]
139    Neq,
140    #[token("<")]
141    Lt,
142    #[token(">")]
143    Gt,
144    #[token("<=")]
145    Le,
146    #[token(">=")]
147    Ge,
148    #[token("&&")]
149    AmpAmp,
150    #[token("||")]
151    PipePipe,
152    #[token("!")]
153    Bang,
154    #[token("~")]
155    Tilde,
156    #[token("|")]
157    Pipe,
158    #[token("^")]
159    Caret,
160    #[token("<<")]
161    Shl,
162    #[token(">>")]
163    Shr,
164    #[token("+=")]
165    PlusEq,
166    #[token("-=")]
167    MinusEq,
168    #[token("*=")]
169    StarEq,
170    #[token("/=")]
171    SlashEq,
172    #[token("**=")]
173    StarStarEq,
174    #[token("%=")]
175    PercentEq,
176    #[token("&=")]
177    AmpEq,
178    #[token("|=")]
179    PipeEq,
180    #[token("^=")]
181    CaretEq,
182    #[token("<<=")]
183    ShlEq,
184    #[token(">>=")]
185    ShrEq,
186}
187
188/// Scan a string body after the opening delimiter has been matched. The opener slice
189/// (`"`, `'''`, `r"`, `&'`, …) tells us the quote byte and whether it is a triple
190/// (multiline) string. Backslash escapes the next byte for *termination* purposes in
191/// every flavour (matching Godot/Python: `\"` never closes the string, even raw).
192fn lex_string(lex: &mut Lexer<LexKind>) {
193    let opener = lex.slice().as_bytes();
194    let quote = opener[opener.len() - 1];
195    let triple =
196        opener.len() >= 3 && opener[opener.len() - 2] == quote && opener[opener.len() - 3] == quote;
197
198    let rem = lex.remainder().as_bytes();
199    let n = rem.len();
200    let mut i = 0usize;
201    while i < n {
202        let c = rem[i];
203        if c == b'\\' {
204            i += 2; // skip the escaped byte (may step past `n`; clamped below)
205            continue;
206        }
207        if triple {
208            if c == quote && i + 2 < n && rem[i + 1] == quote && rem[i + 2] == quote {
209                i += 3; // consume the closing triple-quote
210                break;
211            }
212        } else {
213            if c == quote {
214                i += 1; // consume the closing quote
215                break;
216            }
217            if c == b'\n' || c == b'\r' {
218                break; // unterminated single-line string — stop before the newline
219            }
220        }
221        i += 1;
222    }
223    lex.bump(i.min(n));
224}
225
226/// Lex `src` into a lossless [`RawToken`] stream. Never fails: an unlexable byte
227/// becomes a [`SyntaxKind::Error`] token, so the concatenation of token ranges always
228/// reproduces the source.
229#[must_use]
230pub fn tokenize(src: &str) -> Vec<RawToken> {
231    let mut out = Vec::new();
232    let mut lexer = LexKind::lexer(src);
233    while let Some(result) = lexer.next() {
234        let span = lexer.span();
235        let kind = match result {
236            Ok(lex_kind) => map_kind(lex_kind, &src[span.clone()]),
237            Err(()) => SyntaxKind::Error,
238        };
239        out.push(RawToken {
240            kind,
241            range: TextRange::new(text_size(span.start), text_size(span.end)),
242        });
243    }
244    out
245}
246
247/// Convert a byte offset into a `TextSize`, asserting the source fits in `u32`.
248fn text_size(offset: usize) -> TextSize {
249    TextSize::new(u32::try_from(offset).expect("source files must be smaller than 4 GiB"))
250}
251
252/// Map a lexer token kind (plus its text, for identifier reclassification) to the
253/// shared [`SyntaxKind`].
254fn map_kind(kind: LexKind, text: &str) -> SyntaxKind {
255    use LexKind as L;
256    use SyntaxKind as S;
257    match kind {
258        L::Bom => S::Bom,
259        L::Whitespace => S::Whitespace,
260        L::NewlinePhys => S::NewlinePhys,
261        L::LineContinuation => S::LineContinuation,
262        L::RegionComment => S::RegionComment,
263        L::EndRegionComment => S::EndRegionComment,
264        L::DocComment => S::DocComment,
265        L::LineComment => S::LineComment,
266        L::Int => S::Int,
267        L::Float => S::Float,
268        L::String => S::String,
269        L::StringName => S::StringName,
270        L::NodePath => S::NodePath,
271        L::Ident => reclassify_ident(text),
272        L::LParen => S::LParen,
273        L::RParen => S::RParen,
274        L::LBrack => S::LBrack,
275        L::RBrack => S::RBrack,
276        L::LBrace => S::LBrace,
277        L::RBrace => S::RBrace,
278        L::Comma => S::Comma,
279        L::Colon => S::Colon,
280        L::Semicolon => S::Semicolon,
281        L::Dot => S::Dot,
282        L::DotDot => S::DotDot,
283        L::Ellipsis => S::Ellipsis,
284        L::At => S::At,
285        L::Dollar => S::Dollar,
286        L::Percent => S::Percent,
287        L::Amp => S::Amp,
288        L::Arrow => S::Arrow,
289        L::ColonEq => S::ColonEq,
290        L::Plus => S::Plus,
291        L::Minus => S::Minus,
292        L::Star => S::Star,
293        L::Slash => S::Slash,
294        L::StarStar => S::StarStar,
295        L::Eq => S::Eq,
296        L::EqEq => S::EqEq,
297        L::Neq => S::Neq,
298        L::Lt => S::Lt,
299        L::Gt => S::Gt,
300        L::Le => S::Le,
301        L::Ge => S::Ge,
302        L::AmpAmp => S::AmpAmp,
303        L::PipePipe => S::PipePipe,
304        L::Bang => S::Bang,
305        L::Tilde => S::Tilde,
306        L::Pipe => S::Pipe,
307        L::Caret => S::Caret,
308        L::Shl => S::Shl,
309        L::Shr => S::Shr,
310        L::PlusEq => S::PlusEq,
311        L::MinusEq => S::MinusEq,
312        L::StarEq => S::StarEq,
313        L::SlashEq => S::SlashEq,
314        L::StarStarEq => S::StarStarEq,
315        L::PercentEq => S::PercentEq,
316        L::AmpEq => S::AmpEq,
317        L::PipeEq => S::PipeEq,
318        L::CaretEq => S::CaretEq,
319        L::ShlEq => S::ShlEq,
320        L::ShrEq => S::ShrEq,
321    }
322}
323
324/// Reclassify an identifier's text to a keyword / literal-keyword / built-in constant
325/// kind, or [`SyntaxKind::Ident`] if it is an ordinary name. `true`/`false`/`null` are
326/// literals (not keywords) per Godot's tokenizer; `PI`/`TAU`/`INF`/`NAN` are the
327/// engine's built-in constant tokens.
328fn reclassify_ident(text: &str) -> SyntaxKind {
329    use SyntaxKind as S;
330    match text {
331        "if" => S::IfKw,
332        "elif" => S::ElifKw,
333        "else" => S::ElseKw,
334        "for" => S::ForKw,
335        "while" => S::WhileKw,
336        "match" => S::MatchKw,
337        "when" => S::WhenKw,
338        "break" => S::BreakKw,
339        "continue" => S::ContinueKw,
340        "pass" => S::PassKw,
341        "return" => S::ReturnKw,
342        "var" => S::VarKw,
343        "const" => S::ConstKw,
344        "enum" => S::EnumKw,
345        "func" => S::FuncKw,
346        "static" => S::StaticKw,
347        "signal" => S::SignalKw,
348        "class" => S::ClassKw,
349        "class_name" => S::ClassNameKw,
350        "extends" => S::ExtendsKw,
351        "is" => S::IsKw,
352        "in" => S::InKw,
353        "as" => S::AsKw,
354        "self" => S::SelfKw,
355        "super" => S::SuperKw,
356        "void" => S::VoidKw,
357        "await" => S::AwaitKw,
358        "preload" => S::PreloadKw,
359        "assert" => S::AssertKw,
360        "breakpoint" => S::BreakpointKw,
361        "not" => S::NotKw,
362        "and" => S::AndKw,
363        "or" => S::OrKw,
364        "yield" => S::YieldKw,
365        "namespace" => S::NamespaceKw,
366        "trait" => S::TraitKw,
367        "true" => S::True,
368        "false" => S::False,
369        "null" => S::Null,
370        "PI" => S::ConstPi,
371        "TAU" => S::ConstTau,
372        "INF" => S::ConstInf,
373        "NAN" => S::ConstNan,
374        _ => S::Ident,
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    /// The lossless invariant: every byte is covered exactly once, in order.
383    fn assert_lossless(src: &str) {
384        let toks = tokenize(src);
385        // Ranges are contiguous, start at 0, end at len.
386        let mut prev_end = TextSize::new(0);
387        let mut rebuilt = String::new();
388        for t in &toks {
389            assert_eq!(
390                t.range.start(),
391                prev_end,
392                "gap/overlap before {t:?} in {src:?}"
393            );
394            prev_end = t.range.end();
395            rebuilt.push_str(&src[t.range]);
396        }
397        assert_eq!(prev_end, TextSize::of(src), "did not cover to EOF: {src:?}");
398        assert_eq!(rebuilt, src, "round-trip mismatch for {src:?}");
399    }
400
401    fn kinds(src: &str) -> Vec<SyntaxKind> {
402        tokenize(src).into_iter().map(|t| t.kind).collect()
403    }
404
405    #[test]
406    fn lossless_over_a_realistic_snippet() {
407        let src = "## doc\n@export var hp: int = 100 # hi\nfunc _ready() -> void:\n\tprint($Player, %Unique)\n";
408        assert_lossless(src);
409    }
410
411    #[test]
412    fn keywords_and_literals_reclassified() {
413        use SyntaxKind as S;
414        assert_eq!(kinds("func"), vec![S::FuncKw]);
415        assert_eq!(
416            kinds("true false null"),
417            vec![S::True, S::Whitespace, S::False, S::Whitespace, S::Null]
418        );
419        assert_eq!(kinds("PI"), vec![S::ConstPi]);
420        assert_eq!(kinds("my_var"), vec![S::Ident]);
421        assert_eq!(kinds("class_name"), vec![S::ClassNameKw]);
422    }
423
424    #[test]
425    fn unicode_identifiers_lex_as_one_ident() {
426        use SyntaxKind as S;
427        // UAX #31 identifiers (valid in Godot) lex as a single Ident — not split at the non-ASCII
428        // char as the old ASCII-only rule did.
429        assert_eq!(kinds("café"), vec![S::Ident]);
430        // A Latin identifier carrying a Cyrillic homoglyph (`\u{0430}` = `а`) is still ONE Ident, so
431        // the analyzer can flag it as CONFUSABLE_IDENTIFIER.
432        assert_eq!(kinds("p\u{0430}ypal"), vec![S::Ident]);
433    }
434
435    #[test]
436    fn numbers() {
437        use SyntaxKind as S;
438        assert_eq!(kinds("0x8f51"), vec![S::Int]);
439        assert_eq!(kinds("0b1010"), vec![S::Int]);
440        assert_eq!(kinds("12_345"), vec![S::Int]);
441        assert_eq!(kinds("3.14"), vec![S::Float]);
442        assert_eq!(kinds(".5"), vec![S::Float]);
443        assert_eq!(kinds("1."), vec![S::Float]);
444        assert_eq!(kinds("58.1e-10"), vec![S::Float]);
445    }
446
447    #[test]
448    fn strings_all_flavours() {
449        use SyntaxKind as S;
450        assert_eq!(kinds(r#""hello""#), vec![S::String]);
451        assert_eq!(kinds("'world'"), vec![S::String]);
452        assert_eq!(kinds(r#""with \" escape""#), vec![S::String]);
453        assert_eq!(kinds(r#"r"raw\n""#), vec![S::String]);
454        assert_eq!(kinds("\"\"\"multi\nline\"\"\""), vec![S::String]);
455        assert_eq!(kinds(r#"&"sname""#), vec![S::StringName]);
456        assert_eq!(kinds(r#"^"node/path""#), vec![S::NodePath]);
457        // $"x" is two tokens: Dollar then String.
458        assert_eq!(kinds(r#"$"Player""#), vec![S::Dollar, S::String]);
459    }
460
461    #[test]
462    fn unterminated_string_is_lossless() {
463        // Single-line unterminated: stops before the newline, still a String.
464        let src = "\"oops\nok";
465        assert_lossless(src);
466        assert_eq!(kinds(src)[0], SyntaxKind::String);
467        // Triple unterminated: consumes to EOF.
468        assert_lossless("\"\"\"never closed");
469    }
470
471    #[test]
472    fn operators_longest_match() {
473        use SyntaxKind as S;
474        assert_eq!(kinds("**="), vec![S::StarStarEq]);
475        assert_eq!(kinds(">>="), vec![S::ShrEq]);
476        assert_eq!(kinds(":="), vec![S::ColonEq]);
477        assert_eq!(kinds("->"), vec![S::Arrow]);
478        assert_eq!(kinds("..."), vec![S::Ellipsis]);
479        assert_eq!(kinds("&&"), vec![S::AmpAmp]);
480    }
481
482    #[test]
483    fn unlexable_byte_becomes_error_token() {
484        // A stray backtick matches no rule → Error, but still lossless.
485        let src = "a ` b";
486        assert_lossless(src);
487        assert!(kinds(src).contains(&SyntaxKind::Error));
488    }
489
490    #[test]
491    fn comments_distinguished() {
492        use SyntaxKind as S;
493        assert_eq!(kinds("# plain"), vec![S::LineComment]);
494        assert_eq!(kinds("## doc"), vec![S::DocComment]);
495        assert_eq!(kinds("#region A"), vec![S::RegionComment]);
496        assert_eq!(kinds("#endregion"), vec![S::EndRegionComment]);
497    }
498}