Skip to main content

gdscript_syntax/
parser.rs

1//! WS3 — the resilient recursive-descent parser.
2//!
3//! Architecture (matklad's "Resilient LL Parsing", adapted to build a [`cstree`]
4//! tree — see `plans/PHASE-1-IMPLEMENTATION-PLAYBOOK.md` §WS3):
5//!
6//! - The parser walks the **non-trivia** tokens (real tokens + the synthetic
7//!   `Newline`/`Indent`/`Dedent` markers) and emits a flat [`Event`] stream
8//!   (`Open`/`Close`/`Advance`). It never returns `Result`: parsing *always* yields a
9//!   tree plus a list of [`SyntaxError`]s.
10//! - A [`Marker`]/[`MarkClosed`] API lets a node be opened, closed with its final
11//!   kind, or wrapped retroactively (`open_before`) — e.g. promoting an expression to
12//!   a `BinExpr` once an operator is seen.
13//! - A **fuel** counter turns any accidental non-advancing loop into an immediate
14//!   panic the robustness harness catches, instead of a hang.
15//! - The `sink` replays the events over the *full* token stream (trivia included),
16//!   building the lossless green tree and re-attaching trivia.
17//!
18//! The grammar productions live in [`grammar`]; this module owns the machinery.
19
20use std::cell::Cell;
21use std::sync::Arc;
22
23use cstree::Syntax;
24use cstree::build::GreenNodeBuilder;
25use cstree::green::GreenNode;
26use cstree::interning::TokenInterner;
27use cstree::syntax::ResolvedNode;
28use text_size::{TextRange, TextSize};
29
30use crate::SyntaxKind;
31use crate::lexer::{RawToken, tokenize};
32use crate::prepass::run as run_prepass;
33
34mod grammar;
35
36/// The result of parsing a source file: a lossless green tree, the interner needed to
37/// read token text back, and the diagnostics gathered while parsing.
38#[derive(Debug, Clone)]
39pub struct Parse {
40    green: GreenNode,
41    interner: Arc<TokenInterner>,
42    errors: Vec<SyntaxError>,
43}
44
45impl Parse {
46    /// The resolved (interner-carrying) red tree root. Cheap to produce; supports
47    /// `Display`/`.text()` and the byte-exact round-trip.
48    #[must_use]
49    pub fn syntax_node(&self) -> ResolvedNode<SyntaxKind> {
50        ResolvedNode::new_root_with_resolver(self.green.clone(), Arc::clone(&self.interner))
51    }
52
53    /// The parse diagnostics (lexer/parser recovery + indentation issues).
54    #[must_use]
55    pub fn errors(&self) -> &[SyntaxError] {
56        &self.errors
57    }
58
59    /// The raw green tree (position-independent, shared).
60    #[must_use]
61    pub fn green(&self) -> &GreenNode {
62        &self.green
63    }
64
65    /// A stable, indented S-expression dump of the tree (kinds + byte ranges + token
66    /// text) — the golden-fixture review surface.
67    #[must_use]
68    pub fn debug_tree(&self) -> String {
69        cstree::syntax::SyntaxNode::<SyntaxKind>::new_root(self.green.clone())
70            .debug(&self.interner, true)
71    }
72}
73
74/// Equality compares the lossless green tree and the diagnostics; the **interner is excluded**
75/// because it is a derived token-text cache (two parses with equal green trees reference equal
76/// token text). This makes [`Parse`] a sound `salsa` tracked-fn return: an unchanged reparse
77/// *backdates* instead of invalidating dependents — the Phase-3 incrementality precondition
78/// (Playbook §4). `GreenNode` equality is structural, so this is `O(tree)` worst case but
79/// short-circuits on the first difference.
80impl PartialEq for Parse {
81    fn eq(&self, other: &Self) -> bool {
82        self.green == other.green && self.errors == other.errors
83    }
84}
85impl Eq for Parse {}
86
87/// A byte-ranged syntax diagnostic with an "expected X" style message.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct SyntaxError {
90    /// The byte range the error applies to.
91    pub range: TextRange,
92    /// A human-readable message.
93    pub message: String,
94}
95
96/// Parse GDScript source into a lossless [`Parse`]. Never fails.
97#[must_use]
98pub fn parse(text: &str) -> Parse {
99    let raw = tokenize(text);
100    let (tokens, indent_diags) = run_prepass(&raw, text);
101
102    let mut p = Parser::new(text, &tokens);
103    p.source_file();
104    let Parser {
105        events, mut errors, ..
106    } = p;
107
108    errors.extend(indent_diags.into_iter().map(|d| SyntaxError {
109        range: d.range,
110        message: d.message,
111    }));
112
113    let (green, interner) = build_tree(&events, &tokens, text);
114    Parse {
115        green,
116        interner,
117        errors,
118    }
119}
120
121/// A parser event. `Open`'s kind is `Tombstone` until the matching [`Parser::close`]
122/// overwrites it; an `Open` left as `Tombstone` is an abandoned marker the sink skips.
123#[derive(Debug, Clone, Copy)]
124enum Event {
125    Open { kind: SyntaxKind },
126    Close,
127    Advance,
128}
129
130/// A handle to an opened-but-not-yet-closed node (an index into the event list).
131struct Marker {
132    pos: usize,
133}
134
135/// A handle to a closed node, usable to wrap it retroactively via
136/// [`Parser::open_before`].
137#[derive(Clone, Copy)]
138struct MarkClosed {
139    pos: usize,
140}
141
142/// How many times [`Parser::nth`] may be called without an intervening
143/// [`Parser::advance`] before we declare the parser stuck. Generous; only a genuine
144/// non-advancing loop trips it.
145const FUEL: u32 = 256;
146
147struct Parser<'s> {
148    src: &'s str,
149    tokens: &'s [RawToken],
150    /// Indices into `tokens` of the non-trivia tokens the grammar walks.
151    nontrivia: Vec<usize>,
152    /// Cursor into `nontrivia`.
153    pos: usize,
154    fuel: Cell<u32>,
155    events: Vec<Event>,
156    errors: Vec<SyntaxError>,
157}
158
159impl<'s> Parser<'s> {
160    fn new(src: &'s str, tokens: &'s [RawToken]) -> Self {
161        let nontrivia = tokens
162            .iter()
163            .enumerate()
164            .filter(|(_, t)| !t.kind.is_trivia())
165            .map(|(i, _)| i)
166            .collect();
167        Self {
168            src,
169            tokens,
170            nontrivia,
171            pos: 0,
172            fuel: Cell::new(FUEL),
173            events: Vec::new(),
174            errors: Vec::new(),
175        }
176    }
177
178    /// The kind `n` non-trivia tokens ahead (`Eof` past the end). Burns a unit of fuel.
179    fn nth(&self, n: usize) -> SyntaxKind {
180        assert!(self.fuel.get() > 0, "parser stuck at position {}", self.pos);
181        self.fuel.set(self.fuel.get() - 1);
182        self.nontrivia
183            .get(self.pos + n)
184            .map_or(SyntaxKind::Eof, |&i| self.tokens[i].kind)
185    }
186
187    fn at(&self, kind: SyntaxKind) -> bool {
188        self.nth(0) == kind
189    }
190
191    fn at_any(&self, kinds: &[SyntaxKind]) -> bool {
192        kinds.contains(&self.nth(0))
193    }
194
195    fn eof(&self) -> bool {
196        self.pos >= self.nontrivia.len()
197    }
198
199    /// The byte range of the current token (an empty range at EOF), for diagnostics.
200    fn cur_range(&self) -> TextRange {
201        self.nontrivia.get(self.pos).map_or_else(
202            || TextRange::empty(TextSize::of(self.src)),
203            |&i| self.tokens[i].range,
204        )
205    }
206
207    /// The source text of the current token (`""` at EOF) — used for the few
208    /// contextual keywords GDScript lexes as identifiers (`get`/`set`).
209    fn cur_text(&self) -> &str {
210        self.nontrivia
211            .get(self.pos)
212            .map_or("", |&i| &self.src[self.tokens[i].range])
213    }
214
215    fn advance(&mut self) {
216        // A resilient parser treats `advance` at EOF as a no-op: recovery paths may
217        // reach it, and every list/loop re-checks `eof()`, so this can't spin. (Fuel is
218        // only reset on a real advance, so a stuck loop still trips the fuel guard.)
219        if self.eof() {
220            return;
221        }
222        self.fuel.set(FUEL);
223        self.events.push(Event::Advance);
224        self.pos += 1;
225    }
226
227    fn open(&mut self) -> Marker {
228        let m = Marker {
229            pos: self.events.len(),
230        };
231        self.events.push(Event::Open {
232            kind: SyntaxKind::Tombstone,
233        });
234        m
235    }
236
237    // `Marker` is intentionally consumed by value: moving it enforces "close a node
238    // exactly once" at the type level (a used Marker can't be reused or dropped).
239    #[allow(clippy::needless_pass_by_value)]
240    fn close(&mut self, m: Marker, kind: SyntaxKind) -> MarkClosed {
241        self.events[m.pos] = Event::Open { kind };
242        self.events.push(Event::Close);
243        MarkClosed { pos: m.pos }
244    }
245
246    /// Wrap an already-closed node in a new (outer) node — the retroactive-wrap used
247    /// by the Pratt parser to promote operands into `BinExpr`/`CallExpr`/etc.
248    fn open_before(&mut self, m: MarkClosed) -> Marker {
249        self.events.insert(
250            m.pos,
251            Event::Open {
252                kind: SyntaxKind::Tombstone,
253            },
254        );
255        Marker { pos: m.pos }
256    }
257
258    fn eat(&mut self, kind: SyntaxKind) -> bool {
259        if self.at(kind) {
260            self.advance();
261            true
262        } else {
263            false
264        }
265    }
266
267    /// Consume `kind` or record an "expected" diagnostic (without consuming).
268    fn expect(&mut self, kind: SyntaxKind) {
269        if self.eat(kind) {
270            return;
271        }
272        self.error(format!("expected {kind:?}"));
273    }
274
275    /// Record a diagnostic at the current token.
276    fn error(&mut self, message: String) {
277        self.errors.push(SyntaxError {
278            range: self.cur_range(),
279            message,
280        });
281    }
282
283    /// Wrap the current (unexpected) token in an `ErrorNode` and report it — the
284    /// skip-one-token recovery step. Makes progress so loops terminate. Returns the
285    /// closed node so it can be used as an operand placeholder in expression recovery.
286    fn advance_with_error(&mut self, message: &str) -> MarkClosed {
287        let m = self.open();
288        self.error(message.to_owned());
289        if !self.eof() {
290            self.advance();
291        }
292        self.close(m, SyntaxKind::ErrorNode)
293    }
294}
295
296/// Replay the parser events over the full token stream (trivia included) to build the
297/// lossless green tree. Trivia is flushed before each advanced token; trailing trivia
298/// is flushed inside the root just before it closes.
299fn build_tree(events: &[Event], tokens: &[RawToken], src: &str) -> (GreenNode, Arc<TokenInterner>) {
300    let mut builder: GreenNodeBuilder<'static, 'static, SyntaxKind> = GreenNodeBuilder::new();
301    let mut tok = 0usize;
302    let mut depth: u32 = 0;
303
304    for event in events {
305        match *event {
306            Event::Open { kind } => {
307                if kind == SyntaxKind::Tombstone {
308                    continue; // abandoned marker
309                }
310                depth += 1;
311                builder.start_node(kind);
312            }
313            Event::Close => {
314                depth -= 1;
315                if depth == 0 {
316                    // Root closing: flush any remaining tokens (trailing trivia) inside
317                    // it so nothing escapes the single root.
318                    while tok < tokens.len() {
319                        emit(&mut builder, tokens[tok], src);
320                        tok += 1;
321                    }
322                }
323                builder.finish_node();
324            }
325            Event::Advance => {
326                while tok < tokens.len() && tokens[tok].kind.is_trivia() {
327                    emit(&mut builder, tokens[tok], src);
328                    tok += 1;
329                }
330                if tok < tokens.len() {
331                    emit(&mut builder, tokens[tok], src);
332                    tok += 1;
333                }
334            }
335        }
336    }
337
338    let (green, cache) = builder.finish();
339    let interner = cache
340        .expect("a builder created with `new()` owns its cache")
341        .into_interner()
342        .expect("the cache owns its interner");
343    (green, Arc::new(interner))
344}
345
346/// Emit one token into the builder: fixed-lexeme kinds via `static_token`, everything
347/// else (identifiers, literals, trivia, the zero-width synthetic markers) via the
348/// interning `token`.
349fn emit(builder: &mut GreenNodeBuilder<'static, 'static, SyntaxKind>, t: RawToken, src: &str) {
350    if <SyntaxKind as Syntax>::static_text(t.kind).is_some() {
351        builder.static_token(t.kind);
352    } else {
353        builder.token(t.kind, &src[t.range]);
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    fn round_trips(src: &str) {
362        let parse = parse(src);
363        assert_eq!(
364            parse.syntax_node().to_string(),
365            src,
366            "round-trip mismatch for {src:?}",
367        );
368    }
369
370    #[test]
371    fn round_trips_a_function() {
372        round_trips("func f():\n\tpass\n");
373    }
374
375    #[test]
376    fn round_trips_inline_function() {
377        round_trips("func square(a): return a\n");
378    }
379
380    #[test]
381    fn round_trips_with_trivia() {
382        round_trips("## doc\nfunc _ready() -> void:\n\tpass\n\n# trailing comment\n");
383    }
384
385    #[test]
386    fn round_trips_multiple_functions() {
387        round_trips("func a():\n\tpass\nfunc b():\n\tpass\n");
388    }
389
390    #[test]
391    fn round_trips_empty_and_blank() {
392        round_trips("");
393        round_trips("\n\n");
394        round_trips("# only a comment\n");
395    }
396
397    #[test]
398    fn produces_expected_top_level_shape() {
399        let parse = parse("func f():\n\tpass\n");
400        let root = parse.syntax_node();
401        assert_eq!(root.kind(), SyntaxKind::SourceFile);
402        let func = root
403            .children()
404            .find(|n| n.kind() == SyntaxKind::FuncDecl)
405            .expect("a FuncDecl child");
406        assert!(func.children().any(|n| n.kind() == SyntaxKind::Block));
407    }
408
409    /// A node-only S-expression (no tokens, no trivia) — the structural shape, used to
410    /// assert operator precedence/associativity.
411    fn node_sexpr(node: &ResolvedNode<SyntaxKind>) -> String {
412        let mut s = format!("({:?}", node.kind());
413        for child in node.children() {
414            s.push(' ');
415            s.push_str(&node_sexpr(child));
416        }
417        s.push(')');
418        s
419    }
420
421    fn structure(src: &str) -> String {
422        node_sexpr(&parse(src).syntax_node())
423    }
424
425    #[test]
426    fn precedence_factor_binds_tighter_than_add() {
427        // 1 + 2 * 3  →  1 + (2 * 3)
428        assert_eq!(
429            structure("var x = 1 + 2 * 3\n"),
430            "(SourceFile (VarDecl (Name) (BinExpr (Literal) (BinExpr (Literal) (Literal)))))"
431        );
432        // 1 * 2 + 3  →  (1 * 2) + 3
433        assert_eq!(
434            structure("var x = 1 * 2 + 3\n"),
435            "(SourceFile (VarDecl (Name) (BinExpr (BinExpr (Literal) (Literal)) (Literal))))"
436        );
437    }
438
439    #[test]
440    fn power_is_left_associative() {
441        // GDScript: 2 ** 3 ** 4  →  (2 ** 3) ** 4  (unlike Python's right-assoc)
442        assert_eq!(
443            structure("var x = 2 ** 3 ** 4\n"),
444            "(SourceFile (VarDecl (Name) (BinExpr (BinExpr (Literal) (Literal)) (Literal))))"
445        );
446    }
447
448    #[test]
449    fn unary_minus_then_power() {
450        // -2 ** 2  →  -(2 ** 2)  (power binds tighter than the unary sign)
451        assert_eq!(
452            structure("var x = -2 ** 2\n"),
453            "(SourceFile (VarDecl (Name) (UnaryExpr (BinExpr (Literal) (Literal)))))"
454        );
455    }
456
457    #[test]
458    fn ternary_is_right_associative() {
459        assert_eq!(
460            structure("var x = a if c else b\n"),
461            "(SourceFile (VarDecl (Name) (TernaryExpr (NameRef) (NameRef) (NameRef))))"
462        );
463    }
464
465    #[test]
466    fn postfix_chain_call_field_index() {
467        // a.b().c[0]
468        assert_eq!(
469            structure("var x = a.b().c[0]\n"),
470            "(SourceFile (VarDecl (Name) (IndexExpr (FieldExpr (CallExpr (FieldExpr (NameRef) \
471             (NameRef)) (ArgList)) (NameRef)) (Literal))))"
472        );
473    }
474
475    #[test]
476    fn leading_utf8_bom_is_trivia_not_an_error() {
477        // A `.gd` saved with a UTF-8 BOM is valid GDScript (Godot strips it). The BOM must
478        // be lexed as trivia, round-trip byte-for-byte, and NOT produce a parse error at 1:1.
479        let src = "\u{feff}class_name Foo\nextends Node\n";
480        let parse = parse(src);
481        assert_eq!(
482            parse.syntax_node().to_string(),
483            src,
484            "BOM file must round-trip byte-for-byte"
485        );
486        assert!(
487            parse.errors().is_empty(),
488            "BOM-prefixed file should parse clean: {:?}",
489            parse.errors()
490        );
491        // The BOM does not shift the first declaration's indentation: `class_name` is at col 0.
492        assert!(
493            structure(src).starts_with("(SourceFile (ClassNameDecl"),
494            "{}",
495            structure(src)
496        );
497    }
498
499    #[test]
500    fn multiline_lambda_does_not_absorb_following_paren_line() {
501        // A block-body lambda assigned to a var, followed by a statement that begins with
502        // `(`. The dedent ends the lambda; the `(...)` line is its OWN statement — it must
503        // NOT be parsed as a postfix call on the lambda. (Regression: the parser used to
504        // absorb the `(` as `CallExpr(LambdaExpr, …)`.)
505        let src = "func f():\n\tvar cb := func():\n\t\treturn 1\n\t(self).process()\n";
506        let st = structure(src);
507        assert!(
508            st.contains("(VarDecl (Name) (LambdaExpr"),
509            "lambda should be the var initializer, standalone: {st}"
510        );
511        assert!(
512            !st.contains("CallExpr (LambdaExpr"),
513            "the following `(` line must not be absorbed as a call on the lambda: {st}"
514        );
515        // The `(self).process()` line is a separate ExprStmt with its own call chain.
516        assert!(
517            st.contains("(ExprStmt (CallExpr (FieldExpr (ParenExpr"),
518            "the `(self).process()` line should be its own statement: {st}"
519        );
520        round_trips(src);
521    }
522
523    #[test]
524    fn inline_lambda_still_chains_postfix() {
525        // An *inline* (single-line) lambda has no dedent, so a postfix `.call()` on the same
526        // logical line must still chain — the fix only suppresses postfix after a block body.
527        let src = "var x = (func(): return 1).call()\n";
528        let st = structure(src);
529        assert!(
530            st.contains("CallExpr (FieldExpr (ParenExpr (LambdaExpr"),
531            "inline lambda should still accept a postfix chain: {st}"
532        );
533        round_trips(src);
534    }
535
536    /// A broad, realistic GDScript file exercising most of the grammar. The key
537    /// invariant is that it round-trips byte-for-byte and parses without panicking.
538    const CORPUS: &str = r#"@tool
539class_name Player extends CharacterBody2D
540## A documented player controller.
541
542const SPEED := 300.0
543@export var health: int = 100
544@export_range(0, 100) var armor := 0
545static var instances: Array[Player] = []
546
547enum State { IDLE, RUNNING, JUMPING = 10 }
548
549signal died(reason: String)
550
551var _vel: Vector2 = Vector2.ZERO:
552	get:
553		return _vel
554	set(value):
555		_vel = value
556
557class Inner extends RefCounted:
558	var x = 1
559	func helper() -> int:
560		return x * 2
561
562func _ready() -> void:
563	var node := $Sprite2D
564	var unique = %HealthBar
565	add_child(preload("res://thing.tscn").instantiate())
566	for i in range(0, 10):
567		if i % 2 == 0 and i > 0:
568			print(i, " even")
569		elif i == 5:
570			continue
571		else:
572			pass
573	while health > 0:
574		health -= 1
575	match State.IDLE:
576		State.IDLE, State.RUNNING:
577			pass
578		[var first, ..]:
579			print(first)
580		{"key": var v} when v > 0:
581			print(v)
582		_:
583			breakpoint
584	var cb := func(a: int, b := 2) -> int: return a + b
585	var ok = node is Node2D
586	var cast = node as Sprite2D
587	assert(health >= 0, "negative health")
588	died.emit("test")
589"#;
590
591    #[test]
592    fn corpus_round_trips_byte_for_byte() {
593        round_trips(CORPUS);
594    }
595
596    #[test]
597    fn corpus_parses_without_unexpected_errors() {
598        // The corpus is valid GDScript; it should parse with no syntax errors.
599        let parse = parse(CORPUS);
600        assert!(
601            parse.errors().is_empty(),
602            "unexpected parse errors:\n{:#?}",
603            parse.errors()
604        );
605    }
606
607    #[test]
608    fn inline_if_elif_else_clauses_attach() {
609        // Real-corpus regression (ReactiveUI-Godot reconciler.gd / router matcher.gd):
610        // an inline branch body (`if c: stmt`) followed by `elif`/`else` on the next
611        // line. The inline body ends at a logical newline that must not orphan the
612        // clause as a stray statement.
613        let src = "func f():\n\tif a: x = 1\n\telif b: x = 2\n\telse: x = 3\n";
614        let parse = parse(src);
615        assert_eq!(parse.syntax_node().to_string(), src, "lossless");
616        assert!(parse.errors().is_empty(), "no errors: {:?}", parse.errors());
617        let root = parse.syntax_node();
618        let if_stmt = root
619            .descendants()
620            .find(|n| n.kind() == SyntaxKind::IfStmt)
621            .expect("an IfStmt node");
622        assert!(
623            if_stmt
624                .descendants()
625                .any(|n| n.kind() == SyntaxKind::ElifClause),
626            "elif clause attached to the if"
627        );
628        assert!(
629            if_stmt
630                .descendants()
631                .any(|n| n.kind() == SyntaxKind::ElseClause),
632            "else clause attached to the if"
633        );
634    }
635
636    #[test]
637    fn soft_keyword_names_parse() {
638        // Real-corpus regression (ReactiveUI-Godot router): Godot's `is_identifier()` /
639        // `is_node_name()` soft keywords used as identifiers — `match` as a function
640        // name and a member name, `when` as a parameter and an identifier expression.
641        let src = "static func match(when: bool) -> int:\n\tvar r = RUIRouteMatcher.match(when)\n\treturn when\n";
642        let parse = parse(src);
643        assert_eq!(parse.syntax_node().to_string(), src, "lossless");
644        assert!(parse.errors().is_empty(), "no errors: {:?}", parse.errors());
645    }
646
647    #[test]
648    fn multiline_lambda_with_trailing_call_paren() {
649        // Real-corpus regression (ReactiveUI-Godot media.gd): a multiline lambda whose
650        // enclosing call paren closes on the body's last line (`call(func(): … last())`).
651        let src = "func f():\n\tt.connect(func():\n\t\tif ok:\n\t\t\tp.free())\n";
652        let parse = parse(src);
653        assert_eq!(parse.syntax_node().to_string(), src, "lossless");
654        assert!(parse.errors().is_empty(), "no errors: {:?}", parse.errors());
655    }
656
657    #[test]
658    fn multiline_lambda_in_call_argument_parses() {
659        // The fixed lambda-in-brackets case: a multiline lambda body inside a call.
660        let src = "func f():\n\tcb(func(a, b):\n\t\treturn a + b\n\t)\n";
661        let parse = parse(src);
662        assert_eq!(parse.syntax_node().to_string(), src, "lossless");
663        assert!(parse.errors().is_empty(), "no errors: {:?}", parse.errors());
664        let root = parse.syntax_node();
665        let lambda = root
666            .descendants()
667            .find(|n| n.kind() == SyntaxKind::LambdaExpr)
668            .expect("a LambdaExpr node");
669        let block = lambda
670            .children()
671            .find(|n| n.kind() == SyntaxKind::Block)
672            .expect("the lambda body Block");
673        assert!(
674            block
675                .descendants()
676                .any(|n| n.kind() == SyntaxKind::ReturnStmt),
677            "the lambda body contains the return statement"
678        );
679    }
680
681    #[test]
682    fn single_line_lambda_in_call_argument_parses() {
683        // The body stops at the call's `)` (parser inline-block fix).
684        let src = "var m = arr.map(func(x): x * 2)\n";
685        let parse = parse(src);
686        assert_eq!(parse.syntax_node().to_string(), src, "lossless");
687        assert!(parse.errors().is_empty(), "no errors: {:?}", parse.errors());
688        assert!(
689            parse
690                .syntax_node()
691                .descendants()
692                .any(|n| n.kind() == SyntaxKind::LambdaExpr),
693            "a LambdaExpr node"
694        );
695    }
696
697    #[test]
698    fn broken_code_recovers_and_round_trips() {
699        // A malformed parameter list: a tree is still produced, errors are reported,
700        // siblings still parse, and the source round-trips.
701        let src = "func ok():\n\tpass\nfunc bad(:\n\tpass\nfunc also_ok():\n\tpass\n";
702        let parse = parse(src);
703        assert_eq!(
704            parse.syntax_node().to_string(),
705            src,
706            "recovery must stay lossless"
707        );
708        assert!(!parse.errors().is_empty(), "expected a syntax error");
709        // The two well-formed functions are still recognized.
710        let funcs = parse
711            .syntax_node()
712            .children()
713            .filter(|n| n.kind() == SyntaxKind::FuncDecl)
714            .count();
715        assert!(
716            funcs >= 2,
717            "siblings should survive a broken declaration, got {funcs}"
718        );
719    }
720
721    #[test]
722    fn golden_small_class() {
723        let parse = parse("class_name Foo\nvar x := 1\n");
724        expect_test::expect_file!["../test_data/golden/small_class.cst"]
725            .assert_eq(&parse.debug_tree());
726    }
727}