Skip to main content

gdscript_hir/
item_tree.rs

1//! The item tree (Playbook §3.1): a signature-level view of one `.gd` file — its
2//! `class_name`, `extends` target, and class members (funcs/vars/consts/signals/enums/inner
3//! classes) — lowered from the CST **without reading any function body**.
4//!
5//! This "no bodies" rule is the Phase-3 cache invariant: editing a function body must not
6//! change the item tree, so signature-derived data (and everything keyed on it) can be
7//! reused across body edits once salsa lands. To keep that promise the tree holds only plain
8//! owned data plus reparse-stable [`AstPtr`]s — never live CST nodes — so it is `Eq` and a
9//! body edit that doesn't move a declaration produces an identical tree.
10
11use std::sync::Arc;
12
13use gdscript_base::TextRange;
14use gdscript_syntax::ast::{self, AstNode};
15use gdscript_syntax::{GdNode, SyntaxKind};
16use smol_str::SmolStr;
17
18use crate::cst::{self, AstPtr};
19
20/// The signature-level model of one file (or one inner class).
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
22pub struct ItemTree {
23    /// The registered global class name (`class_name X`), if any. Always `None` for an
24    /// inner class.
25    pub class_name: Option<SmolStr>,
26    /// The `extends` target, if written.
27    pub extends: Option<ExtendsRef>,
28    /// The class-level annotations (`@tool`, `@icon`, `@static_unload`, `@abstract`) — every
29    /// annotation that is a direct child of the file / inner-class body, in source order. Member
30    /// annotations live on the member; a class annotation that happens to also precede the first
31    /// member appears in both (the checks filter by name).
32    pub annotations: Vec<AnnotationItem>,
33    /// The class members, in source order.
34    pub members: Vec<Member>,
35}
36
37impl ItemTree {
38    /// The first member named `name` (linear scan — member lists are small).
39    #[must_use]
40    pub fn member(&self, name: &str) -> Option<&Member> {
41        self.members.iter().find(|m| m.name() == Some(name))
42    }
43}
44
45/// An `extends` target. Phase 2 only resolves a bare engine-class [`ExtendsRef::Name`]; the
46/// dotted and script-path forms funnel through the Phase-3 seam to `Ty::Unknown`.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ExtendsRef {
49    /// `extends Node` — a bare identifier, resolved against the engine table (else `Unknown`).
50    Name(SmolStr),
51    /// `extends A.B` — a dotted path (namespaced / inner class); `Unknown` in Phase 2.
52    Path(SmolStr),
53    /// `extends "res://x.gd"` — a script path literal; `Unknown` in Phase 2.
54    ScriptPath(SmolStr),
55    /// `extends "res://x.gd".Inner` — a script path **selecting an inner class**. We can't model the
56    /// inner class yet (see `TECH_DEBT`), so this is the seam (`Unknown`) — never the outer script, which
57    /// would wrongly accept the outer class's members. The path is carried for a future inner-class
58    /// resolver. (`SmolStr` is the path part, sans the trailing `.Inner` selectors.)
59    ScriptPathInner(SmolStr),
60}
61
62/// One class member.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Member {
65    /// `func f(...)`.
66    Func(FuncItem),
67    /// `var x`.
68    Var(VarItem),
69    /// `const X`.
70    Const(ConstItem),
71    /// `signal s`.
72    Signal(SignalItem),
73    /// `enum E { ... }` (or an anonymous `enum { ... }`).
74    Enum(EnumItem),
75    /// `class Inner: ...`.
76    Class(InnerClassItem),
77}
78
79impl Member {
80    /// The member's declared name, or `None` for an anonymous enum.
81    #[must_use]
82    pub fn name(&self) -> Option<&str> {
83        match self {
84            Self::Func(f) => Some(&f.name),
85            Self::Var(v) => Some(&v.name),
86            Self::Const(c) => Some(&c.name),
87            Self::Signal(s) => Some(&s.name),
88            Self::Enum(e) => e.name.as_deref(),
89            Self::Class(c) => Some(&c.name),
90        }
91    }
92}
93
94/// A parameter of a function or signal.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct ParamItem {
97    /// The parameter name.
98    pub name: SmolStr,
99    /// The written type annotation (unresolved text, e.g. `"int"`, `"Array[int]"`), if any.
100    pub type_ref: Option<SmolStr>,
101    /// Whether the parameter has a default value (`p := expr` / `p: T = expr`).
102    pub has_default: bool,
103}
104
105/// A decorator annotation (`@export`, `@onready`, `@tool`, …) captured in the item tree. In the CST
106/// annotations are *sibling* nodes of the declaration they decorate (or, for class annotations like
107/// `@tool`, direct children of the file); the item tree lifts them onto the item so checks /
108/// accessors don't re-walk siblings ad hoc.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct AnnotationItem {
111    /// The annotation name without the leading `@` (e.g. `export`, `onready`, `export_range`).
112    pub name: SmolStr,
113    /// The annotation name token's range.
114    pub range: TextRange,
115}
116
117/// Whether `annotations` contains one named exactly `name`.
118#[must_use]
119pub fn has_annotation(annotations: &[AnnotationItem], name: &str) -> bool {
120    annotations.iter().any(|a| a.name == name)
121}
122
123/// A `func` member (signature only — the body is lowered lazily by [`crate::body`]).
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct FuncItem {
126    /// The function name.
127    pub name: SmolStr,
128    /// The parameters, in order.
129    pub params: Vec<ParamItem>,
130    /// The written return-type annotation (unresolved text), if any.
131    pub return_type: Option<SmolStr>,
132    /// Whether this is a `static func`.
133    pub is_static: bool,
134    /// The decorator annotations on this function (`@onready`, `@rpc`, …), in source order.
135    pub annotations: Vec<AnnotationItem>,
136    /// Pointer to the `FuncDecl` node, for body lowering.
137    pub ptr: AstPtr,
138    /// The whole declaration's range.
139    pub range: TextRange,
140    /// The name token's range (the navigation focus).
141    pub name_range: TextRange,
142}
143
144/// A `var` member.
145#[derive(Debug, Clone, PartialEq, Eq)]
146#[allow(
147    clippy::struct_excessive_bools,
148    reason = "independent declaration facts of a `var` (static / exported / has-init / inferred); not a state machine to encode as an enum"
149)]
150pub struct VarItem {
151    /// The variable name.
152    pub name: SmolStr,
153    /// The written type annotation (unresolved text), if any.
154    pub type_ref: Option<SmolStr>,
155    /// Whether this is a `static var`.
156    pub is_static: bool,
157    /// Whether it carries an `@export`/`@export_*` annotation — such a var is surfaced in the editor
158    /// inspector and stored as a `.tscn` node property (the basis for scene-aware rename, W8 A3).
159    /// Derived from [`annotations`](VarItem::annotations).
160    pub is_exported: bool,
161    /// The decorator annotations on this var (`@export`, `@onready`, `@export_range`, …), in order.
162    pub annotations: Vec<AnnotationItem>,
163    /// Whether it has an initializer expression.
164    pub has_init: bool,
165    /// Whether the type was inferred with `:=`.
166    pub is_inferred: bool,
167    /// Pointer to the `VarDecl` node, for initializer inference.
168    pub ptr: AstPtr,
169    /// The whole declaration's range.
170    pub range: TextRange,
171    /// The name token's range.
172    pub name_range: TextRange,
173}
174
175/// A `const` member.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct ConstItem {
178    /// The constant name.
179    pub name: SmolStr,
180    /// The written type annotation (unresolved text), if any.
181    pub type_ref: Option<SmolStr>,
182    /// The `res://` (or relative) path of a `const X = preload("…")` initializer — read at the
183    /// **signature** level (the initializer is directly a `preload` of a string literal). Lets a
184    /// cross-file reference (`other.X`) resolve the const to the preloaded script's `ScriptRef`, which
185    /// the offset-free `script_class` projection otherwise can't (it drops initializers). Firewall-safe:
186    /// a `const` declaration is not a function body, so a body edit leaves it unchanged.
187    pub preload_path: Option<SmolStr>,
188    /// The decorator annotations on this const, in source order.
189    pub annotations: Vec<AnnotationItem>,
190    /// Pointer to the `ConstDecl` node, for value inference.
191    pub ptr: AstPtr,
192    /// The whole declaration's range.
193    pub range: TextRange,
194    /// The name token's range.
195    pub name_range: TextRange,
196}
197
198/// A `signal` member.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct SignalItem {
201    /// The signal name.
202    pub name: SmolStr,
203    /// The typed parameters, in order.
204    pub params: Vec<ParamItem>,
205    /// The decorator annotations on this signal, in source order.
206    pub annotations: Vec<AnnotationItem>,
207    /// The whole declaration's range.
208    pub range: TextRange,
209    /// The name token's range.
210    pub name_range: TextRange,
211}
212
213/// An `enum` member.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct EnumItem {
216    /// The enum name, or `None` for an anonymous `enum { ... }` (whose variants become
217    /// class-level `int` constants).
218    pub name: Option<SmolStr>,
219    /// The variant names, in order.
220    pub variants: Vec<SmolStr>,
221    /// The whole declaration's range.
222    pub range: TextRange,
223    /// The name token's range (the whole `enum` keyword range for an anonymous enum).
224    pub name_range: TextRange,
225}
226
227/// An inner `class` member: its name plus its own (recursively lowered) item tree.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct InnerClassItem {
230    /// The inner class name.
231    pub name: SmolStr,
232    /// The inner class's members + `extends`.
233    pub tree: ItemTree,
234    /// The whole declaration's range.
235    pub range: TextRange,
236    /// The name token's range.
237    pub name_range: TextRange,
238}
239
240/// Lower a parsed file to its [`ItemTree`] (Playbook §3.1). Pure; reads no bodies.
241#[must_use]
242pub fn item_tree(root: &GdNode) -> Arc<ItemTree> {
243    let Some(file) = ast::SourceFile::cast(root.clone()) else {
244        return Arc::new(ItemTree::default());
245    };
246    Arc::new(lower_class(root, file.decls()))
247}
248
249/// Lower a sequence of declarations (a file body or an inner-class body) plus the `extends`
250/// clause found among `container`'s structure into an [`ItemTree`].
251fn lower_class(container: &GdNode, decls: impl Iterator<Item = ast::Decl>) -> ItemTree {
252    let mut tree = ItemTree {
253        extends: find_extends(container),
254        annotations: container_annotations(container),
255        ..ItemTree::default()
256    };
257    for decl in decls {
258        match decl {
259            ast::Decl::ClassName(d) => {
260                if let Some(name) = decl_name(d.name()) {
261                    tree.class_name = Some(name);
262                }
263            }
264            ast::Decl::Func(d) => tree.members.push(Member::Func(lower_func(&d))),
265            ast::Decl::Var(d) => tree.members.push(Member::Var(lower_var(&d))),
266            ast::Decl::Const(d) => tree.members.push(Member::Const(lower_const(&d))),
267            ast::Decl::Signal(d) => tree.members.push(Member::Signal(lower_signal(&d))),
268            ast::Decl::Enum(d) => tree.members.push(Member::Enum(lower_enum(&d))),
269            ast::Decl::Class(d) => {
270                if let Some(item) = lower_inner_class(&d) {
271                    tree.members.push(Member::Class(item));
272                }
273            }
274        }
275    }
276    tree
277}
278
279fn lower_func(d: &ast::FuncDecl) -> FuncItem {
280    let node = d.syntax();
281    FuncItem {
282        name: decl_name(d.name()).unwrap_or_default(),
283        params: d
284            .param_list()
285            .map(|pl| lower_params(&pl))
286            .unwrap_or_default(),
287        return_type: d.return_type().and_then(|t| t.text()).map(SmolStr::new),
288        is_static: d.is_static(),
289        annotations: preceding_annotations(node),
290        ptr: AstPtr::of(node),
291        range: cst::text_range_of(node),
292        name_range: name_range(d.name(), node),
293    }
294}
295
296fn lower_var(d: &ast::VarDecl) -> VarItem {
297    let node = d.syntax();
298    let annotations = preceding_annotations(node);
299    VarItem {
300        name: decl_name(d.name()).unwrap_or_default(),
301        type_ref: d.type_ref().and_then(|t| t.text()).map(SmolStr::new),
302        is_static: d.is_static(),
303        is_exported: is_exported(&annotations),
304        annotations,
305        has_init: cst::first_child_expr(node).is_some(),
306        is_inferred: cst::has_token(node, SyntaxKind::ColonEq),
307        ptr: AstPtr::of(node),
308        range: cst::text_range_of(node),
309        name_range: name_range(d.name(), node),
310    }
311}
312
313/// Whether `annotations` mark the declaration `@export`/`@export_*` (surfaced in the inspector +
314/// stored as a `.tscn` node property — the basis for scene-aware rename, W8 A3).
315fn is_exported(annotations: &[AnnotationItem]) -> bool {
316    annotations
317        .iter()
318        .any(|a| a.name == "export" || a.name.starts_with("export_"))
319}
320
321/// The decorator annotations immediately preceding `node` — a run of sibling `Annotation` nodes, in
322/// source order. A non-annotation sibling ends the run (annotations decorate the declaration that
323/// follows them: `@onready @export var x`).
324fn preceding_annotations(node: &GdNode) -> Vec<AnnotationItem> {
325    let mut out = Vec::new();
326    let mut sib = node.prev_sibling();
327    while let Some(s) = sib {
328        if s.kind() != SyntaxKind::Annotation {
329            break;
330        }
331        if let Some(item) = annotation_item(s) {
332            out.push(item);
333        }
334        sib = s.prev_sibling();
335    }
336    out.reverse(); // collected nearest-first → restore source order
337    out
338}
339
340/// Every `Annotation` that is a direct child of `container` (the file / inner-class body) — the
341/// class-level annotations (`@tool`, `@icon`, `@static_unload`, `@abstract`), in source order.
342fn container_annotations(container: &GdNode) -> Vec<AnnotationItem> {
343    container
344        .children()
345        .filter(|c| c.kind() == SyntaxKind::Annotation)
346        .filter_map(annotation_item)
347        .collect()
348}
349
350/// Lift an `Annotation` CST node to its `(name, name-token range)`, or `None` if it has no name.
351fn annotation_item(ann: &GdNode) -> Option<AnnotationItem> {
352    use cstree::util::NodeOrToken;
353    ann.children_with_tokens()
354        .filter_map(NodeOrToken::into_token)
355        .find(|t| t.kind() == SyntaxKind::Ident)
356        .map(|t| AnnotationItem {
357            name: SmolStr::new(t.text()),
358            range: cst::token_range(t),
359        })
360}
361
362fn lower_const(d: &ast::ConstDecl) -> ConstItem {
363    let node = d.syntax();
364    // The annotation, if any, is the `TypeRef` child (the AST exposes no accessor on
365    // `ConstDecl`, so read it directly).
366    let type_ref = cst::first_child(node, |k| k == SyntaxKind::TypeRef)
367        .and_then(ast::TypeRef::cast)
368        .and_then(|t| t.text())
369        .map(SmolStr::new);
370    ConstItem {
371        name: decl_name(d.name()).unwrap_or_default(),
372        type_ref,
373        preload_path: const_preload_path(node),
374        annotations: preceding_annotations(node),
375        ptr: AstPtr::of(node),
376        range: cst::text_range_of(node),
377        name_range: name_range(d.name(), node),
378    }
379}
380
381/// The `res://` (or relative) path a `const X = preload("…")` aliases, read at the signature level.
382/// The initializer must be **directly** a `preload` of a string literal (so the const aliases exactly
383/// one preloaded script — not a `preload` nested in an array/expression). Mirrors the body lowering's
384/// `PreloadExpr` extraction.
385fn const_preload_path(const_decl: &GdNode) -> Option<SmolStr> {
386    let preload = cst::first_child(const_decl, |k| k == SyntaxKind::PreloadExpr)?;
387    let arg = cst::first_child(&preload, |k| k == SyntaxKind::ArgList)
388        .and_then(|al| cst::first_child_expr(&al))?;
389    if arg.kind() != SyntaxKind::Literal {
390        return None;
391    }
392    cst::child_token_text(&arg, SyntaxKind::String)
393        .map(|s| SmolStr::new(s.trim_matches(['"', '\''])))
394}
395
396fn lower_signal(d: &ast::SignalDecl) -> SignalItem {
397    let node = d.syntax();
398    SignalItem {
399        name: decl_name(d.name()).unwrap_or_default(),
400        params: d
401            .param_list()
402            .map(|pl| lower_params(&pl))
403            .unwrap_or_default(),
404        annotations: preceding_annotations(node),
405        range: cst::text_range_of(node),
406        name_range: name_range(d.name(), node),
407    }
408}
409
410fn lower_enum(d: &ast::EnumDecl) -> EnumItem {
411    let node = d.syntax();
412    EnumItem {
413        name: decl_name(d.name()),
414        variants: d
415            .variants()
416            .filter_map(|v| v.text())
417            .map(SmolStr::new)
418            .collect(),
419        range: cst::text_range_of(node),
420        name_range: name_range(d.name(), node),
421    }
422}
423
424fn lower_inner_class(d: &ast::InnerClassDecl) -> Option<InnerClassItem> {
425    let node = d.syntax();
426    let name = decl_name(d.name())?;
427    let mut tree = d
428        .body()
429        .map(|b| lower_class(b.syntax(), b.decls()))
430        .unwrap_or_default();
431    // An inner class inlines its `extends` directly on the decl (no `ExtendsClause` wrapper),
432    // so resolve it from the decl node rather than the (empty) body result.
433    tree.extends = find_extends(node);
434    Some(InnerClassItem {
435        name,
436        tree,
437        range: cst::text_range_of(node),
438        name_range: name_range(d.name(), node),
439    })
440}
441
442fn lower_params(pl: &ast::ParamList) -> Vec<ParamItem> {
443    pl.params()
444        .map(|p| ParamItem {
445            name: decl_name(p.name()).unwrap_or_default(),
446            type_ref: p.type_ref().and_then(|t| t.text()).map(SmolStr::new),
447            has_default: cst::has_token(p.syntax(), SyntaxKind::ColonEq)
448                || cst::has_token(p.syntax(), SyntaxKind::Eq)
449                || cst::first_child_expr(p.syntax()).is_some(),
450        })
451        .collect()
452}
453
454/// Find the `extends` target of `container`, in either of the two CST shapes the parser
455/// produces: the top-level form wraps it in an `ExtendsClause` child node, while an inner
456/// class inlines the `extends` keyword + target tokens directly on the `InnerClassDecl`. In
457/// both shapes the target tokens (a `String`, or `Ident` (`.` `Ident`)*) are *direct* tokens
458/// of the node we parse — the class name is wrapped in a `Name` node, never a bare token.
459fn find_extends(container: &GdNode) -> Option<ExtendsRef> {
460    if let Some(clause) = cst::first_child(container, |k| k == SyntaxKind::ExtendsClause) {
461        return parse_extends_tokens(&clause);
462    }
463    if cst::has_token(container, SyntaxKind::ExtendsKw) {
464        return parse_extends_tokens(container);
465    }
466    None
467}
468
469/// Parse the `extends` target from a node's direct tokens.
470fn parse_extends_tokens(node: &GdNode) -> Option<ExtendsRef> {
471    // Identifier tokens after the `extends` keyword: the dotted selectors (`A.B`, or the `.Inner`
472    // trailing a string path).
473    let idents: Vec<String> = node
474        .children_with_tokens()
475        .filter_map(cstree::util::NodeOrToken::into_token)
476        .filter(|t| t.kind() == SyntaxKind::Ident)
477        .map(|t| t.text().to_owned())
478        .collect();
479    // A string literal path: `extends "res://x.gd"` — or `extends "res://x.gd".Inner`, which selects an
480    // inner class we can't model yet → the seam (NOT the outer script, which would wrongly accept the
481    // outer class's members).
482    if let Some(s) = cst::child_token_text(node, SyntaxKind::String) {
483        let path = SmolStr::new(s.trim_matches(['"', '\'']));
484        return Some(if idents.is_empty() {
485            ExtendsRef::ScriptPath(path)
486        } else {
487            ExtendsRef::ScriptPathInner(path)
488        });
489    }
490    // Otherwise one or more dotted identifiers: `extends Node` / `extends A.B`.
491    match idents.len() {
492        0 => None,
493        1 => Some(ExtendsRef::Name(SmolStr::new(&idents[0]))),
494        _ => Some(ExtendsRef::Path(SmolStr::new(idents.join(".")))),
495    }
496}
497
498fn decl_name(name: Option<ast::Name>) -> Option<SmolStr> {
499    name.and_then(|n| n.text()).map(SmolStr::new)
500}
501
502/// The focus range: the name token's range, or the whole declaration's range as a fallback
503/// (anonymous enums, recovered declarations).
504///
505/// The lossless tree flushes the inter-token whitespace *before* the identifier into the `Name`
506/// node (the `Name` marker opens before the `Ident`'s advance), so `Name`'s own range carries a
507/// leading-space. Trim it to the bare identifier — navigation uses this as a symbol's focus range
508/// and to tag its own declaration in find-references, both of which must be the exact identifier.
509fn name_range(name: Option<ast::Name>, decl: &GdNode) -> TextRange {
510    name.map_or_else(
511        || cst::text_range_of(decl),
512        |n| trimmed_name_range(n.syntax()),
513    )
514}
515
516/// `Name`'s range with the leading whitespace trivia stripped (see [`name_range`]). A `Name` is
517/// `[leading-trivia][Ident]` — no trailing trivia — so trimming the front yields the identifier.
518fn trimmed_name_range(name_node: &GdNode) -> TextRange {
519    let r = cst::text_range_of(name_node);
520    let text = name_node.text().to_string();
521    let lead = u32::try_from(text.len() - text.trim_start().len()).unwrap_or(0);
522    let len = u32::try_from(text.trim().len()).unwrap_or(0);
523    TextRange::new(r.start + lead, r.start + lead + len)
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use gdscript_syntax::parse;
530
531    fn tree_of(src: &str) -> Arc<ItemTree> {
532        item_tree(&parse(src).syntax_node())
533    }
534
535    #[test]
536    fn class_header_and_members() {
537        let tree = tree_of(
538            "class_name Foo\nextends Node2D\nconst K = 1\nvar x: int\nstatic var s := 2\nsignal hit(dmg: int)\nenum E { A, B }\nfunc f(a: int, b := 1) -> void:\n\tpass\n",
539        );
540        assert_eq!(tree.class_name.as_deref(), Some("Foo"));
541        assert_eq!(tree.extends, Some(ExtendsRef::Name(SmolStr::new("Node2D"))));
542        let names: Vec<_> = tree.members.iter().filter_map(Member::name).collect();
543        assert_eq!(names, vec!["K", "x", "s", "hit", "E", "f"]);
544    }
545
546    #[test]
547    fn func_signature() {
548        let tree = tree_of("func add(a: int, b := 1) -> int:\n\treturn a + b\n");
549        let Member::Func(f) = &tree.members[0] else {
550            panic!("expected func")
551        };
552        assert_eq!(f.name, "add");
553        assert_eq!(f.return_type.as_deref(), Some("int"));
554        assert_eq!(f.params.len(), 2);
555        assert_eq!(f.params[0].type_ref.as_deref(), Some("int"));
556        assert!(!f.params[0].has_default);
557        assert!(f.params[1].has_default);
558    }
559
560    #[test]
561    fn soft_keyword_names_are_not_dropped() {
562        // `match`/`when` are valid identifiers (Godot `is_identifier()` whitelist), so they must
563        // reach the item tree as member / param / variant names — not be dropped as keywords.
564        // Regression for the AST-layer `Name::text()` gap (see `TECH_DEBT.md`).
565        let tree =
566            tree_of("var when := 1\nfunc match(when: int):\n\tpass\nenum E { match, when }\n");
567        let names: Vec<_> = tree.members.iter().filter_map(Member::name).collect();
568        assert_eq!(names, vec!["when", "match", "E"]);
569        let Some(Member::Func(f)) = tree.member("match") else {
570            panic!("expected a func named `match`")
571        };
572        assert_eq!(f.params[0].name, "when");
573        let Some(Member::Enum(e)) = tree.member("E") else {
574            panic!("expected enum E")
575        };
576        assert_eq!(
577            e.variants,
578            vec![SmolStr::new("match"), SmolStr::new("when")]
579        );
580    }
581
582    #[test]
583    fn var_init_and_inference_flags() {
584        let tree = tree_of("var a: int = 1\nvar b := 2\nvar c\nvar d = 3\n");
585        let vars: Vec<&VarItem> = tree
586            .members
587            .iter()
588            .filter_map(|m| match m {
589                Member::Var(v) => Some(v),
590                _ => None,
591            })
592            .collect();
593        // a: explicit type, has init, not inferred
594        assert_eq!(vars[0].type_ref.as_deref(), Some("int"));
595        assert!(vars[0].has_init && !vars[0].is_inferred);
596        // b: `:=` inferred, has init, no annotation
597        assert!(vars[1].type_ref.is_none() && vars[1].has_init && vars[1].is_inferred);
598        // c: no init, no annotation
599        assert!(!vars[2].has_init && vars[2].type_ref.is_none());
600        // d: untyped with init
601        assert!(vars[3].has_init && !vars[3].is_inferred && vars[3].type_ref.is_none());
602    }
603
604    #[test]
605    fn extends_script_path() {
606        let tree = tree_of("extends \"res://player.gd\"\n");
607        assert_eq!(
608            tree.extends,
609            Some(ExtendsRef::ScriptPath(SmolStr::new("res://player.gd")))
610        );
611    }
612
613    #[test]
614    fn extends_script_path_with_inner_class_is_distinguished() {
615        // `extends "res://base.gd".Inner` must NOT collapse to the outer script (which would wrongly
616        // accept the outer class's members); it parses to ScriptPathInner → the seam.
617        let tree = tree_of("extends \"res://base.gd\".Inner\n");
618        assert_eq!(
619            tree.extends,
620            Some(ExtendsRef::ScriptPathInner(SmolStr::new("res://base.gd"))),
621            "the trailing .Inner must be detected, not dropped"
622        );
623    }
624
625    #[test]
626    fn anonymous_enum_has_no_name_but_variants() {
627        let tree = tree_of("enum { RED, GREEN, BLUE }\n");
628        let Member::Enum(e) = &tree.members[0] else {
629            panic!("expected enum")
630        };
631        assert!(e.name.is_none());
632        assert_eq!(
633            e.variants,
634            vec![
635                SmolStr::new("RED"),
636                SmolStr::new("GREEN"),
637                SmolStr::new("BLUE")
638            ]
639        );
640    }
641
642    #[test]
643    fn inner_class_members_and_extends() {
644        let tree = tree_of("class Inner extends RefCounted:\n\tvar y = 2\n\tfunc m():\n\t\tpass\n");
645        let Member::Class(inner) = &tree.members[0] else {
646            panic!("expected inner class")
647        };
648        assert_eq!(inner.name, "Inner");
649        let names: Vec<_> = inner.tree.members.iter().filter_map(Member::name).collect();
650        assert_eq!(names, vec!["y", "m"]);
651        assert_eq!(
652            inner.tree.extends,
653            Some(ExtendsRef::Name(SmolStr::new("RefCounted")))
654        );
655    }
656
657    #[test]
658    fn annotations_are_captured_first_class() {
659        let tree = tree_of(
660            "@tool\nextends Node\n@export var speed = 5\n@onready var label = null\n@rpc(\"any_peer\")\nfunc ping():\n\tpass\n",
661        );
662        // Class-level `@tool`.
663        assert!(
664            has_annotation(&tree.annotations, "tool"),
665            "{:?}",
666            tree.annotations
667        );
668        let var = |name: &str| {
669            tree.members.iter().find_map(|m| match m {
670                Member::Var(v) if v.name == name => Some(v),
671                _ => None,
672            })
673        };
674        let speed = var("speed").unwrap();
675        assert!(has_annotation(&speed.annotations, "export"));
676        assert!(speed.is_exported, "@export derives is_exported");
677        assert!(has_annotation(
678            &var("label").unwrap().annotations,
679            "onready"
680        ));
681        // A function annotation (`@rpc`) with arguments is captured by name.
682        let ping = tree.members.iter().find_map(|m| match m {
683            Member::Func(f) if f.name == "ping" => Some(f),
684            _ => None,
685        });
686        assert!(has_annotation(&ping.unwrap().annotations, "rpc"));
687    }
688
689    #[test]
690    fn ptr_round_trips_to_node() {
691        let parse = parse("func f():\n\tpass\n");
692        let root = parse.syntax_node();
693        let tree = item_tree(&root);
694        let Member::Func(f) = &tree.members[0] else {
695            panic!()
696        };
697        let node = f.ptr.to_node(&root).expect("func node recovered");
698        assert_eq!(node.kind(), SyntaxKind::FuncDecl);
699    }
700}