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 members, in source order.
29    pub members: Vec<Member>,
30}
31
32impl ItemTree {
33    /// The first member named `name` (linear scan — member lists are small).
34    #[must_use]
35    pub fn member(&self, name: &str) -> Option<&Member> {
36        self.members.iter().find(|m| m.name() == Some(name))
37    }
38}
39
40/// An `extends` target. Phase 2 only resolves a bare engine-class [`ExtendsRef::Name`]; the
41/// dotted and script-path forms funnel through the Phase-3 seam to `Ty::Unknown`.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ExtendsRef {
44    /// `extends Node` — a bare identifier, resolved against the engine table (else `Unknown`).
45    Name(SmolStr),
46    /// `extends A.B` — a dotted path (namespaced / inner class); `Unknown` in Phase 2.
47    Path(SmolStr),
48    /// `extends "res://x.gd"` — a script path literal; `Unknown` in Phase 2.
49    ScriptPath(SmolStr),
50    /// `extends "res://x.gd".Inner` — a script path **selecting an inner class**. We can't model the
51    /// inner class yet (see `TECH_DEBT`), so this is the seam (`Unknown`) — never the outer script, which
52    /// would wrongly accept the outer class's members. The path is carried for a future inner-class
53    /// resolver. (`SmolStr` is the path part, sans the trailing `.Inner` selectors.)
54    ScriptPathInner(SmolStr),
55}
56
57/// One class member.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Member {
60    /// `func f(...)`.
61    Func(FuncItem),
62    /// `var x`.
63    Var(VarItem),
64    /// `const X`.
65    Const(ConstItem),
66    /// `signal s`.
67    Signal(SignalItem),
68    /// `enum E { ... }` (or an anonymous `enum { ... }`).
69    Enum(EnumItem),
70    /// `class Inner: ...`.
71    Class(InnerClassItem),
72}
73
74impl Member {
75    /// The member's declared name, or `None` for an anonymous enum.
76    #[must_use]
77    pub fn name(&self) -> Option<&str> {
78        match self {
79            Self::Func(f) => Some(&f.name),
80            Self::Var(v) => Some(&v.name),
81            Self::Const(c) => Some(&c.name),
82            Self::Signal(s) => Some(&s.name),
83            Self::Enum(e) => e.name.as_deref(),
84            Self::Class(c) => Some(&c.name),
85        }
86    }
87}
88
89/// A parameter of a function or signal.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ParamItem {
92    /// The parameter name.
93    pub name: SmolStr,
94    /// The written type annotation (unresolved text, e.g. `"int"`, `"Array[int]"`), if any.
95    pub type_ref: Option<SmolStr>,
96    /// Whether the parameter has a default value (`p := expr` / `p: T = expr`).
97    pub has_default: bool,
98}
99
100/// A `func` member (signature only — the body is lowered lazily by [`crate::body`]).
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct FuncItem {
103    /// The function name.
104    pub name: SmolStr,
105    /// The parameters, in order.
106    pub params: Vec<ParamItem>,
107    /// The written return-type annotation (unresolved text), if any.
108    pub return_type: Option<SmolStr>,
109    /// Whether this is a `static func`.
110    pub is_static: bool,
111    /// Pointer to the `FuncDecl` node, for body lowering.
112    pub ptr: AstPtr,
113    /// The whole declaration's range.
114    pub range: TextRange,
115    /// The name token's range (the navigation focus).
116    pub name_range: TextRange,
117}
118
119/// A `var` member.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct VarItem {
122    /// The variable name.
123    pub name: SmolStr,
124    /// The written type annotation (unresolved text), if any.
125    pub type_ref: Option<SmolStr>,
126    /// Whether this is a `static var`.
127    pub is_static: bool,
128    /// Whether it has an initializer expression.
129    pub has_init: bool,
130    /// Whether the type was inferred with `:=`.
131    pub is_inferred: bool,
132    /// Pointer to the `VarDecl` node, for initializer inference.
133    pub ptr: AstPtr,
134    /// The whole declaration's range.
135    pub range: TextRange,
136    /// The name token's range.
137    pub name_range: TextRange,
138}
139
140/// A `const` member.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ConstItem {
143    /// The constant name.
144    pub name: SmolStr,
145    /// The written type annotation (unresolved text), if any.
146    pub type_ref: Option<SmolStr>,
147    /// The `res://` (or relative) path of a `const X = preload("…")` initializer — read at the
148    /// **signature** level (the initializer is directly a `preload` of a string literal). Lets a
149    /// cross-file reference (`other.X`) resolve the const to the preloaded script's `ScriptRef`, which
150    /// the offset-free `script_class` projection otherwise can't (it drops initializers). Firewall-safe:
151    /// a `const` declaration is not a function body, so a body edit leaves it unchanged.
152    pub preload_path: Option<SmolStr>,
153    /// Pointer to the `ConstDecl` node, for value inference.
154    pub ptr: AstPtr,
155    /// The whole declaration's range.
156    pub range: TextRange,
157    /// The name token's range.
158    pub name_range: TextRange,
159}
160
161/// A `signal` member.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct SignalItem {
164    /// The signal name.
165    pub name: SmolStr,
166    /// The typed parameters, in order.
167    pub params: Vec<ParamItem>,
168    /// The whole declaration's range.
169    pub range: TextRange,
170    /// The name token's range.
171    pub name_range: TextRange,
172}
173
174/// An `enum` member.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct EnumItem {
177    /// The enum name, or `None` for an anonymous `enum { ... }` (whose variants become
178    /// class-level `int` constants).
179    pub name: Option<SmolStr>,
180    /// The variant names, in order.
181    pub variants: Vec<SmolStr>,
182    /// The whole declaration's range.
183    pub range: TextRange,
184    /// The name token's range (the whole `enum` keyword range for an anonymous enum).
185    pub name_range: TextRange,
186}
187
188/// An inner `class` member: its name plus its own (recursively lowered) item tree.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct InnerClassItem {
191    /// The inner class name.
192    pub name: SmolStr,
193    /// The inner class's members + `extends`.
194    pub tree: ItemTree,
195    /// The whole declaration's range.
196    pub range: TextRange,
197    /// The name token's range.
198    pub name_range: TextRange,
199}
200
201/// Lower a parsed file to its [`ItemTree`] (Playbook §3.1). Pure; reads no bodies.
202#[must_use]
203pub fn item_tree(root: &GdNode) -> Arc<ItemTree> {
204    let Some(file) = ast::SourceFile::cast(root.clone()) else {
205        return Arc::new(ItemTree::default());
206    };
207    Arc::new(lower_class(root, file.decls()))
208}
209
210/// Lower a sequence of declarations (a file body or an inner-class body) plus the `extends`
211/// clause found among `container`'s structure into an [`ItemTree`].
212fn lower_class(container: &GdNode, decls: impl Iterator<Item = ast::Decl>) -> ItemTree {
213    let mut tree = ItemTree {
214        extends: find_extends(container),
215        ..ItemTree::default()
216    };
217    for decl in decls {
218        match decl {
219            ast::Decl::ClassName(d) => {
220                if let Some(name) = decl_name(d.name()) {
221                    tree.class_name = Some(name);
222                }
223            }
224            ast::Decl::Func(d) => tree.members.push(Member::Func(lower_func(&d))),
225            ast::Decl::Var(d) => tree.members.push(Member::Var(lower_var(&d))),
226            ast::Decl::Const(d) => tree.members.push(Member::Const(lower_const(&d))),
227            ast::Decl::Signal(d) => tree.members.push(Member::Signal(lower_signal(&d))),
228            ast::Decl::Enum(d) => tree.members.push(Member::Enum(lower_enum(&d))),
229            ast::Decl::Class(d) => {
230                if let Some(item) = lower_inner_class(&d) {
231                    tree.members.push(Member::Class(item));
232                }
233            }
234        }
235    }
236    tree
237}
238
239fn lower_func(d: &ast::FuncDecl) -> FuncItem {
240    let node = d.syntax();
241    FuncItem {
242        name: decl_name(d.name()).unwrap_or_default(),
243        params: d
244            .param_list()
245            .map(|pl| lower_params(&pl))
246            .unwrap_or_default(),
247        return_type: d.return_type().and_then(|t| t.text()).map(SmolStr::new),
248        is_static: d.is_static(),
249        ptr: AstPtr::of(node),
250        range: cst::text_range_of(node),
251        name_range: name_range(d.name(), node),
252    }
253}
254
255fn lower_var(d: &ast::VarDecl) -> VarItem {
256    let node = d.syntax();
257    VarItem {
258        name: decl_name(d.name()).unwrap_or_default(),
259        type_ref: d.type_ref().and_then(|t| t.text()).map(SmolStr::new),
260        is_static: d.is_static(),
261        has_init: cst::first_child_expr(node).is_some(),
262        is_inferred: cst::has_token(node, SyntaxKind::ColonEq),
263        ptr: AstPtr::of(node),
264        range: cst::text_range_of(node),
265        name_range: name_range(d.name(), node),
266    }
267}
268
269fn lower_const(d: &ast::ConstDecl) -> ConstItem {
270    let node = d.syntax();
271    // The annotation, if any, is the `TypeRef` child (the AST exposes no accessor on
272    // `ConstDecl`, so read it directly).
273    let type_ref = cst::first_child(node, |k| k == SyntaxKind::TypeRef)
274        .and_then(ast::TypeRef::cast)
275        .and_then(|t| t.text())
276        .map(SmolStr::new);
277    ConstItem {
278        name: decl_name(d.name()).unwrap_or_default(),
279        type_ref,
280        preload_path: const_preload_path(node),
281        ptr: AstPtr::of(node),
282        range: cst::text_range_of(node),
283        name_range: name_range(d.name(), node),
284    }
285}
286
287/// The `res://` (or relative) path a `const X = preload("…")` aliases, read at the signature level.
288/// The initializer must be **directly** a `preload` of a string literal (so the const aliases exactly
289/// one preloaded script — not a `preload` nested in an array/expression). Mirrors the body lowering's
290/// `PreloadExpr` extraction.
291fn const_preload_path(const_decl: &GdNode) -> Option<SmolStr> {
292    let preload = cst::first_child(const_decl, |k| k == SyntaxKind::PreloadExpr)?;
293    let arg = cst::first_child(&preload, |k| k == SyntaxKind::ArgList)
294        .and_then(|al| cst::first_child_expr(&al))?;
295    if arg.kind() != SyntaxKind::Literal {
296        return None;
297    }
298    cst::child_token_text(&arg, SyntaxKind::String)
299        .map(|s| SmolStr::new(s.trim_matches(['"', '\''])))
300}
301
302fn lower_signal(d: &ast::SignalDecl) -> SignalItem {
303    let node = d.syntax();
304    SignalItem {
305        name: decl_name(d.name()).unwrap_or_default(),
306        params: d
307            .param_list()
308            .map(|pl| lower_params(&pl))
309            .unwrap_or_default(),
310        range: cst::text_range_of(node),
311        name_range: name_range(d.name(), node),
312    }
313}
314
315fn lower_enum(d: &ast::EnumDecl) -> EnumItem {
316    let node = d.syntax();
317    EnumItem {
318        name: decl_name(d.name()),
319        variants: d
320            .variants()
321            .filter_map(|v| v.text())
322            .map(SmolStr::new)
323            .collect(),
324        range: cst::text_range_of(node),
325        name_range: name_range(d.name(), node),
326    }
327}
328
329fn lower_inner_class(d: &ast::InnerClassDecl) -> Option<InnerClassItem> {
330    let node = d.syntax();
331    let name = decl_name(d.name())?;
332    let mut tree = d
333        .body()
334        .map(|b| lower_class(b.syntax(), b.decls()))
335        .unwrap_or_default();
336    // An inner class inlines its `extends` directly on the decl (no `ExtendsClause` wrapper),
337    // so resolve it from the decl node rather than the (empty) body result.
338    tree.extends = find_extends(node);
339    Some(InnerClassItem {
340        name,
341        tree,
342        range: cst::text_range_of(node),
343        name_range: name_range(d.name(), node),
344    })
345}
346
347fn lower_params(pl: &ast::ParamList) -> Vec<ParamItem> {
348    pl.params()
349        .map(|p| ParamItem {
350            name: decl_name(p.name()).unwrap_or_default(),
351            type_ref: p.type_ref().and_then(|t| t.text()).map(SmolStr::new),
352            has_default: cst::has_token(p.syntax(), SyntaxKind::ColonEq)
353                || cst::has_token(p.syntax(), SyntaxKind::Eq)
354                || cst::first_child_expr(p.syntax()).is_some(),
355        })
356        .collect()
357}
358
359/// Find the `extends` target of `container`, in either of the two CST shapes the parser
360/// produces: the top-level form wraps it in an `ExtendsClause` child node, while an inner
361/// class inlines the `extends` keyword + target tokens directly on the `InnerClassDecl`. In
362/// both shapes the target tokens (a `String`, or `Ident` (`.` `Ident`)*) are *direct* tokens
363/// of the node we parse — the class name is wrapped in a `Name` node, never a bare token.
364fn find_extends(container: &GdNode) -> Option<ExtendsRef> {
365    if let Some(clause) = cst::first_child(container, |k| k == SyntaxKind::ExtendsClause) {
366        return parse_extends_tokens(&clause);
367    }
368    if cst::has_token(container, SyntaxKind::ExtendsKw) {
369        return parse_extends_tokens(container);
370    }
371    None
372}
373
374/// Parse the `extends` target from a node's direct tokens.
375fn parse_extends_tokens(node: &GdNode) -> Option<ExtendsRef> {
376    // Identifier tokens after the `extends` keyword: the dotted selectors (`A.B`, or the `.Inner`
377    // trailing a string path).
378    let idents: Vec<String> = node
379        .children_with_tokens()
380        .filter_map(cstree::util::NodeOrToken::into_token)
381        .filter(|t| t.kind() == SyntaxKind::Ident)
382        .map(|t| t.text().to_owned())
383        .collect();
384    // A string literal path: `extends "res://x.gd"` — or `extends "res://x.gd".Inner`, which selects an
385    // inner class we can't model yet → the seam (NOT the outer script, which would wrongly accept the
386    // outer class's members).
387    if let Some(s) = cst::child_token_text(node, SyntaxKind::String) {
388        let path = SmolStr::new(s.trim_matches(['"', '\'']));
389        return Some(if idents.is_empty() {
390            ExtendsRef::ScriptPath(path)
391        } else {
392            ExtendsRef::ScriptPathInner(path)
393        });
394    }
395    // Otherwise one or more dotted identifiers: `extends Node` / `extends A.B`.
396    match idents.len() {
397        0 => None,
398        1 => Some(ExtendsRef::Name(SmolStr::new(&idents[0]))),
399        _ => Some(ExtendsRef::Path(SmolStr::new(idents.join(".")))),
400    }
401}
402
403fn decl_name(name: Option<ast::Name>) -> Option<SmolStr> {
404    name.and_then(|n| n.text()).map(SmolStr::new)
405}
406
407/// The focus range: the name token's range, or the whole declaration's range as a fallback
408/// (anonymous enums, recovered declarations).
409///
410/// The lossless tree flushes the inter-token whitespace *before* the identifier into the `Name`
411/// node (the `Name` marker opens before the `Ident`'s advance), so `Name`'s own range carries a
412/// leading-space. Trim it to the bare identifier — navigation uses this as a symbol's focus range
413/// and to tag its own declaration in find-references, both of which must be the exact identifier.
414fn name_range(name: Option<ast::Name>, decl: &GdNode) -> TextRange {
415    name.map_or_else(
416        || cst::text_range_of(decl),
417        |n| trimmed_name_range(n.syntax()),
418    )
419}
420
421/// `Name`'s range with the leading whitespace trivia stripped (see [`name_range`]). A `Name` is
422/// `[leading-trivia][Ident]` — no trailing trivia — so trimming the front yields the identifier.
423fn trimmed_name_range(name_node: &GdNode) -> TextRange {
424    let r = cst::text_range_of(name_node);
425    let text = name_node.text().to_string();
426    let lead = u32::try_from(text.len() - text.trim_start().len()).unwrap_or(0);
427    let len = u32::try_from(text.trim().len()).unwrap_or(0);
428    TextRange::new(r.start + lead, r.start + lead + len)
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use gdscript_syntax::parse;
435
436    fn tree_of(src: &str) -> Arc<ItemTree> {
437        item_tree(&parse(src).syntax_node())
438    }
439
440    #[test]
441    fn class_header_and_members() {
442        let tree = tree_of(
443            "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",
444        );
445        assert_eq!(tree.class_name.as_deref(), Some("Foo"));
446        assert_eq!(tree.extends, Some(ExtendsRef::Name(SmolStr::new("Node2D"))));
447        let names: Vec<_> = tree.members.iter().filter_map(Member::name).collect();
448        assert_eq!(names, vec!["K", "x", "s", "hit", "E", "f"]);
449    }
450
451    #[test]
452    fn func_signature() {
453        let tree = tree_of("func add(a: int, b := 1) -> int:\n\treturn a + b\n");
454        let Member::Func(f) = &tree.members[0] else {
455            panic!("expected func")
456        };
457        assert_eq!(f.name, "add");
458        assert_eq!(f.return_type.as_deref(), Some("int"));
459        assert_eq!(f.params.len(), 2);
460        assert_eq!(f.params[0].type_ref.as_deref(), Some("int"));
461        assert!(!f.params[0].has_default);
462        assert!(f.params[1].has_default);
463    }
464
465    #[test]
466    fn soft_keyword_names_are_not_dropped() {
467        // `match`/`when` are valid identifiers (Godot `is_identifier()` whitelist), so they must
468        // reach the item tree as member / param / variant names — not be dropped as keywords.
469        // Regression for the AST-layer `Name::text()` gap (see `TECH_DEBT.md`).
470        let tree =
471            tree_of("var when := 1\nfunc match(when: int):\n\tpass\nenum E { match, when }\n");
472        let names: Vec<_> = tree.members.iter().filter_map(Member::name).collect();
473        assert_eq!(names, vec!["when", "match", "E"]);
474        let Some(Member::Func(f)) = tree.member("match") else {
475            panic!("expected a func named `match`")
476        };
477        assert_eq!(f.params[0].name, "when");
478        let Some(Member::Enum(e)) = tree.member("E") else {
479            panic!("expected enum E")
480        };
481        assert_eq!(
482            e.variants,
483            vec![SmolStr::new("match"), SmolStr::new("when")]
484        );
485    }
486
487    #[test]
488    fn var_init_and_inference_flags() {
489        let tree = tree_of("var a: int = 1\nvar b := 2\nvar c\nvar d = 3\n");
490        let vars: Vec<&VarItem> = tree
491            .members
492            .iter()
493            .filter_map(|m| match m {
494                Member::Var(v) => Some(v),
495                _ => None,
496            })
497            .collect();
498        // a: explicit type, has init, not inferred
499        assert_eq!(vars[0].type_ref.as_deref(), Some("int"));
500        assert!(vars[0].has_init && !vars[0].is_inferred);
501        // b: `:=` inferred, has init, no annotation
502        assert!(vars[1].type_ref.is_none() && vars[1].has_init && vars[1].is_inferred);
503        // c: no init, no annotation
504        assert!(!vars[2].has_init && vars[2].type_ref.is_none());
505        // d: untyped with init
506        assert!(vars[3].has_init && !vars[3].is_inferred && vars[3].type_ref.is_none());
507    }
508
509    #[test]
510    fn extends_script_path() {
511        let tree = tree_of("extends \"res://player.gd\"\n");
512        assert_eq!(
513            tree.extends,
514            Some(ExtendsRef::ScriptPath(SmolStr::new("res://player.gd")))
515        );
516    }
517
518    #[test]
519    fn extends_script_path_with_inner_class_is_distinguished() {
520        // `extends "res://base.gd".Inner` must NOT collapse to the outer script (which would wrongly
521        // accept the outer class's members); it parses to ScriptPathInner → the seam.
522        let tree = tree_of("extends \"res://base.gd\".Inner\n");
523        assert_eq!(
524            tree.extends,
525            Some(ExtendsRef::ScriptPathInner(SmolStr::new("res://base.gd"))),
526            "the trailing .Inner must be detected, not dropped"
527        );
528    }
529
530    #[test]
531    fn anonymous_enum_has_no_name_but_variants() {
532        let tree = tree_of("enum { RED, GREEN, BLUE }\n");
533        let Member::Enum(e) = &tree.members[0] else {
534            panic!("expected enum")
535        };
536        assert!(e.name.is_none());
537        assert_eq!(
538            e.variants,
539            vec![
540                SmolStr::new("RED"),
541                SmolStr::new("GREEN"),
542                SmolStr::new("BLUE")
543            ]
544        );
545    }
546
547    #[test]
548    fn inner_class_members_and_extends() {
549        let tree = tree_of("class Inner extends RefCounted:\n\tvar y = 2\n\tfunc m():\n\t\tpass\n");
550        let Member::Class(inner) = &tree.members[0] else {
551            panic!("expected inner class")
552        };
553        assert_eq!(inner.name, "Inner");
554        let names: Vec<_> = inner.tree.members.iter().filter_map(Member::name).collect();
555        assert_eq!(names, vec!["y", "m"]);
556        assert_eq!(
557            inner.tree.extends,
558            Some(ExtendsRef::Name(SmolStr::new("RefCounted")))
559        );
560    }
561
562    #[test]
563    fn ptr_round_trips_to_node() {
564        let parse = parse("func f():\n\tpass\n");
565        let root = parse.syntax_node();
566        let tree = item_tree(&root);
567        let Member::Func(f) = &tree.members[0] else {
568            panic!()
569        };
570        let node = f.ptr.to_node(&root).expect("func node recovered");
571        assert_eq!(node.kind(), SyntaxKind::FuncDecl);
572    }
573}