Skip to main content

gdscript_hir/
def.rs

1//! Canonical symbol identity + cursor classification (Playbook §3.M5) — the basis of cross-file
2//! navigation (find-references, rename, goto-definition).
3//!
4//! [`GodotDef`] is the analyzer's analogue of rust-analyzer's `Definition`: a **stable identity**
5//! for a renameable/findable symbol, keyed on declaration site (file + name / body location),
6//! **never on the name string alone**. [`classify`] is the inverse of inference — it does the same
7//! local → member → inherited → global → autoload → engine lookup [`crate::infer`] does, but
8//! returns the *declaration identity* instead of the type. Find-references resolves the cursor to
9//! a `GodotDef`, then keeps only other tokens that classify to the **same** `GodotDef` (resolve,
10//! don't string-match), so two unrelated `i`s, `A.update` vs `B.update`, or a local shadowing a
11//! member are distinct by construction.
12//!
13//! GDScript forbids two same-named members in one class, so a [`GodotDef::Member`] is identified by
14//! `(owner_file, name)` alone — no member *kind* in the identity (which keeps decl-site and
15//! reference-site classification consistent; the kind is recovered from the item tree for display).
16
17use gdscript_base::{FileId, FilePosition, TextRange};
18use gdscript_db::{Db, FileText, parse};
19use gdscript_scene::NodeIdx;
20use gdscript_syntax::{GdNode, GdToken, SyntaxKind, ast};
21use smol_str::SmolStr;
22
23use crate::cst;
24use crate::ty::Ty;
25
26/// The canonical identity of a findable / renameable symbol. Equality is on **identity**, not the
27/// name string (rust-analyzer's `Definition`).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum GodotDef {
30    /// A `class_name` global. Identity = the one file that declares it.
31    Global {
32        /// The declaring file.
33        decl_file: FileId,
34        /// The class name.
35        name: SmolStr,
36    },
37    /// A script member (func / var / const / signal / enum / inner class). Identity = the script
38    /// file that *declares* it (for an inherited member, the base file where it is found) + name.
39    Member {
40        /// The file declaring the member.
41        owner_file: FileId,
42        /// The member name.
43        name: SmolStr,
44    },
45    /// A local binding (var / param / `for`-var). Identity = the owning function body + the
46    /// binding's declaration-site name range. Two `i`s in different functions, or a local
47    /// shadowing a member, are distinct by construction.
48    Local {
49        /// The file the body lives in.
50        body_file: FileId,
51        /// The enclosing function/initializer unit's range.
52        body_range: TextRange,
53        /// The binding's declaration name-token range.
54        decl_name_range: TextRange,
55    },
56    /// An autoload **singleton** (the `*`-flagged `[autoload]` name; project-unique).
57    Autoload {
58        /// The autoload name.
59        name: SmolStr,
60        /// The `.gd` it points to, if resolvable (`None` for a `.tscn`/non-`.gd` target).
61        target_file: Option<FileId>,
62    },
63    /// An engine / builtin symbol (`Node`, `Vector2`, a builtin func, …) — resolved, but **not**
64    /// ours to rename, and find-references over it is out of scope. Distinguishes "resolved, it's
65    /// engine" from "unresolved" (the latter is `None`).
66    Engine {
67        /// The engine symbol name.
68        name: SmolStr,
69    },
70    /// A node declared in a scene (`.tscn`), referenced by `$Path` / `%Unique` / `get_node("…")` in
71    /// scripts and by its `name="…"` in the scene. Identity = the owning scene + the node's full
72    /// name-path from (and including) the scene root — unique within a scene, stable across an edit.
73    /// The node's renamed name is the path's **last** segment.
74    SceneNode {
75        /// The owning `.tscn`.
76        scene: FileId,
77        /// The node's full path from (and including) the scene root (e.g. `"Main/Panel/StartButton"`).
78        path: SmolStr,
79    },
80}
81
82impl GodotDef {
83    /// The symbol's name — the cheap text pre-filter key for find-references.
84    #[must_use]
85    pub fn name(&self) -> &str {
86        match self {
87            Self::Global { name, .. }
88            | Self::Member { name, .. }
89            | Self::Autoload { name, .. }
90            | Self::Engine { name } => name,
91            // A node's own name is the last segment of its full path (e.g. `StartButton`).
92            Self::SceneNode { path, .. } => path.rsplit('/').next().unwrap_or(path),
93            Self::Local { .. } => "", // filled by the caller from the decl range
94        }
95    }
96
97    /// Whether this symbol can be renamed at all (engine/builtin symbols cannot).
98    #[must_use]
99    pub fn is_renameable(&self) -> bool {
100        !matches!(self, Self::Engine { .. })
101    }
102}
103
104/// Classify the symbol the cursor (`pos`) sits on — the single entry point find-references and
105/// goto-definition share. `None` for a non-identifier token, or a reference whose target cannot be
106/// resolved (the seam — we never guess an identity).
107#[must_use]
108pub fn classify(db: &dyn Db, pos: FilePosition) -> Option<GodotDef> {
109    let ft = db.file_text(pos.file)?;
110    // A scene file (`.tscn`/`.tres`) is not GDScript: the only classifiable position is a node's
111    // `name="…"` value, which identifies that scene node (the rename target for `$Path` references).
112    if ft
113        .res_path(db)
114        .as_deref()
115        .is_some_and(crate::queries::is_scene_path)
116    {
117        return classify_scene_name(db, ft, pos.file, pos.offset);
118    }
119    let root = parse(db, ft).syntax_node();
120    let tok = ast::token_at(&root, pos.offset.into())?;
121    // The cursor inside a `$Path`/`%Unique` expression identifies the scene node at the segment under
122    // it (a mid-path segment resolves the path *up to* there) — so a node can be renamed from a script.
123    if let Some(def) = classify_node_path_at(db, ft, pos.file, &tok, pos.offset) {
124        return Some(def);
125    }
126    // The same, for the call form `get_node("Panel/Btn")` (a `CallExpr`, not a `GetNodeExpr`).
127    if let Some(def) = classify_get_node_call_at(db, ft, &tok, pos.offset) {
128        return Some(def);
129    }
130    let parent = tok.parent();
131    // Identifiers are symbols. The soft keywords `match`/`when` are too — but ONLY in a name
132    // position (a `Name` decl, or a `NameRef`/`FieldExpr` member). A bare `match` *statement* keyword
133    // (parent = `MatchStmt`) must stay a non-symbol, else the cursor on it would falsely resolve to a
134    // same-named declaration. (Mirrors the grammar's `at_name` whitelist; see `TECH_DEBT.md`.)
135    let is_name_tok = tok.kind() == SyntaxKind::Ident
136        || (matches!(tok.kind(), SyntaxKind::MatchKw | SyntaxKind::WhenKw)
137            && matches!(parent.kind(), SyntaxKind::Name | SyntaxKind::NameRef));
138    if !is_name_tok {
139        return None; // keywords / punctuation are not symbols
140    }
141    let name = SmolStr::new(tok.text());
142    let tok_range = cst::token_range(&tok);
143
144    // (A) Declaration sites: the cursor is on the `Name` token of a declaration.
145    if parent.kind() == SyntaxKind::Name
146        && let Some(def) = classify_decl(db, ft, pos.file, parent, &name, tok_range)
147    {
148        return Some(def);
149    }
150    // (A2) The head name of an `extends Base` clause. Its `Ident` is a *bare* child of the
151    //      `ExtendsClause` / `ClassNameDecl` / inner-class decl — not wrapped in a `Name` or
152    //      `TypeRef` node — so neither (A) nor (B) catches it. Resolve it as a type name so a
153    //      `class_name`'s find-references / rename includes every `extends ThatClass` reference
154    //      (else a rename silently leaves the `extends` stale — an incomplete, corrupting edit).
155    if let Some(head) = cst::extends_head_token(parent)
156        && cst::token_range(&head) == tok_range
157    {
158        return classify_type_name(db, &name);
159    }
160    // (A3) An anonymous-enum variant declaration (`enum { FIRE }`): a bare `Ident` under an
161    //      `EnumVariant` whose enum has no name. Such a variant is a class-level `int` constant, so
162    //      it shares the Member identity space — resolve to Member{file, name} so find-refs / goto
163    //      reach it. (A *named* enum's variants are accessed as `Enum.NAME`, not as bare class-level
164    //      names, so they are out of scope here.)
165    if parent.kind() == SyntaxKind::EnumVariant && in_anon_enum(parent) {
166        return Some(GodotDef::Member {
167            owner_file: pos.file,
168            name,
169        });
170    }
171    // (B) A type reference (`var x: Foo`, `is Foo`, `as Foo`): the token is inside a `TypeRef`.
172    //     Resolve the type name to a class_name global or an engine class.
173    if has_ancestor(&tok, SyntaxKind::TypeRef) {
174        return classify_type_name(db, &name);
175    }
176    // (C-guard) A *reference* inside an inner `class Name:` body (a `self.member` / bare member ref in
177    // an inner method, now that inner bodies are inferred — burndown 4.24 inc.2) resolves against the
178    // INNER scope, which `resolve_name_to_def` doesn't model yet: it would mis-resolve a bare inner
179    // member to a top-level same-named one, corrupting that top member's rename. So navigation stays
180    // **correct-or-refuse** inside inner-class bodies until the inner-scope-aware resolution lands
181    // (4.24 inc.3). The inner class's own *name* and its member *declarations* are handled by
182    // `classify_decl` above (an inner decl already refuses there) — this guards only the body refs.
183    if has_ancestor(&tok, SyntaxKind::InnerClassDecl) {
184        return None;
185    }
186    // (C) A reference inside a function body / field initializer (a `NameRef`, or the member token
187    //     of a `FieldExpr`). Resolve through the inference units.
188    classify_body_ref(db, ft, pos.file, pos.offset, &name)
189}
190
191/// Classify a declaration-site name (`parent` is the `Name` node; its parent is the decl).
192fn classify_decl(
193    db: &dyn Db,
194    ft: FileText,
195    file: FileId,
196    name_node: &GdNode,
197    name: &SmolStr,
198    tok_range: TextRange,
199) -> Option<GodotDef> {
200    let decl = name_node.parent()?;
201    // A `var`/`const` nested inside a function, a property accessor (`get`/`set`), or a lambda body
202    // is a LOCAL — not a class member. (A `FuncDecl` ancestor alone misses an accessor-body or a
203    // class-level-lambda-body local, which would otherwise be mis-typed as a `Member`.)
204    let in_body = node_has_ancestor(decl, SyntaxKind::FuncDecl)
205        || node_has_ancestor(decl, SyntaxKind::Getter)
206        || node_has_ancestor(decl, SyntaxKind::Setter)
207        || node_has_ancestor(decl, SyntaxKind::LambdaExpr);
208    // A declaration nested inside a `class Inner:` body is an inner-class member. Its `(file, name)`
209    // identity would collide with a same-named TOP-LEVEL member, letting find-refs / rename cross
210    // between two unrelated classes (a silent corrupting edit). Inner-class member identity isn't
211    // modeled yet (item_tree stores inner members separately), so treat them as out of scope —
212    // navigation refuses rather than mis-resolves. (The inner class's own *name* is unaffected: its
213    // decl node IS the `InnerClassDecl`, whose ancestor walk starts *above* it.)
214    let in_inner_class = node_has_ancestor(decl, SyntaxKind::InnerClassDecl);
215    match decl.kind() {
216        SyntaxKind::ClassNameDecl => Some(GodotDef::Global {
217            decl_file: file,
218            name: name.clone(),
219        }),
220        // A parameter, `for`-loop variable, or `match`-pattern `var` capture is always a local.
221        SyntaxKind::Param | SyntaxKind::ForStmt | SyntaxKind::PatternBind => {
222            local_def(db, ft, file, tok_range)
223        }
224        // A `var`/`const` inside a body is a local.
225        SyntaxKind::VarDecl | SyntaxKind::ConstDecl if in_body => {
226            local_def(db, ft, file, tok_range)
227        }
228        // Otherwise a class-level member — but only of the top-level class (inner-class members are
229        // out of scope, see above).
230        SyntaxKind::FuncDecl
231        | SyntaxKind::SignalDecl
232        | SyntaxKind::EnumDecl
233        | SyntaxKind::InnerClassDecl
234        | SyntaxKind::VarDecl
235        | SyntaxKind::ConstDecl
236            if !in_inner_class =>
237        {
238            Some(GodotDef::Member {
239                owner_file: file,
240                name: name.clone(),
241            })
242        }
243        _ => None,
244    }
245}
246
247/// Build a [`GodotDef::Local`] for the binding whose decl-name is at `tok_range`. The identity uses
248/// the **binding's** `name_range` (via `binding_at`), so a declaration cursor and a reference (which
249/// resolves to the same binding) produce the *same* `Local` — even if the raw token range and the
250/// lowered binding range differ.
251fn local_def(db: &dyn Db, ft: FileText, file: FileId, tok_range: TextRange) -> Option<GodotDef> {
252    let fi = crate::queries::analyze_file(db, ft);
253    let unit = fi.unit_at(tok_range.start)?;
254    let binding = unit.result.binding_at(tok_range.start)?;
255    Some(GodotDef::Local {
256        body_file: file,
257        body_range: unit.range,
258        decl_name_range: trim_range(ft.text(db), binding.name_range),
259    })
260}
261
262/// A binding's `name_range` can include leading whitespace (a body-lowering quirk); trim it to the
263/// bare identifier so a `Local`'s identity and a rename's edit range are both exact.
264fn trim_range(text: &str, nr: TextRange) -> TextRange {
265    match text.get(nr.start as usize..nr.end as usize) {
266        Some(s) => {
267            let lead = u32::try_from(s.len() - s.trim_start().len()).unwrap_or(0);
268            let len = u32::try_from(s.trim().len()).unwrap_or(0);
269            TextRange::new(nr.start + lead, nr.start + lead + len)
270        }
271        None => nr,
272    }
273}
274
275/// Resolve a bare type name (in a `TypeRef`) to a `class_name` global or an engine class.
276fn classify_type_name(db: &dyn Db, name: &SmolStr) -> Option<GodotDef> {
277    let api = db.engine()?;
278    match crate::resolve::resolve_type_name(db, api, name) {
279        Ty::ScriptRef(sref) => Some(GodotDef::Global {
280            decl_file: FileId(sref.0),
281            name: name.clone(),
282        }),
283        Ty::Object(_) | Ty::Builtin(_) => Some(GodotDef::Engine { name: name.clone() }),
284        _ => None,
285    }
286}
287
288/// Classify a reference inside a function/initializer body (a `NameRef`, or a `FieldExpr` member).
289fn classify_body_ref(
290    db: &dyn Db,
291    ft: FileText,
292    file: FileId,
293    offset: u32,
294    name: &SmolStr,
295) -> Option<GodotDef> {
296    let fi = crate::queries::analyze_file(db, ft);
297    let unit = fi.unit_at(offset)?;
298    let eid = unit.body.source_map.expr_at_offset(offset)?;
299    match unit.body.expr(eid) {
300        crate::body::Expr::Name(n) if n == name => {
301            resolve_name_to_def(db, ft, file, offset, unit, name)
302        }
303        crate::body::Expr::Field {
304            receiver,
305            name: fname,
306            name_range,
307        } if fname == name && name_range.start <= offset && offset < name_range.end => {
308            // `self.member` consults this file's own/inherited members (self's static type is the
309            // *base*, so we must resolve it as an own member, like `infer_field` does).
310            if matches!(unit.body.expr(*receiver), crate::body::Expr::SelfExpr) {
311                return member_owner(db, crate::ty::ScriptRefId(file.0), name, 0).map(|owner| {
312                    GodotDef::Member {
313                        owner_file: owner,
314                        name: name.clone(),
315                    }
316                });
317            }
318            let recv_ty = unit.result.type_of(*receiver)?;
319            match recv_ty {
320                Ty::ScriptRef(sref) => {
321                    member_owner(db, *sref, name, 0).map(|owner| GodotDef::Member {
322                        owner_file: owner,
323                        name: name.clone(),
324                    })
325                }
326                Ty::Object(_) | Ty::Builtin(_) => Some(GodotDef::Engine { name: name.clone() }),
327                _ => None, // uninformative receiver — cannot prove identity
328            }
329        }
330        _ => None,
331    }
332}
333
334/// The **canonical bare-name lookup order** (Godot `reduce_identifier`), shared with
335/// [`crate::infer::resolve_name`]'s type-producing copy:
336///
337/// 1. a **local** binding (var / param / `for`-var / `match`-capture; nearest-preceding on a shadow),
338/// 2. an **own** class member,
339/// 3. an **inherited** member (walk the user `extends` chain),
340/// 4. an **engine global** (builtin / native class / singleton / utility / enum),
341/// 5. a **`class_name` global**,
342/// 6. an **autoload** singleton.
343///
344/// This function is the identity (`GodotDef`) producer; `infer::resolve_name` is the `Ty` producer.
345/// They are intentionally separate (the latter is woven with flow-narrowing + `UNUSED`/`UNASSIGNED`
346/// side-effects and runs mid-inference), but must never **drift** on *which* declaration a use binds
347/// to — the `classify_and_infer_agree_*` tests (gdscript-ide) lock that agreement at every rung.
348fn resolve_name_to_def(
349    db: &dyn Db,
350    ft: FileText,
351    file: FileId,
352    offset: u32,
353    unit: &crate::infer::Unit,
354    name: &SmolStr,
355) -> Option<GodotDef> {
356    // 1. A local binding in this unit (var / param / for-var / match-capture). A name can be
357    //    shadowed (a param and a same-named local, or a re-declared `var`), so pick the
358    //    nearest-PRECEDING declaration — the binding with the greatest start `<=` the reference
359    //    offset — mirroring GDScript's lexical shadowing. (First-by-iteration would pick the
360    //    outermost, conflating two distinct locals and corrupting a rename.) The binding
361    //    `name_range` may carry leading whitespace, so trim before comparing / recording.
362    let text = ft.text(db);
363    let mut best: Option<TextRange> = None;
364    for b in &unit.result.bindings {
365        if !matches!(
366            b.kind,
367            crate::infer::BindingKind::Var
368                | crate::infer::BindingKind::Param
369                | crate::infer::BindingKind::ForVar
370                | crate::infer::BindingKind::MatchBind
371        ) {
372            continue;
373        }
374        let nr = trim_range(text, b.name_range);
375        if text.get(nr.start as usize..nr.end as usize) != Some(name.as_str()) {
376            continue;
377        }
378        if nr.start <= offset && best.is_none_or(|cur| nr.start >= cur.start) {
379            best = Some(nr);
380        }
381    }
382    if let Some(nr) = best {
383        return Some(GodotDef::Local {
384            body_file: file,
385            body_range: unit.range,
386            decl_name_range: nr,
387        });
388    }
389    // 2/3. Own or inherited member (walk this script's extends chain).
390    if let Some(owner) = member_owner(db, crate::ty::ScriptRefId(file.0), name, 0) {
391        return Some(GodotDef::Member {
392            owner_file: owner,
393            name: name.clone(),
394        });
395    }
396    // 4. An engine global (builtin / native class / singleton / utility / enum) — before
397    //    `class_name`, matching `resolve_name`'s precedence.
398    if let Some(api) = db.engine()
399        && crate::resolve::resolve_global(api, name).is_some()
400    {
401        return Some(GodotDef::Engine { name: name.clone() });
402    }
403    // 5. A `class_name` global.
404    if let Some(def) = class_name_global_def(db, name) {
405        return Some(def);
406    }
407    // 6. An autoload singleton.
408    autoload_def(db, name)
409}
410
411/// Rung 5 of the canonical lookup order: a project-global `class_name` → its declaring file's
412/// [`GodotDef::Global`]. (The same `global_registry` `infer::resolve_name` resolves through, so the
413/// two paths agree on *which* file declares the class.)
414fn class_name_global_def(db: &dyn Db, name: &SmolStr) -> Option<GodotDef> {
415    let root = db.source_root()?;
416    let decl = crate::queries::global_registry(db, root).resolve(name)?;
417    Some(GodotDef::Global {
418        decl_file: decl.file_id(db),
419        name: name.clone(),
420    })
421}
422
423/// Rung 6 of the canonical lookup order: an autoload singleton → [`GodotDef::Autoload`], carrying the
424/// `.gd` it points to when resolvable (`None` for a `.tscn`/non-`.gd` target). (The same
425/// `autoload_registry` `infer::resolve_name` resolves through.)
426fn autoload_def(db: &dyn Db, name: &SmolStr) -> Option<GodotDef> {
427    let config = db.project_config()?;
428    let path = crate::queries::autoload_registry(db, config)
429        .resolve_path(name)
430        .cloned()?;
431    let target_file = db.source_root().and_then(|root| {
432        crate::queries::res_path_registry(db, root)
433            .get(path.as_str())
434            .copied()
435    });
436    Some(GodotDef::Autoload {
437        name: name.clone(),
438        target_file,
439    })
440}
441
442/// The file that *declares* member `name` for the script in `sref`, walking the `extends` chain
443/// (own members first, then user bases). Depth-bounded like the inference member walk.
444fn member_owner(
445    db: &dyn Db,
446    sref: crate::ty::ScriptRefId,
447    name: &str,
448    depth: u32,
449) -> Option<FileId> {
450    if depth > 32 {
451        return None;
452    }
453    let file = db.file_text(FileId(sref.0))?;
454    let tree = crate::queries::item_tree(db, file);
455    // An own member, OR an anonymous-enum variant (a class-level `int` constant that the member
456    // table doesn't expose — its enum has no name and its variants aren't `Member`s).
457    if tree.member(name).is_some() || anon_enum_has_variant(&tree, name) {
458        return Some(file.file_id(db));
459    }
460    match crate::queries::script_class(db, file).base() {
461        Ty::ScriptRef(base) => member_owner(db, *base, name, depth + 1),
462        _ => None, // engine base member, or none — not a user-declared member
463    }
464}
465
466/// Whether `tree` declares `name` as a variant of an **anonymous** `enum { … }` (a flattened
467/// class-level `int` constant). Named-enum variants are excluded — they are accessed as `Enum.NAME`,
468/// not as bare class-level names.
469fn anon_enum_has_variant(tree: &crate::item_tree::ItemTree, name: &str) -> bool {
470    tree.members.iter().any(|m| {
471        matches!(m, crate::item_tree::Member::Enum(e)
472            if e.name.is_none() && e.variants.iter().any(|v| v == name))
473    })
474}
475
476/// Whether `enum_variant` (an `EnumVariant` node) belongs to an anonymous `enum { … }` (no name).
477fn in_anon_enum(enum_variant: &GdNode) -> bool {
478    enum_variant.parent().is_some_and(|enum_decl| {
479        enum_decl.kind() == SyntaxKind::EnumDecl
480            && !enum_decl.children().any(|c| c.kind() == SyntaxKind::Name)
481    })
482}
483
484/// Whether `tok` has an ancestor node of `kind`.
485fn has_ancestor(tok: &GdToken, kind: SyntaxKind) -> bool {
486    node_has_ancestor_or_self(tok.parent(), kind)
487}
488
489/// Whether `node` itself or any ancestor is of `kind`.
490fn node_has_ancestor(node: &GdNode, kind: SyntaxKind) -> bool {
491    node.parent()
492        .is_some_and(|p| node_has_ancestor_or_self(p, kind))
493}
494
495fn node_has_ancestor_or_self(node: &GdNode, kind: SyntaxKind) -> bool {
496    let mut cur = Some(node.clone());
497    while let Some(n) = cur {
498        if n.kind() == kind {
499            return true;
500        }
501        cur = n.parent().cloned();
502    }
503    false
504}
505
506// ---- M2: node-path navigation (go-to-definition into the `.tscn`) -------------------------
507
508/// A `$Path`/`%Unique`/`get_node("…")` resolved to its scene-node declaration — for go-to-definition
509/// **into the owning `.tscn`** (the `[node …]` line). The inverse of M1's node-path typing.
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct NodePathTarget {
512    /// The owning scene's file.
513    pub scene: FileId,
514    /// The resolved node's name.
515    pub node_name: SmolStr,
516    /// Byte span of the whole `[node …]` header line.
517    pub header_span: TextRange,
518    /// Byte span of the `name="…"` value (the finer focus).
519    pub name_span: TextRange,
520}
521
522/// If the cursor sits on a node-path expression (`$Path`/`%Unique`/`get_node("…")`) that resolves
523/// against the owning scene, the target node's declaration in the `.tscn`. `None` otherwise.
524#[must_use]
525pub fn node_path_target(db: &dyn Db, pos: FilePosition) -> Option<NodePathTarget> {
526    let ft = db.file_text(pos.file)?;
527    let fi = crate::queries::analyze_file(db, ft);
528    let unit = fi.unit_at(pos.offset)?;
529    let eid = unit.body.source_map.expr_at_offset(pos.offset)?;
530    let crate::body::Expr::GetNode {
531        path: Some(path),
532        unique,
533    } = unit.body.expr(eid)
534    else {
535        return None;
536    };
537    let ctx = crate::queries::scene_context(db, ft)?;
538    let idx = if *unique {
539        ctx.model.resolve_unique(path)
540    } else {
541        ctx.model.resolve_path_from(ctx.attach, path)
542    }?;
543    let node = ctx.model.node(idx)?;
544    Some(NodePathTarget {
545        scene: ctx.scene,
546        node_name: node.name.clone(),
547        header_span: node.header_span,
548        name_span: node.name_span,
549    })
550}
551
552// ---- W8: node-as-symbol classification (scene-aware rename) -------------------------------
553
554/// Classify the cursor when it sits inside a `$Path`/`%Unique` node-path expression: resolve the
555/// path **up to the segment under the cursor** to a scene node, returning its [`GodotDef::SceneNode`]
556/// identity. A mid-path segment (`$A/B/C` on `B`) resolves `A/B`, so renaming `B` is distinct from
557/// renaming `C`. `None` when the cursor is not in a node path, the attachment is ambiguous
558/// (multi-scene — we can't say *which* scene's node), or the path doesn't resolve.
559fn classify_node_path_at(
560    db: &dyn Db,
561    ft: FileText,
562    _file: FileId,
563    tok: &GdToken,
564    offset: u32,
565) -> Option<GodotDef> {
566    let np = node_path_ancestor(tok)?;
567    let unique = np.kind() == SyntaxKind::UniqueNodeExpr;
568    let truncated = node_path_up_to_cursor(&np, offset)?;
569    let ctx = crate::queries::scene_context(db, ft)?;
570    if ctx.ambiguous {
571        return None; // multi-scene attachment — we can't identify *which* scene's node
572    }
573    let idx = if unique {
574        ctx.model.resolve_unique(&truncated)
575    } else {
576        ctx.model.resolve_path_from(ctx.attach, &truncated)
577    }?;
578    Some(GodotDef::SceneNode {
579        scene: ctx.scene,
580        path: ctx.model.node_full_path(idx)?,
581    })
582}
583
584/// Classify the cursor on the literal string of a `get_node("Panel/Btn")` / `get_node_or_null(…)`
585/// call (lowered to `Expr::GetNode`, but a `CallExpr` at the CST level so `classify_node_path_at`
586/// does not catch it). Resolves the path up to the cursor segment from the script's attach node.
587fn classify_get_node_call_at(
588    db: &dyn Db,
589    ft: FileText,
590    tok: &GdToken,
591    offset: u32,
592) -> Option<GodotDef> {
593    if tok.kind() != SyntaxKind::String {
594        return None;
595    }
596    let fi = crate::queries::analyze_file(db, ft);
597    let unit = fi.unit_at(offset)?;
598    let eid = unit.body.source_map.expr_at_offset(offset)?;
599    let crate::body::Expr::GetNode { path: Some(_), .. } = unit.body.expr(eid) else {
600        return None; // not a node-path call (or a computed `get_node(var)`)
601    };
602    let range = cst::token_range(tok);
603    let content = tok.text().trim_matches(['"', '\'']);
604    let truncated = segment_up_to(content, range.start + 1, offset)?;
605    let ctx = crate::queries::scene_context(db, ft)?;
606    if ctx.ambiguous {
607        return None;
608    }
609    // The literal may carry a `%Name` head; `resolve_path_from` strips the `%` per segment.
610    Some(GodotDef::SceneNode {
611        scene: ctx.scene,
612        path: ctx
613            .model
614            .node_full_path(ctx.model.resolve_path_from(ctx.attach, &truncated)?)?,
615    })
616}
617
618/// The innermost `GetNodeExpr`/`UniqueNodeExpr` ancestor of `tok`, if any.
619fn node_path_ancestor(tok: &GdToken) -> Option<GdNode> {
620    let mut cur = Some(tok.parent().clone());
621    while let Some(n) = cur {
622        if matches!(
623            n.kind(),
624            SyntaxKind::GetNodeExpr | SyntaxKind::UniqueNodeExpr
625        ) {
626            return Some(n);
627        }
628        cur = n.parent().cloned();
629    }
630    None
631}
632
633/// The node path `np` truncated to (and including) the segment the cursor at `offset` is on. Two CST
634/// shapes: a token form (`$Panel/StartButton` — each segment its own `Ident`) and a string form
635/// (`$"Panel/StartButton"` — one `String` holding the path).
636fn node_path_up_to_cursor(np: &GdNode, offset: u32) -> Option<String> {
637    // String form: locate the segment by byte offset inside the quoted content.
638    if let Some(str_tok) = np
639        .children_with_tokens()
640        .filter_map(cstree::util::NodeOrToken::into_token)
641        .find(|t| t.kind() == SyntaxKind::String)
642    {
643        let range = cst::token_range(str_tok);
644        let content = str_tok.text().trim_matches(['"', '\'']);
645        return segment_up_to(content, range.start + 1, offset); // +1 past the opening quote
646    }
647    // Token form: the segment `Ident`s (range, text) in order; keep through the one at the cursor.
648    let idents: Vec<(TextRange, String)> = np
649        .children_with_tokens()
650        .filter_map(cstree::util::NodeOrToken::into_token)
651        .filter(|t| t.kind() == SyntaxKind::Ident)
652        .map(|t| (cst::token_range(t), t.text().to_owned()))
653        .collect();
654    let hit = idents
655        .iter()
656        .position(|(r, _)| offset >= r.start && offset <= r.end)
657        // the cursor sits on a `/` or trailing space — attribute it to the preceding segment
658        .or_else(|| idents.iter().rposition(|(r, _)| r.start <= offset))?;
659    Some(
660        idents[..=hit]
661            .iter()
662            .map(|(_, s)| s.as_str())
663            .collect::<Vec<_>>()
664            .join("/"),
665    )
666}
667
668/// Classify a position in a `.tscn`: the node whose `name="…"` value contains the cursor →
669/// [`GodotDef::SceneNode`]. `None` if the cursor is not on a node name.
670/// The path `content` (located at byte `content_start`) truncated to (and including) the segment
671/// containing `offset`. Used for every `/`-segmented path value (a `$"…"` string, a `parent=`, a
672/// `[connection] from`/`to`). `None` if the cursor is outside the content.
673fn segment_up_to(content: &str, content_start: u32, offset: u32) -> Option<String> {
674    let rel = offset.checked_sub(content_start)? as usize;
675    if rel > content.len() {
676        return None;
677    }
678    let end = content[rel.min(content.len())..]
679        .find('/')
680        .map_or(content.len(), |i| rel + i);
681    Some(content[..end].to_owned())
682}
683
684/// Whether `offset` is inside `[span.start, span.end)`.
685fn in_span(offset: u32, span: TextRange) -> bool {
686    offset >= span.start && offset < span.end
687}
688
689/// Classify a position in a `.tscn`: the scene node the cursor identifies — its own `name="…"`, a
690/// `parent="…"` path segment, or a `[connection] from`/`to` path segment (all resolved to a
691/// [`GodotDef::SceneNode`] by full path). These are the dependent references a node rename rewrites.
692fn classify_scene_name(db: &dyn Db, ft: FileText, file: FileId, offset: u32) -> Option<GodotDef> {
693    let model = crate::queries::scene_model(db, ft);
694    let def = |idx: NodeIdx| {
695        Some(GodotDef::SceneNode {
696            scene: file,
697            path: model.node_full_path(idx)?,
698        })
699    };
700    // (1) The node's own `name="…"`.
701    for (i, n) in model.nodes.iter().enumerate() {
702        if in_span(offset, n.name_span) {
703            return def(NodeIdx(u32::try_from(i).ok()?));
704        }
705    }
706    // (2) A `parent="…"` path segment (root-relative; resolved from the root).
707    for n in &model.nodes {
708        if let (Some(span), Some(path)) = (n.parent_span, n.parent_path.as_deref())
709            && in_span(offset, span)
710        {
711            let trunc = segment_up_to(path, span.start, offset)?;
712            return def(model.resolve_path(&trunc)?);
713        }
714    }
715    // (3) A `[connection] from`/`to` path segment (root-relative).
716    for c in &model.connections {
717        for (span, path) in [(c.from_span, &c.from), (c.to_span, &c.to)] {
718            if in_span(offset, span) {
719                let trunc = segment_up_to(path, span.start, offset)?;
720                return def(model.resolve_path(&trunc)?);
721            }
722        }
723    }
724    None
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use gdscript_db::RootDatabase;
731    use salsa::Durability;
732
733    fn db_with(files: &[(u32, &str)]) -> RootDatabase {
734        let mut db = RootDatabase::default();
735        for (id, src) in files {
736            db.set_file_text(FileId(*id), src, Durability::LOW);
737        }
738        db.sync_source_root();
739        db
740    }
741
742    fn at(db: &RootDatabase, file: u32, needle: &str, src: &str) -> Option<GodotDef> {
743        let offset = u32::try_from(src.find(needle).expect("needle")).unwrap();
744        classify(
745            db,
746            FilePosition {
747                file: FileId(file),
748                offset,
749            },
750        )
751    }
752
753    /// classify at the byte offset of the `nth` (0-based) occurrence of `needle`.
754    fn at_nth(db: &RootDatabase, file: u32, needle: &str, n: usize, src: &str) -> Option<GodotDef> {
755        let off = src.match_indices(needle).nth(n).expect("nth needle").0;
756        classify(
757            db,
758            FilePosition {
759                file: FileId(file),
760                offset: u32::try_from(off).unwrap(),
761            },
762        )
763    }
764
765    #[test]
766    fn two_unrelated_locals_are_distinct() {
767        let src =
768            "func a():\n\tvar i := 1\n\tvar ra := i\nfunc b():\n\tvar i := 2\n\tvar rb := i\n";
769        let db = db_with(&[(0, src)]);
770        // The `i` reference in a() (`ra := i`) vs in b() (`rb := i`) — the two `:= i` sites.
771        let off_a = u32::try_from(src.match_indices(":= i").next().unwrap().0 + 3).unwrap();
772        let off_b = u32::try_from(src.match_indices(":= i").nth(1).unwrap().0 + 3).unwrap();
773        let da = classify(
774            &db,
775            FilePosition {
776                file: FileId(0),
777                offset: off_a,
778            },
779        )
780        .unwrap();
781        let dbf = classify(
782            &db,
783            FilePosition {
784                file: FileId(0),
785                offset: off_b,
786            },
787        )
788        .unwrap();
789        assert!(matches!(da, GodotDef::Local { .. }), "{da:?}");
790        assert!(matches!(dbf, GodotDef::Local { .. }), "{dbf:?}");
791        assert_ne!(da, dbf, "two unrelated `i`s must be distinct locals");
792    }
793
794    #[test]
795    fn local_shadowing_a_member_is_distinct() {
796        let src = "var pos := 1\nfunc f():\n\tvar pos := 2\n\tprint(pos)\n";
797        let db = db_with(&[(0, src)]);
798        // The member decl `var pos` (1st "pos") vs the local `var pos` (2nd "pos").
799        let member = at_nth(&db, 0, "pos", 0, src).unwrap();
800        let local = at_nth(&db, 0, "pos", 1, src).unwrap();
801        assert!(matches!(member, GodotDef::Member { .. }), "{member:?}");
802        assert!(matches!(local, GodotDef::Local { .. }), "{local:?}");
803        assert_ne!(member, local);
804        // The reference `pos` in `print(pos)` (3rd "pos") resolves to the LOCAL (scope wins).
805        let r = at_nth(&db, 0, "pos", 2, src).unwrap();
806        assert_eq!(r, local);
807    }
808
809    #[test]
810    fn same_named_members_of_different_classes_are_distinct() {
811        let a = "class_name A\nfunc update():\n\tpass\n";
812        let b = "class_name B\nfunc update():\n\tpass\n";
813        let db = db_with(&[(0, a), (1, b)]);
814        let ua = at(&db, 0, "update", a).unwrap();
815        let ub = at(&db, 1, "update", b).unwrap();
816        assert!(matches!(ua, GodotDef::Member { .. }));
817        assert!(matches!(ub, GodotDef::Member { .. }));
818        assert_ne!(ua, ub, "A.update and B.update must be distinct");
819    }
820
821    #[test]
822    fn class_name_decl_and_reference_classify_to_the_same_global() {
823        let widget = "class_name Widget\nfunc make() -> int:\n\treturn 1\n";
824        let user = "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n";
825        let db = db_with(&[(0, widget), (1, user)]);
826        let decl = at(&db, 0, "Widget", widget).unwrap();
827        let ann = at(&db, 1, "Widget\n", user).unwrap(); // the annotation `: Widget`
828        let ctor = at(&db, 1, "Widget.new", user).unwrap();
829        assert!(matches!(
830            decl,
831            GodotDef::Global {
832                decl_file: FileId(0),
833                ..
834            }
835        ));
836        assert_eq!(decl, ann, "annotation must resolve to the class_name def");
837        assert_eq!(
838            decl, ctor,
839            "`Widget.new()` must resolve to the class_name def"
840        );
841    }
842
843    #[test]
844    fn extends_user_class_classifies_to_the_global() {
845        // The `Base` in `extends Base` is a bare Ident (not a Name/TypeRef node); it must still
846        // classify to Base's `class_name` global — else find-refs/rename of Base would miss the
847        // `extends` and leave it stale.
848        let base = "class_name Base\nfunc m():\n\tpass\n";
849        let derived = "class_name Derived\nextends Base\n";
850        let db = db_with(&[(0, base), (1, derived)]);
851        let decl = at(&db, 0, "Base", base).unwrap();
852        let ext = at(&db, 1, "Base", derived).unwrap(); // the `Base` in `extends Base`
853        assert!(matches!(
854            decl,
855            GodotDef::Global {
856                decl_file: FileId(0),
857                ..
858            }
859        ));
860        assert_eq!(
861            decl, ext,
862            "`extends Base` must classify to Base's class_name def"
863        );
864    }
865
866    #[test]
867    fn inherited_member_resolves_to_the_declaring_base() {
868        let base = "class_name Base\nfunc base_m() -> int:\n\treturn 1\n";
869        let derived = "class_name Derived\nextends Base\nfunc use_it():\n\tself.base_m()\n";
870        let db = db_with(&[(0, base), (1, derived)]);
871        let decl = at(&db, 0, "base_m", base).unwrap();
872        let call = at(&db, 1, "base_m()", derived).unwrap();
873        assert!(matches!(
874            decl,
875            GodotDef::Member {
876                owner_file: FileId(0),
877                ..
878            }
879        ));
880        assert_eq!(
881            decl, call,
882            "inherited call must resolve to the base's member def"
883        );
884    }
885
886    #[test]
887    fn inner_class_member_is_out_of_scope() {
888        // A method inside `class Inner:` must NOT share identity with a same-named top-level method
889        // (that would let rename cross between two unrelated classes). It is out of scope → None.
890        let src =
891            "class_name A\nfunc update():\n\tpass\nclass Inner:\n\tfunc update():\n\t\tpass\n";
892        let db = db_with(&[(0, src)]);
893        let top = at_nth(&db, 0, "update", 0, src).unwrap();
894        let inner = at_nth(&db, 0, "update", 1, src);
895        assert!(matches!(top, GodotDef::Member { .. }), "{top:?}");
896        assert_eq!(
897            inner, None,
898            "an inner-class member must not classify (out of scope), got {inner:?}"
899        );
900    }
901
902    #[test]
903    fn match_capture_classifies_as_local_distinct_from_member() {
904        // A `match`-captured `var cap` is a local that shadows a same-named member; a reference to
905        // it must resolve to the Local, not the member (else rename of the member would corrupt it).
906        let src = "var cap := 0\nfunc f(v):\n\tmatch v:\n\t\tvar cap:\n\t\t\tprint(cap)\n";
907        let db = db_with(&[(0, src)]);
908        let member = at_nth(&db, 0, "cap", 0, src).unwrap();
909        let capture = at_nth(&db, 0, "cap", 1, src).unwrap();
910        let usage = at_nth(&db, 0, "cap", 2, src).unwrap();
911        assert!(matches!(member, GodotDef::Member { .. }), "{member:?}");
912        assert!(matches!(capture, GodotDef::Local { .. }), "{capture:?}");
913        assert_eq!(
914            usage, capture,
915            "`print(cap)` must resolve to the match capture"
916        );
917        assert_ne!(usage, member);
918    }
919
920    #[test]
921    fn accessor_body_local_is_not_a_member() {
922        // A `var` inside a property `get`/`set` accessor is a local, never a class member.
923        let src = "var hp: int:\n\tget:\n\t\tvar tmp = 2\n\t\treturn tmp\n";
924        let db = db_with(&[(0, src)]);
925        let tmp = at_nth(&db, 0, "tmp", 0, src);
926        assert!(
927            !matches!(tmp, Some(GodotDef::Member { .. })),
928            "a local in a get/set body must not be a Member, got {tmp:?}"
929        );
930    }
931
932    #[test]
933    fn anon_enum_variant_classifies_as_member() {
934        // An anonymous-enum variant is a class-level constant; its declaration and a bare reference
935        // must classify to the same identity (so find-refs / goto reach it).
936        let src = "enum { FIRE, ICE }\nfunc f():\n\tprint(FIRE)\n";
937        let db = db_with(&[(0, src)]);
938        let decl = at_nth(&db, 0, "FIRE", 0, src).unwrap(); // enum { FIRE }
939        let usage = at_nth(&db, 0, "FIRE", 1, src).unwrap(); // print(FIRE)
940        assert!(matches!(decl, GodotDef::Member { .. }), "{decl:?}");
941        assert_eq!(
942            decl, usage,
943            "an anon-enum variant decl and use share identity"
944        );
945    }
946
947    #[test]
948    fn shadowed_local_reference_resolves_to_the_nearest_declaration() {
949        // A param `x` and a local `var x`: the `print(x)` reference must resolve to the LOCAL
950        // (nearest-preceding decl), not the param — else find-refs/rename conflates two locals.
951        let src = "func f(x):\n\tvar x := 2\n\tprint(x)\n";
952        let db = db_with(&[(0, src)]);
953        let param = at_nth(&db, 0, "x", 0, src).unwrap();
954        let local = at_nth(&db, 0, "x", 1, src).unwrap();
955        let usage = at_nth(&db, 0, "x", 2, src).unwrap();
956        assert!(matches!(param, GodotDef::Local { .. }), "{param:?}");
957        assert!(matches!(local, GodotDef::Local { .. }), "{local:?}");
958        assert_ne!(param, local, "param x and local x are distinct");
959        assert_eq!(
960            usage, local,
961            "the reference resolves to the nearest (local) declaration"
962        );
963    }
964}