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    /// The positional element type names of a `## @return-tuple(T0, T1, …)` doc-tag (BUG A3) —
133    /// an analyzer-specific convention letting a library declare a FIXED-SHAPE array return
134    /// (GDScript has no tuple syntax; `-> Array` loses the per-position types). Inert in Godot
135    /// (a doc comment), so annotating never breaks a real build. Resolves to
136    /// [`crate::ty::Ty::Tuple`], so a constant index projects the element's real type
137    /// (`useState(...)[1]` → the setter `Callable`).
138    pub tuple_return: Option<Vec<SmolStr>>,
139    /// Whether this is a `static func`.
140    pub is_static: bool,
141    /// Whether the parameter list ends in a `...rest` vararg (Godot 4.5+). The vararg param
142    /// has no fixed slot, so it is NOT in [`params`](FuncItem::params) — arity checking reads
143    /// this flag to absorb any surplus arguments.
144    pub is_vararg: bool,
145    /// The decorator annotations on this function (`@onready`, `@rpc`, …), in source order.
146    pub annotations: Vec<AnnotationItem>,
147    /// Pointer to the `FuncDecl` node, for body lowering.
148    pub ptr: AstPtr,
149    /// The whole declaration's range.
150    pub range: TextRange,
151    /// The name token's range (the navigation focus).
152    pub name_range: TextRange,
153}
154
155/// A `var` member.
156#[derive(Debug, Clone, PartialEq, Eq)]
157#[allow(
158    clippy::struct_excessive_bools,
159    reason = "independent declaration facts of a `var` (static / exported / has-init / inferred); not a state machine to encode as an enum"
160)]
161pub struct VarItem {
162    /// The variable name.
163    pub name: SmolStr,
164    /// The written type annotation (unresolved text), if any.
165    pub type_ref: Option<SmolStr>,
166    /// Whether this is a `static var`.
167    pub is_static: bool,
168    /// Whether it carries an `@export`/`@export_*` annotation — such a var is surfaced in the editor
169    /// inspector and stored as a `.tscn` node property (the basis for scene-aware rename, W8 A3).
170    /// Derived from [`annotations`](VarItem::annotations).
171    pub is_exported: bool,
172    /// The decorator annotations on this var (`@export`, `@onready`, `@export_range`, …), in order.
173    pub annotations: Vec<AnnotationItem>,
174    /// Whether it has an initializer expression.
175    pub has_init: bool,
176    /// Whether the type was inferred with `:=`.
177    pub is_inferred: bool,
178    /// Pointer to the `VarDecl` node, for initializer inference.
179    pub ptr: AstPtr,
180    /// The whole declaration's range.
181    pub range: TextRange,
182    /// The name token's range.
183    pub name_range: TextRange,
184}
185
186/// A `const` member.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ConstItem {
189    /// The constant name.
190    pub name: SmolStr,
191    /// The written type annotation (unresolved text), if any.
192    pub type_ref: Option<SmolStr>,
193    /// The `res://` (or relative) path of a `const X = preload("…")` initializer — read at the
194    /// **signature** level (the initializer is directly a `preload` of a string literal). Lets a
195    /// cross-file reference (`other.X`) resolve the const to the preloaded script's `ScriptRef`, which
196    /// the offset-free `script_class` projection otherwise can't (it drops initializers). Firewall-safe:
197    /// a `const` declaration is not a function body, so a body edit leaves it unchanged.
198    pub preload_path: Option<SmolStr>,
199    /// The decorator annotations on this const, in source order.
200    pub annotations: Vec<AnnotationItem>,
201    /// Pointer to the `ConstDecl` node, for value inference.
202    pub ptr: AstPtr,
203    /// The whole declaration's range.
204    pub range: TextRange,
205    /// The name token's range.
206    pub name_range: TextRange,
207}
208
209/// A `signal` member.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct SignalItem {
212    /// The signal name.
213    pub name: SmolStr,
214    /// The typed parameters, in order.
215    pub params: Vec<ParamItem>,
216    /// The decorator annotations on this signal, in source order.
217    pub annotations: Vec<AnnotationItem>,
218    /// The whole declaration's range.
219    pub range: TextRange,
220    /// The name token's range.
221    pub name_range: TextRange,
222}
223
224/// An `enum` member.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct EnumItem {
227    /// The enum name, or `None` for an anonymous `enum { ... }` (whose variants become
228    /// class-level `int` constants).
229    pub name: Option<SmolStr>,
230    /// The variant names, in order.
231    pub variants: Vec<SmolStr>,
232    /// The whole declaration's range.
233    pub range: TextRange,
234    /// The name token's range (the whole `enum` keyword range for an anonymous enum).
235    pub name_range: TextRange,
236}
237
238/// An inner `class` member: its name plus its own (recursively lowered) item tree.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct InnerClassItem {
241    /// The inner class name.
242    pub name: SmolStr,
243    /// The inner class's members + `extends`.
244    pub tree: ItemTree,
245    /// The whole declaration's range.
246    pub range: TextRange,
247    /// The name token's range.
248    pub name_range: TextRange,
249}
250
251/// Lower a parsed file to its [`ItemTree`] (Playbook §3.1). Pure; reads no bodies.
252#[must_use]
253pub fn item_tree(root: &GdNode) -> Arc<ItemTree> {
254    let Some(file) = ast::SourceFile::cast(root.clone()) else {
255        return Arc::new(ItemTree::default());
256    };
257    Arc::new(lower_class(root, file.decls()))
258}
259
260/// Lower a sequence of declarations (a file body or an inner-class body) plus the `extends`
261/// clause found among `container`'s structure into an [`ItemTree`].
262fn lower_class(container: &GdNode, decls: impl Iterator<Item = ast::Decl>) -> ItemTree {
263    let mut tree = ItemTree {
264        extends: find_extends(container),
265        annotations: container_annotations(container),
266        ..ItemTree::default()
267    };
268    for decl in decls {
269        match decl {
270            ast::Decl::ClassName(d) => {
271                if let Some(name) = decl_name(d.name()) {
272                    tree.class_name = Some(name);
273                }
274            }
275            ast::Decl::Func(d) => tree.members.push(Member::Func(lower_func(&d))),
276            ast::Decl::Var(d) => tree.members.push(Member::Var(lower_var(&d))),
277            ast::Decl::Const(d) => tree.members.push(Member::Const(lower_const(&d))),
278            ast::Decl::Signal(d) => tree.members.push(Member::Signal(lower_signal(&d))),
279            ast::Decl::Enum(d) => tree.members.push(Member::Enum(lower_enum(&d))),
280            ast::Decl::Class(d) => {
281                if let Some(item) = lower_inner_class(&d) {
282                    tree.members.push(Member::Class(item));
283                }
284            }
285        }
286    }
287    tree
288}
289
290fn lower_func(d: &ast::FuncDecl) -> FuncItem {
291    let node = d.syntax();
292    FuncItem {
293        name: decl_name(d.name()).unwrap_or_default(),
294        params: d
295            .param_list()
296            .map(|pl| lower_params(&pl))
297            .unwrap_or_default(),
298        return_type: d.return_type().and_then(|t| t.text()).map(SmolStr::new),
299        tuple_return: doc_tuple_return(node),
300        is_static: d.is_static(),
301        is_vararg: d.param_list().is_some_and(|pl| {
302            pl.syntax()
303                .children()
304                .any(|c| c.kind() == SyntaxKind::VarargParam)
305        }),
306        annotations: preceding_annotations(node),
307        ptr: AstPtr::of(node),
308        range: cst::text_range_of(node),
309        name_range: name_range(d.name(), node),
310    }
311}
312
313/// Parse a `## @return-tuple(T0, T1, …)` doc-tag from the `FuncDecl`'s leading `##` doc comments
314/// (attached inside the node as leading trivia). At least two comma-separated identifiers are
315/// required — a "tuple" of fewer carries no positional information. `None` when absent/malformed
316/// (a malformed tag degrades to the plain annotation, never an error — it's a comment).
317fn doc_tuple_return(node: &GdNode) -> Option<Vec<SmolStr>> {
318    use cstree::util::NodeOrToken;
319    for el in node.children_with_tokens() {
320        match el {
321            NodeOrToken::Token(t) if t.kind() == SyntaxKind::DocComment => {
322                if let Some(names) = parse_return_tuple_tag(t.text()) {
323                    return Some(names);
324                }
325            }
326            // Leading trivia ends at the first real token/node (`static`/`func`/annotations).
327            // `is_trivia` covers the retained trivia (whitespace, comments, `NewlinePhys`, …);
328            // the zero-width synthetic `Newline` is structural but equally skippable here.
329            NodeOrToken::Token(t) if !t.kind().is_trivia() && t.kind() != SyntaxKind::Newline => {
330                break;
331            }
332            NodeOrToken::Node(_) => break,
333            NodeOrToken::Token(_) => {}
334        }
335    }
336    None
337}
338
339/// The `(T0, T1, …)` names of one doc line's `@return-tuple(...)` tag, or `None` when the line
340/// carries no well-formed tag (a malformed line never aborts the scan of later doc lines).
341fn parse_return_tuple_tag(text: &str) -> Option<Vec<SmolStr>> {
342    let at = text.find("@return-tuple(")?;
343    let rest = &text[at + "@return-tuple(".len()..];
344    let inner = &rest[..rest.find(')')?];
345    let names: Vec<SmolStr> = inner
346        .split(',')
347        .map(str::trim)
348        .filter(|s| !s.is_empty())
349        .map(SmolStr::new)
350        .collect();
351    // At least two comma-separated type names — a "tuple" of fewer carries no positional info.
352    (names.len() >= 2
353        && names.iter().all(|n| {
354            n.chars()
355                .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
356        }))
357    .then_some(names)
358}
359
360fn lower_var(d: &ast::VarDecl) -> VarItem {
361    let node = d.syntax();
362    let annotations = preceding_annotations(node);
363    VarItem {
364        name: decl_name(d.name()).unwrap_or_default(),
365        type_ref: d.type_ref().and_then(|t| t.text()).map(SmolStr::new),
366        is_static: d.is_static(),
367        is_exported: is_exported(&annotations),
368        annotations,
369        has_init: cst::first_child_expr(node).is_some(),
370        is_inferred: cst::has_token(node, SyntaxKind::ColonEq),
371        ptr: AstPtr::of(node),
372        range: cst::text_range_of(node),
373        name_range: name_range(d.name(), node),
374    }
375}
376
377/// Whether `annotations` mark the declaration `@export`/`@export_*` (surfaced in the inspector +
378/// stored as a `.tscn` node property — the basis for scene-aware rename, W8 A3).
379fn is_exported(annotations: &[AnnotationItem]) -> bool {
380    annotations
381        .iter()
382        .any(|a| a.name == "export" || a.name.starts_with("export_"))
383}
384
385/// The decorator annotations immediately preceding `node` — a run of sibling `Annotation` nodes, in
386/// source order. A non-annotation sibling ends the run (annotations decorate the declaration that
387/// follows them: `@onready @export var x`).
388fn preceding_annotations(node: &GdNode) -> Vec<AnnotationItem> {
389    let mut out = Vec::new();
390    let mut sib = node.prev_sibling();
391    while let Some(s) = sib {
392        if s.kind() != SyntaxKind::Annotation {
393            break;
394        }
395        if let Some(item) = annotation_item(s) {
396            out.push(item);
397        }
398        sib = s.prev_sibling();
399    }
400    out.reverse(); // collected nearest-first → restore source order
401    out
402}
403
404/// Every `Annotation` that is a direct child of `container` (the file / inner-class body) — the
405/// class-level annotations (`@tool`, `@icon`, `@static_unload`, `@abstract`), in source order.
406fn container_annotations(container: &GdNode) -> Vec<AnnotationItem> {
407    container
408        .children()
409        .filter(|c| c.kind() == SyntaxKind::Annotation)
410        .filter_map(annotation_item)
411        .collect()
412}
413
414/// Lift an `Annotation` CST node to its `(name, name-token range)`, or `None` if it has no name.
415fn annotation_item(ann: &GdNode) -> Option<AnnotationItem> {
416    use cstree::util::NodeOrToken;
417    ann.children_with_tokens()
418        .filter_map(NodeOrToken::into_token)
419        .find(|t| t.kind() == SyntaxKind::Ident)
420        .map(|t| AnnotationItem {
421            name: SmolStr::new(t.text()),
422            range: cst::token_range(t),
423        })
424}
425
426fn lower_const(d: &ast::ConstDecl) -> ConstItem {
427    let node = d.syntax();
428    // The annotation, if any, is the `TypeRef` child (the AST exposes no accessor on
429    // `ConstDecl`, so read it directly).
430    let type_ref = cst::first_child(node, |k| k == SyntaxKind::TypeRef)
431        .and_then(ast::TypeRef::cast)
432        .and_then(|t| t.text())
433        .map(SmolStr::new);
434    ConstItem {
435        name: decl_name(d.name()).unwrap_or_default(),
436        type_ref,
437        preload_path: const_preload_path(node),
438        annotations: preceding_annotations(node),
439        ptr: AstPtr::of(node),
440        range: cst::text_range_of(node),
441        name_range: name_range(d.name(), node),
442    }
443}
444
445/// The `res://` (or relative) path a `const X = preload("…")` aliases, read at the signature level.
446/// The initializer must be **directly** a `preload` of a string literal (so the const aliases exactly
447/// one preloaded script — not a `preload` nested in an array/expression). Mirrors the body lowering's
448/// `PreloadExpr` extraction.
449fn const_preload_path(const_decl: &GdNode) -> Option<SmolStr> {
450    let preload = cst::first_child(const_decl, |k| k == SyntaxKind::PreloadExpr)?;
451    let arg = cst::first_child(&preload, |k| k == SyntaxKind::ArgList)
452        .and_then(|al| cst::first_child_expr(&al))?;
453    if arg.kind() != SyntaxKind::Literal {
454        return None;
455    }
456    cst::child_token_text(&arg, SyntaxKind::String)
457        .map(|s| SmolStr::new(s.trim_matches(['"', '\''])))
458}
459
460fn lower_signal(d: &ast::SignalDecl) -> SignalItem {
461    let node = d.syntax();
462    SignalItem {
463        name: decl_name(d.name()).unwrap_or_default(),
464        params: d
465            .param_list()
466            .map(|pl| lower_params(&pl))
467            .unwrap_or_default(),
468        annotations: preceding_annotations(node),
469        range: cst::text_range_of(node),
470        name_range: name_range(d.name(), node),
471    }
472}
473
474fn lower_enum(d: &ast::EnumDecl) -> EnumItem {
475    let node = d.syntax();
476    EnumItem {
477        name: decl_name(d.name()),
478        variants: d
479            .variants()
480            .filter_map(|v| v.text())
481            .map(SmolStr::new)
482            .collect(),
483        range: cst::text_range_of(node),
484        name_range: name_range(d.name(), node),
485    }
486}
487
488fn lower_inner_class(d: &ast::InnerClassDecl) -> Option<InnerClassItem> {
489    let node = d.syntax();
490    let name = decl_name(d.name())?;
491    let mut tree = d
492        .body()
493        .map(|b| lower_class(b.syntax(), b.decls()))
494        .unwrap_or_default();
495    // An inner class inlines its `extends` directly on the decl (no `ExtendsClause` wrapper),
496    // so resolve it from the decl node rather than the (empty) body result.
497    tree.extends = find_extends(node);
498    Some(InnerClassItem {
499        name,
500        tree,
501        range: cst::text_range_of(node),
502        name_range: name_range(d.name(), node),
503    })
504}
505
506fn lower_params(pl: &ast::ParamList) -> Vec<ParamItem> {
507    pl.params()
508        .map(|p| ParamItem {
509            name: decl_name(p.name()).unwrap_or_default(),
510            type_ref: p.type_ref().and_then(|t| t.text()).map(SmolStr::new),
511            has_default: cst::has_token(p.syntax(), SyntaxKind::ColonEq)
512                || cst::has_token(p.syntax(), SyntaxKind::Eq)
513                || cst::first_child_expr(p.syntax()).is_some(),
514        })
515        .collect()
516}
517
518/// Find the `extends` target of `container`, in either of the two CST shapes the parser
519/// produces: the top-level form wraps it in an `ExtendsClause` child node, while an inner
520/// class inlines the `extends` keyword + target tokens directly on the `InnerClassDecl`. In
521/// both shapes the target tokens (a `String`, or `Ident` (`.` `Ident`)*) are *direct* tokens
522/// of the node we parse — the class name is wrapped in a `Name` node, never a bare token.
523fn find_extends(container: &GdNode) -> Option<ExtendsRef> {
524    if let Some(clause) = cst::first_child(container, |k| k == SyntaxKind::ExtendsClause) {
525        return parse_extends_tokens(&clause);
526    }
527    if cst::has_token(container, SyntaxKind::ExtendsKw) {
528        return parse_extends_tokens(container);
529    }
530    None
531}
532
533/// Parse the `extends` target from a node's direct tokens.
534fn parse_extends_tokens(node: &GdNode) -> Option<ExtendsRef> {
535    // Identifier tokens after the `extends` keyword: the dotted selectors (`A.B`, or the `.Inner`
536    // trailing a string path).
537    let idents: Vec<String> = node
538        .children_with_tokens()
539        .filter_map(cstree::util::NodeOrToken::into_token)
540        .filter(|t| t.kind() == SyntaxKind::Ident)
541        .map(|t| t.text().to_owned())
542        .collect();
543    // A string literal path: `extends "res://x.gd"` — or `extends "res://x.gd".Inner`, which selects an
544    // inner class we can't model yet → the seam (NOT the outer script, which would wrongly accept the
545    // outer class's members).
546    if let Some(s) = cst::child_token_text(node, SyntaxKind::String) {
547        let path = SmolStr::new(s.trim_matches(['"', '\'']));
548        return Some(if idents.is_empty() {
549            ExtendsRef::ScriptPath(path)
550        } else {
551            ExtendsRef::ScriptPathInner(path)
552        });
553    }
554    // Otherwise one or more dotted identifiers: `extends Node` / `extends A.B`.
555    match idents.len() {
556        0 => None,
557        1 => Some(ExtendsRef::Name(SmolStr::new(&idents[0]))),
558        _ => Some(ExtendsRef::Path(SmolStr::new(idents.join(".")))),
559    }
560}
561
562fn decl_name(name: Option<ast::Name>) -> Option<SmolStr> {
563    name.and_then(|n| n.text()).map(SmolStr::new)
564}
565
566/// The focus range: the name token's range, or the whole declaration's range as a fallback
567/// (anonymous enums, recovered declarations).
568///
569/// The lossless tree flushes the inter-token whitespace *before* the identifier into the `Name`
570/// node (the `Name` marker opens before the `Ident`'s advance), so `Name`'s own range carries a
571/// leading-space. Trim it to the bare identifier — navigation uses this as a symbol's focus range
572/// and to tag its own declaration in find-references, both of which must be the exact identifier.
573fn name_range(name: Option<ast::Name>, decl: &GdNode) -> TextRange {
574    name.map_or_else(
575        || cst::text_range_of(decl),
576        |n| trimmed_name_range(n.syntax()),
577    )
578}
579
580/// `Name`'s range with the leading whitespace trivia stripped (see [`name_range`]). A `Name` is
581/// `[leading-trivia][Ident]` — no trailing trivia — so trimming the front yields the identifier.
582fn trimmed_name_range(name_node: &GdNode) -> TextRange {
583    let r = cst::text_range_of(name_node);
584    let text = name_node.text().to_string();
585    let lead = u32::try_from(text.len() - text.trim_start().len()).unwrap_or(0);
586    let len = u32::try_from(text.trim().len()).unwrap_or(0);
587    TextRange::new(r.start + lead, r.start + lead + len)
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use gdscript_syntax::parse;
594
595    fn tree_of(src: &str) -> Arc<ItemTree> {
596        item_tree(&parse(src).syntax_node())
597    }
598
599    #[test]
600    fn class_header_and_members() {
601        let tree = tree_of(
602            "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",
603        );
604        assert_eq!(tree.class_name.as_deref(), Some("Foo"));
605        assert_eq!(tree.extends, Some(ExtendsRef::Name(SmolStr::new("Node2D"))));
606        let names: Vec<_> = tree.members.iter().filter_map(Member::name).collect();
607        assert_eq!(names, vec!["K", "x", "s", "hit", "E", "f"]);
608    }
609
610    #[test]
611    fn func_signature() {
612        let tree = tree_of("func add(a: int, b := 1) -> int:\n\treturn a + b\n");
613        let Member::Func(f) = &tree.members[0] else {
614            panic!("expected func")
615        };
616        assert_eq!(f.name, "add");
617        assert_eq!(f.return_type.as_deref(), Some("int"));
618        assert_eq!(f.params.len(), 2);
619        assert_eq!(f.params[0].type_ref.as_deref(), Some("int"));
620        assert!(!f.params[0].has_default);
621        assert!(f.params[1].has_default);
622    }
623
624    #[test]
625    fn soft_keyword_names_are_not_dropped() {
626        // `match`/`when` are valid identifiers (Godot `is_identifier()` whitelist), so they must
627        // reach the item tree as member / param / variant names — not be dropped as keywords.
628        // Regression for the AST-layer `Name::text()` gap (see `TECH_DEBT.md`).
629        let tree =
630            tree_of("var when := 1\nfunc match(when: int):\n\tpass\nenum E { match, when }\n");
631        let names: Vec<_> = tree.members.iter().filter_map(Member::name).collect();
632        assert_eq!(names, vec!["when", "match", "E"]);
633        let Some(Member::Func(f)) = tree.member("match") else {
634            panic!("expected a func named `match`")
635        };
636        assert_eq!(f.params[0].name, "when");
637        let Some(Member::Enum(e)) = tree.member("E") else {
638            panic!("expected enum E")
639        };
640        assert_eq!(
641            e.variants,
642            vec![SmolStr::new("match"), SmolStr::new("when")]
643        );
644    }
645
646    #[test]
647    fn var_init_and_inference_flags() {
648        let tree = tree_of("var a: int = 1\nvar b := 2\nvar c\nvar d = 3\n");
649        let vars: Vec<&VarItem> = tree
650            .members
651            .iter()
652            .filter_map(|m| match m {
653                Member::Var(v) => Some(v),
654                _ => None,
655            })
656            .collect();
657        // a: explicit type, has init, not inferred
658        assert_eq!(vars[0].type_ref.as_deref(), Some("int"));
659        assert!(vars[0].has_init && !vars[0].is_inferred);
660        // b: `:=` inferred, has init, no annotation
661        assert!(vars[1].type_ref.is_none() && vars[1].has_init && vars[1].is_inferred);
662        // c: no init, no annotation
663        assert!(!vars[2].has_init && vars[2].type_ref.is_none());
664        // d: untyped with init
665        assert!(vars[3].has_init && !vars[3].is_inferred && vars[3].type_ref.is_none());
666    }
667
668    #[test]
669    fn extends_script_path() {
670        let tree = tree_of("extends \"res://player.gd\"\n");
671        assert_eq!(
672            tree.extends,
673            Some(ExtendsRef::ScriptPath(SmolStr::new("res://player.gd")))
674        );
675    }
676
677    #[test]
678    fn extends_script_path_with_inner_class_is_distinguished() {
679        // `extends "res://base.gd".Inner` must NOT collapse to the outer script (which would wrongly
680        // accept the outer class's members); it parses to ScriptPathInner → the seam.
681        let tree = tree_of("extends \"res://base.gd\".Inner\n");
682        assert_eq!(
683            tree.extends,
684            Some(ExtendsRef::ScriptPathInner(SmolStr::new("res://base.gd"))),
685            "the trailing .Inner must be detected, not dropped"
686        );
687    }
688
689    #[test]
690    fn anonymous_enum_has_no_name_but_variants() {
691        let tree = tree_of("enum { RED, GREEN, BLUE }\n");
692        let Member::Enum(e) = &tree.members[0] else {
693            panic!("expected enum")
694        };
695        assert!(e.name.is_none());
696        assert_eq!(
697            e.variants,
698            vec![
699                SmolStr::new("RED"),
700                SmolStr::new("GREEN"),
701                SmolStr::new("BLUE")
702            ]
703        );
704    }
705
706    #[test]
707    fn inner_class_members_and_extends() {
708        let tree = tree_of("class Inner extends RefCounted:\n\tvar y = 2\n\tfunc m():\n\t\tpass\n");
709        let Member::Class(inner) = &tree.members[0] else {
710            panic!("expected inner class")
711        };
712        assert_eq!(inner.name, "Inner");
713        let names: Vec<_> = inner.tree.members.iter().filter_map(Member::name).collect();
714        assert_eq!(names, vec!["y", "m"]);
715        assert_eq!(
716            inner.tree.extends,
717            Some(ExtendsRef::Name(SmolStr::new("RefCounted")))
718        );
719    }
720
721    #[test]
722    fn annotations_are_captured_first_class() {
723        let tree = tree_of(
724            "@tool\nextends Node\n@export var speed = 5\n@onready var label = null\n@rpc(\"any_peer\")\nfunc ping():\n\tpass\n",
725        );
726        // Class-level `@tool`.
727        assert!(
728            has_annotation(&tree.annotations, "tool"),
729            "{:?}",
730            tree.annotations
731        );
732        let var = |name: &str| {
733            tree.members.iter().find_map(|m| match m {
734                Member::Var(v) if v.name == name => Some(v),
735                _ => None,
736            })
737        };
738        let speed = var("speed").unwrap();
739        assert!(has_annotation(&speed.annotations, "export"));
740        assert!(speed.is_exported, "@export derives is_exported");
741        assert!(has_annotation(
742            &var("label").unwrap().annotations,
743            "onready"
744        ));
745        // A function annotation (`@rpc`) with arguments is captured by name.
746        let ping = tree.members.iter().find_map(|m| match m {
747            Member::Func(f) if f.name == "ping" => Some(f),
748            _ => None,
749        });
750        assert!(has_annotation(&ping.unwrap().annotations, "rpc"));
751    }
752
753    #[test]
754    fn ptr_round_trips_to_node() {
755        let parse = parse("func f():\n\tpass\n");
756        let root = parse.syntax_node();
757        let tree = item_tree(&root);
758        let Member::Func(f) = &tree.members[0] else {
759            panic!()
760        };
761        let node = f.ptr.to_node(&root).expect("func node recovered");
762        assert_eq!(node.kind(), SyntaxKind::FuncDecl);
763    }
764}