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_syntax::{GdNode, GdToken, SyntaxKind, ast};
20use smol_str::SmolStr;
21
22use crate::cst;
23use crate::ty::Ty;
24
25/// The canonical identity of a findable / renameable symbol. Equality is on **identity**, not the
26/// name string (rust-analyzer's `Definition`).
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum GodotDef {
29    /// A `class_name` global. Identity = the one file that declares it.
30    Global {
31        /// The declaring file.
32        decl_file: FileId,
33        /// The class name.
34        name: SmolStr,
35    },
36    /// A script member (func / var / const / signal / enum / inner class). Identity = the script
37    /// file that *declares* it (for an inherited member, the base file where it is found) + name.
38    Member {
39        /// The file declaring the member.
40        owner_file: FileId,
41        /// The member name.
42        name: SmolStr,
43    },
44    /// A local binding (var / param / `for`-var). Identity = the owning function body + the
45    /// binding's declaration-site name range. Two `i`s in different functions, or a local
46    /// shadowing a member, are distinct by construction.
47    Local {
48        /// The file the body lives in.
49        body_file: FileId,
50        /// The enclosing function/initializer unit's range.
51        body_range: TextRange,
52        /// The binding's declaration name-token range.
53        decl_name_range: TextRange,
54    },
55    /// An autoload **singleton** (the `*`-flagged `[autoload]` name; project-unique).
56    Autoload {
57        /// The autoload name.
58        name: SmolStr,
59        /// The `.gd` it points to, if resolvable (`None` for a `.tscn`/non-`.gd` target).
60        target_file: Option<FileId>,
61    },
62    /// An engine / builtin symbol (`Node`, `Vector2`, a builtin func, …) — resolved, but **not**
63    /// ours to rename, and find-references over it is out of scope. Distinguishes "resolved, it's
64    /// engine" from "unresolved" (the latter is `None`).
65    Engine {
66        /// The engine symbol name.
67        name: SmolStr,
68    },
69}
70
71impl GodotDef {
72    /// The symbol's name — the cheap text pre-filter key for find-references.
73    #[must_use]
74    pub fn name(&self) -> &str {
75        match self {
76            Self::Global { name, .. }
77            | Self::Member { name, .. }
78            | Self::Autoload { name, .. }
79            | Self::Engine { name } => name,
80            Self::Local { .. } => "", // filled by the caller from the decl range
81        }
82    }
83
84    /// Whether this symbol can be renamed at all (engine/builtin symbols cannot).
85    #[must_use]
86    pub fn is_renameable(&self) -> bool {
87        !matches!(self, Self::Engine { .. })
88    }
89}
90
91/// Classify the symbol the cursor (`pos`) sits on — the single entry point find-references and
92/// goto-definition share. `None` for a non-identifier token, or a reference whose target cannot be
93/// resolved (the seam — we never guess an identity).
94#[must_use]
95pub fn classify(db: &dyn Db, pos: FilePosition) -> Option<GodotDef> {
96    let ft = db.file_text(pos.file)?;
97    let root = parse(db, ft).syntax_node();
98    let tok = ast::token_at(&root, pos.offset.into())?;
99    let parent = tok.parent();
100    // Identifiers are symbols. The soft keywords `match`/`when` are too — but ONLY in a name
101    // position (a `Name` decl, or a `NameRef`/`FieldExpr` member). A bare `match` *statement* keyword
102    // (parent = `MatchStmt`) must stay a non-symbol, else the cursor on it would falsely resolve to a
103    // same-named declaration. (Mirrors the grammar's `at_name` whitelist; see `TECH_DEBT.md`.)
104    let is_name_tok = tok.kind() == SyntaxKind::Ident
105        || (matches!(tok.kind(), SyntaxKind::MatchKw | SyntaxKind::WhenKw)
106            && matches!(parent.kind(), SyntaxKind::Name | SyntaxKind::NameRef));
107    if !is_name_tok {
108        return None; // keywords / punctuation are not symbols
109    }
110    let name = SmolStr::new(tok.text());
111    let tok_range = cst::token_range(&tok);
112
113    // (A) Declaration sites: the cursor is on the `Name` token of a declaration.
114    if parent.kind() == SyntaxKind::Name
115        && let Some(def) = classify_decl(db, ft, pos.file, parent, &name, tok_range)
116    {
117        return Some(def);
118    }
119    // (A2) The head name of an `extends Base` clause. Its `Ident` is a *bare* child of the
120    //      `ExtendsClause` / `ClassNameDecl` / inner-class decl — not wrapped in a `Name` or
121    //      `TypeRef` node — so neither (A) nor (B) catches it. Resolve it as a type name so a
122    //      `class_name`'s find-references / rename includes every `extends ThatClass` reference
123    //      (else a rename silently leaves the `extends` stale — an incomplete, corrupting edit).
124    if let Some(head) = cst::extends_head_token(parent)
125        && cst::token_range(&head) == tok_range
126    {
127        return classify_type_name(db, &name);
128    }
129    // (A3) An anonymous-enum variant declaration (`enum { FIRE }`): a bare `Ident` under an
130    //      `EnumVariant` whose enum has no name. Such a variant is a class-level `int` constant, so
131    //      it shares the Member identity space — resolve to Member{file, name} so find-refs / goto
132    //      reach it. (A *named* enum's variants are accessed as `Enum.NAME`, not as bare class-level
133    //      names, so they are out of scope here.)
134    if parent.kind() == SyntaxKind::EnumVariant && in_anon_enum(parent) {
135        return Some(GodotDef::Member {
136            owner_file: pos.file,
137            name,
138        });
139    }
140    // (B) A type reference (`var x: Foo`, `is Foo`, `as Foo`): the token is inside a `TypeRef`.
141    //     Resolve the type name to a class_name global or an engine class.
142    if has_ancestor(&tok, SyntaxKind::TypeRef) {
143        return classify_type_name(db, &name);
144    }
145    // (C) A reference inside a function body / field initializer (a `NameRef`, or the member token
146    //     of a `FieldExpr`). Resolve through the inference units.
147    classify_body_ref(db, ft, pos.file, pos.offset, &name)
148}
149
150/// Classify a declaration-site name (`parent` is the `Name` node; its parent is the decl).
151fn classify_decl(
152    db: &dyn Db,
153    ft: FileText,
154    file: FileId,
155    name_node: &GdNode,
156    name: &SmolStr,
157    tok_range: TextRange,
158) -> Option<GodotDef> {
159    let decl = name_node.parent()?;
160    // A `var`/`const` nested inside a function, a property accessor (`get`/`set`), or a lambda body
161    // is a LOCAL — not a class member. (A `FuncDecl` ancestor alone misses an accessor-body or a
162    // class-level-lambda-body local, which would otherwise be mis-typed as a `Member`.)
163    let in_body = node_has_ancestor(decl, SyntaxKind::FuncDecl)
164        || node_has_ancestor(decl, SyntaxKind::Getter)
165        || node_has_ancestor(decl, SyntaxKind::Setter)
166        || node_has_ancestor(decl, SyntaxKind::LambdaExpr);
167    // A declaration nested inside a `class Inner:` body is an inner-class member. Its `(file, name)`
168    // identity would collide with a same-named TOP-LEVEL member, letting find-refs / rename cross
169    // between two unrelated classes (a silent corrupting edit). Inner-class member identity isn't
170    // modeled yet (item_tree stores inner members separately), so treat them as out of scope —
171    // navigation refuses rather than mis-resolves. (The inner class's own *name* is unaffected: its
172    // decl node IS the `InnerClassDecl`, whose ancestor walk starts *above* it.)
173    let in_inner_class = node_has_ancestor(decl, SyntaxKind::InnerClassDecl);
174    match decl.kind() {
175        SyntaxKind::ClassNameDecl => Some(GodotDef::Global {
176            decl_file: file,
177            name: name.clone(),
178        }),
179        // A parameter, `for`-loop variable, or `match`-pattern `var` capture is always a local.
180        SyntaxKind::Param | SyntaxKind::ForStmt | SyntaxKind::PatternBind => {
181            local_def(db, ft, file, tok_range)
182        }
183        // A `var`/`const` inside a body is a local.
184        SyntaxKind::VarDecl | SyntaxKind::ConstDecl if in_body => {
185            local_def(db, ft, file, tok_range)
186        }
187        // Otherwise a class-level member — but only of the top-level class (inner-class members are
188        // out of scope, see above).
189        SyntaxKind::FuncDecl
190        | SyntaxKind::SignalDecl
191        | SyntaxKind::EnumDecl
192        | SyntaxKind::InnerClassDecl
193        | SyntaxKind::VarDecl
194        | SyntaxKind::ConstDecl
195            if !in_inner_class =>
196        {
197            Some(GodotDef::Member {
198                owner_file: file,
199                name: name.clone(),
200            })
201        }
202        _ => None,
203    }
204}
205
206/// Build a [`GodotDef::Local`] for the binding whose decl-name is at `tok_range`. The identity uses
207/// the **binding's** `name_range` (via `binding_at`), so a declaration cursor and a reference (which
208/// resolves to the same binding) produce the *same* `Local` — even if the raw token range and the
209/// lowered binding range differ.
210fn local_def(db: &dyn Db, ft: FileText, file: FileId, tok_range: TextRange) -> Option<GodotDef> {
211    let fi = crate::queries::analyze_file(db, ft);
212    let unit = fi.unit_at(tok_range.start)?;
213    let binding = unit.result.binding_at(tok_range.start)?;
214    Some(GodotDef::Local {
215        body_file: file,
216        body_range: unit.range,
217        decl_name_range: trim_range(ft.text(db), binding.name_range),
218    })
219}
220
221/// A binding's `name_range` can include leading whitespace (a body-lowering quirk); trim it to the
222/// bare identifier so a `Local`'s identity and a rename's edit range are both exact.
223fn trim_range(text: &str, nr: TextRange) -> TextRange {
224    match text.get(nr.start as usize..nr.end as usize) {
225        Some(s) => {
226            let lead = u32::try_from(s.len() - s.trim_start().len()).unwrap_or(0);
227            let len = u32::try_from(s.trim().len()).unwrap_or(0);
228            TextRange::new(nr.start + lead, nr.start + lead + len)
229        }
230        None => nr,
231    }
232}
233
234/// Resolve a bare type name (in a `TypeRef`) to a `class_name` global or an engine class.
235fn classify_type_name(db: &dyn Db, name: &SmolStr) -> Option<GodotDef> {
236    let api = db.engine()?;
237    match crate::resolve::resolve_type_name(db, api, name) {
238        Ty::ScriptRef(sref) => Some(GodotDef::Global {
239            decl_file: FileId(sref.0),
240            name: name.clone(),
241        }),
242        Ty::Object(_) | Ty::Builtin(_) => Some(GodotDef::Engine { name: name.clone() }),
243        _ => None,
244    }
245}
246
247/// Classify a reference inside a function/initializer body (a `NameRef`, or a `FieldExpr` member).
248fn classify_body_ref(
249    db: &dyn Db,
250    ft: FileText,
251    file: FileId,
252    offset: u32,
253    name: &SmolStr,
254) -> Option<GodotDef> {
255    let fi = crate::queries::analyze_file(db, ft);
256    let unit = fi.unit_at(offset)?;
257    let eid = unit.body.source_map.expr_at_offset(offset)?;
258    match unit.body.expr(eid) {
259        crate::body::Expr::Name(n) if n == name => {
260            resolve_name_to_def(db, ft, file, offset, unit, name)
261        }
262        crate::body::Expr::Field {
263            receiver,
264            name: fname,
265            name_range,
266        } if fname == name && name_range.start <= offset && offset < name_range.end => {
267            // `self.member` consults this file's own/inherited members (self's static type is the
268            // *base*, so we must resolve it as an own member, like `infer_field` does).
269            if matches!(unit.body.expr(*receiver), crate::body::Expr::SelfExpr) {
270                return member_owner(db, crate::ty::ScriptRefId(file.0), name, 0).map(|owner| {
271                    GodotDef::Member {
272                        owner_file: owner,
273                        name: name.clone(),
274                    }
275                });
276            }
277            let recv_ty = unit.result.type_of(*receiver)?;
278            match recv_ty {
279                Ty::ScriptRef(sref) => {
280                    member_owner(db, *sref, name, 0).map(|owner| GodotDef::Member {
281                        owner_file: owner,
282                        name: name.clone(),
283                    })
284                }
285                Ty::Object(_) | Ty::Builtin(_) => Some(GodotDef::Engine { name: name.clone() }),
286                _ => None, // uninformative receiver — cannot prove identity
287            }
288        }
289        _ => None,
290    }
291}
292
293/// Replicate [`crate::infer`]'s bare-name lookup order, returning the *declaration identity*:
294/// local → own/inherited member → engine global → `class_name` global → autoload. `offset` is the
295/// reference site, used to pick the correct binding when a name is shadowed (lexical scoping).
296fn resolve_name_to_def(
297    db: &dyn Db,
298    ft: FileText,
299    file: FileId,
300    offset: u32,
301    unit: &crate::infer::Unit,
302    name: &SmolStr,
303) -> Option<GodotDef> {
304    // 1. A local binding in this unit (var / param / for-var / match-capture). A name can be
305    //    shadowed (a param and a same-named local, or a re-declared `var`), so pick the
306    //    nearest-PRECEDING declaration — the binding with the greatest start `<=` the reference
307    //    offset — mirroring GDScript's lexical shadowing. (First-by-iteration would pick the
308    //    outermost, conflating two distinct locals and corrupting a rename.) The binding
309    //    `name_range` may carry leading whitespace, so trim before comparing / recording.
310    let text = ft.text(db);
311    let mut best: Option<TextRange> = None;
312    for b in &unit.result.bindings {
313        if !matches!(
314            b.kind,
315            crate::infer::BindingKind::Var
316                | crate::infer::BindingKind::Param
317                | crate::infer::BindingKind::ForVar
318                | crate::infer::BindingKind::MatchBind
319        ) {
320            continue;
321        }
322        let nr = trim_range(text, b.name_range);
323        if text.get(nr.start as usize..nr.end as usize) != Some(name.as_str()) {
324            continue;
325        }
326        if nr.start <= offset && best.is_none_or(|cur| nr.start >= cur.start) {
327            best = Some(nr);
328        }
329    }
330    if let Some(nr) = best {
331        return Some(GodotDef::Local {
332            body_file: file,
333            body_range: unit.range,
334            decl_name_range: nr,
335        });
336    }
337    // 2/3. Own or inherited member (walk this script's extends chain).
338    if let Some(owner) = member_owner(db, crate::ty::ScriptRefId(file.0), name, 0) {
339        return Some(GodotDef::Member {
340            owner_file: owner,
341            name: name.clone(),
342        });
343    }
344    // 4. An engine global (builtin / native class / singleton / utility / enum) — before
345    //    `class_name`, matching `resolve_name`'s precedence.
346    if let Some(api) = db.engine()
347        && crate::resolve::resolve_global(api, name).is_some()
348    {
349        return Some(GodotDef::Engine { name: name.clone() });
350    }
351    // 5. A `class_name` global.
352    if let Some(root) = db.source_root()
353        && let Some(decl) = crate::queries::global_registry(db, root).resolve(name)
354    {
355        return Some(GodotDef::Global {
356            decl_file: decl.file_id(db),
357            name: name.clone(),
358        });
359    }
360    // 6. An autoload singleton.
361    if let Some(config) = db.project_config()
362        && let Some(path) = crate::queries::autoload_registry(db, config)
363            .resolve_path(name)
364            .cloned()
365    {
366        let target = db.source_root().and_then(|root| {
367            crate::queries::res_path_registry(db, root)
368                .get(path.as_str())
369                .copied()
370        });
371        return Some(GodotDef::Autoload {
372            name: name.clone(),
373            target_file: target,
374        });
375    }
376    None
377}
378
379/// The file that *declares* member `name` for the script in `sref`, walking the `extends` chain
380/// (own members first, then user bases). Depth-bounded like the inference member walk.
381fn member_owner(
382    db: &dyn Db,
383    sref: crate::ty::ScriptRefId,
384    name: &str,
385    depth: u32,
386) -> Option<FileId> {
387    if depth > 32 {
388        return None;
389    }
390    let file = db.file_text(FileId(sref.0))?;
391    let tree = crate::queries::item_tree(db, file);
392    // An own member, OR an anonymous-enum variant (a class-level `int` constant that the member
393    // table doesn't expose — its enum has no name and its variants aren't `Member`s).
394    if tree.member(name).is_some() || anon_enum_has_variant(&tree, name) {
395        return Some(file.file_id(db));
396    }
397    match crate::queries::script_class(db, file).base() {
398        Ty::ScriptRef(base) => member_owner(db, *base, name, depth + 1),
399        _ => None, // engine base member, or none — not a user-declared member
400    }
401}
402
403/// Whether `tree` declares `name` as a variant of an **anonymous** `enum { … }` (a flattened
404/// class-level `int` constant). Named-enum variants are excluded — they are accessed as `Enum.NAME`,
405/// not as bare class-level names.
406fn anon_enum_has_variant(tree: &crate::item_tree::ItemTree, name: &str) -> bool {
407    tree.members.iter().any(|m| {
408        matches!(m, crate::item_tree::Member::Enum(e)
409            if e.name.is_none() && e.variants.iter().any(|v| v == name))
410    })
411}
412
413/// Whether `enum_variant` (an `EnumVariant` node) belongs to an anonymous `enum { … }` (no name).
414fn in_anon_enum(enum_variant: &GdNode) -> bool {
415    enum_variant.parent().is_some_and(|enum_decl| {
416        enum_decl.kind() == SyntaxKind::EnumDecl
417            && !enum_decl.children().any(|c| c.kind() == SyntaxKind::Name)
418    })
419}
420
421/// Whether `tok` has an ancestor node of `kind`.
422fn has_ancestor(tok: &GdToken, kind: SyntaxKind) -> bool {
423    node_has_ancestor_or_self(tok.parent(), kind)
424}
425
426/// Whether `node` itself or any ancestor is of `kind`.
427fn node_has_ancestor(node: &GdNode, kind: SyntaxKind) -> bool {
428    node.parent()
429        .is_some_and(|p| node_has_ancestor_or_self(p, kind))
430}
431
432fn node_has_ancestor_or_self(node: &GdNode, kind: SyntaxKind) -> bool {
433    let mut cur = Some(node.clone());
434    while let Some(n) = cur {
435        if n.kind() == kind {
436            return true;
437        }
438        cur = n.parent().cloned();
439    }
440    false
441}
442
443// ---- M2: node-path navigation (go-to-definition into the `.tscn`) -------------------------
444
445/// A `$Path`/`%Unique`/`get_node("…")` resolved to its scene-node declaration — for go-to-definition
446/// **into the owning `.tscn`** (the `[node …]` line). The inverse of M1's node-path typing.
447#[derive(Debug, Clone, PartialEq, Eq)]
448pub struct NodePathTarget {
449    /// The owning scene's file.
450    pub scene: FileId,
451    /// The resolved node's name.
452    pub node_name: SmolStr,
453    /// Byte span of the whole `[node …]` header line.
454    pub header_span: TextRange,
455    /// Byte span of the `name="…"` value (the finer focus).
456    pub name_span: TextRange,
457}
458
459/// If the cursor sits on a node-path expression (`$Path`/`%Unique`/`get_node("…")`) that resolves
460/// against the owning scene, the target node's declaration in the `.tscn`. `None` otherwise.
461#[must_use]
462pub fn node_path_target(db: &dyn Db, pos: FilePosition) -> Option<NodePathTarget> {
463    let ft = db.file_text(pos.file)?;
464    let fi = crate::queries::analyze_file(db, ft);
465    let unit = fi.unit_at(pos.offset)?;
466    let eid = unit.body.source_map.expr_at_offset(pos.offset)?;
467    let crate::body::Expr::GetNode {
468        path: Some(path),
469        unique,
470    } = unit.body.expr(eid)
471    else {
472        return None;
473    };
474    let ctx = crate::queries::scene_context(db, ft)?;
475    let idx = if *unique {
476        ctx.model.resolve_unique(path)
477    } else {
478        ctx.model.resolve_path_from(ctx.attach, path)
479    }?;
480    let node = ctx.model.node(idx)?;
481    Some(NodePathTarget {
482        scene: ctx.scene,
483        node_name: node.name.clone(),
484        header_span: node.header_span,
485        name_span: node.name_span,
486    })
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use gdscript_db::RootDatabase;
493    use salsa::Durability;
494
495    fn db_with(files: &[(u32, &str)]) -> RootDatabase {
496        let mut db = RootDatabase::default();
497        for (id, src) in files {
498            db.set_file_text(FileId(*id), src, Durability::LOW);
499        }
500        db.sync_source_root();
501        db
502    }
503
504    fn at(db: &RootDatabase, file: u32, needle: &str, src: &str) -> Option<GodotDef> {
505        let offset = u32::try_from(src.find(needle).expect("needle")).unwrap();
506        classify(
507            db,
508            FilePosition {
509                file: FileId(file),
510                offset,
511            },
512        )
513    }
514
515    /// classify at the byte offset of the `nth` (0-based) occurrence of `needle`.
516    fn at_nth(db: &RootDatabase, file: u32, needle: &str, n: usize, src: &str) -> Option<GodotDef> {
517        let off = src.match_indices(needle).nth(n).expect("nth needle").0;
518        classify(
519            db,
520            FilePosition {
521                file: FileId(file),
522                offset: u32::try_from(off).unwrap(),
523            },
524        )
525    }
526
527    #[test]
528    fn two_unrelated_locals_are_distinct() {
529        let src =
530            "func a():\n\tvar i := 1\n\tvar ra := i\nfunc b():\n\tvar i := 2\n\tvar rb := i\n";
531        let db = db_with(&[(0, src)]);
532        // The `i` reference in a() (`ra := i`) vs in b() (`rb := i`) — the two `:= i` sites.
533        let off_a = u32::try_from(src.match_indices(":= i").next().unwrap().0 + 3).unwrap();
534        let off_b = u32::try_from(src.match_indices(":= i").nth(1).unwrap().0 + 3).unwrap();
535        let da = classify(
536            &db,
537            FilePosition {
538                file: FileId(0),
539                offset: off_a,
540            },
541        )
542        .unwrap();
543        let dbf = classify(
544            &db,
545            FilePosition {
546                file: FileId(0),
547                offset: off_b,
548            },
549        )
550        .unwrap();
551        assert!(matches!(da, GodotDef::Local { .. }), "{da:?}");
552        assert!(matches!(dbf, GodotDef::Local { .. }), "{dbf:?}");
553        assert_ne!(da, dbf, "two unrelated `i`s must be distinct locals");
554    }
555
556    #[test]
557    fn local_shadowing_a_member_is_distinct() {
558        let src = "var pos := 1\nfunc f():\n\tvar pos := 2\n\tprint(pos)\n";
559        let db = db_with(&[(0, src)]);
560        // The member decl `var pos` (1st "pos") vs the local `var pos` (2nd "pos").
561        let member = at_nth(&db, 0, "pos", 0, src).unwrap();
562        let local = at_nth(&db, 0, "pos", 1, src).unwrap();
563        assert!(matches!(member, GodotDef::Member { .. }), "{member:?}");
564        assert!(matches!(local, GodotDef::Local { .. }), "{local:?}");
565        assert_ne!(member, local);
566        // The reference `pos` in `print(pos)` (3rd "pos") resolves to the LOCAL (scope wins).
567        let r = at_nth(&db, 0, "pos", 2, src).unwrap();
568        assert_eq!(r, local);
569    }
570
571    #[test]
572    fn same_named_members_of_different_classes_are_distinct() {
573        let a = "class_name A\nfunc update():\n\tpass\n";
574        let b = "class_name B\nfunc update():\n\tpass\n";
575        let db = db_with(&[(0, a), (1, b)]);
576        let ua = at(&db, 0, "update", a).unwrap();
577        let ub = at(&db, 1, "update", b).unwrap();
578        assert!(matches!(ua, GodotDef::Member { .. }));
579        assert!(matches!(ub, GodotDef::Member { .. }));
580        assert_ne!(ua, ub, "A.update and B.update must be distinct");
581    }
582
583    #[test]
584    fn class_name_decl_and_reference_classify_to_the_same_global() {
585        let widget = "class_name Widget\nfunc make() -> int:\n\treturn 1\n";
586        let user = "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n";
587        let db = db_with(&[(0, widget), (1, user)]);
588        let decl = at(&db, 0, "Widget", widget).unwrap();
589        let ann = at(&db, 1, "Widget\n", user).unwrap(); // the annotation `: Widget`
590        let ctor = at(&db, 1, "Widget.new", user).unwrap();
591        assert!(matches!(
592            decl,
593            GodotDef::Global {
594                decl_file: FileId(0),
595                ..
596            }
597        ));
598        assert_eq!(decl, ann, "annotation must resolve to the class_name def");
599        assert_eq!(
600            decl, ctor,
601            "`Widget.new()` must resolve to the class_name def"
602        );
603    }
604
605    #[test]
606    fn extends_user_class_classifies_to_the_global() {
607        // The `Base` in `extends Base` is a bare Ident (not a Name/TypeRef node); it must still
608        // classify to Base's `class_name` global — else find-refs/rename of Base would miss the
609        // `extends` and leave it stale.
610        let base = "class_name Base\nfunc m():\n\tpass\n";
611        let derived = "class_name Derived\nextends Base\n";
612        let db = db_with(&[(0, base), (1, derived)]);
613        let decl = at(&db, 0, "Base", base).unwrap();
614        let ext = at(&db, 1, "Base", derived).unwrap(); // the `Base` in `extends Base`
615        assert!(matches!(
616            decl,
617            GodotDef::Global {
618                decl_file: FileId(0),
619                ..
620            }
621        ));
622        assert_eq!(
623            decl, ext,
624            "`extends Base` must classify to Base's class_name def"
625        );
626    }
627
628    #[test]
629    fn inherited_member_resolves_to_the_declaring_base() {
630        let base = "class_name Base\nfunc base_m() -> int:\n\treturn 1\n";
631        let derived = "class_name Derived\nextends Base\nfunc use_it():\n\tself.base_m()\n";
632        let db = db_with(&[(0, base), (1, derived)]);
633        let decl = at(&db, 0, "base_m", base).unwrap();
634        let call = at(&db, 1, "base_m()", derived).unwrap();
635        assert!(matches!(
636            decl,
637            GodotDef::Member {
638                owner_file: FileId(0),
639                ..
640            }
641        ));
642        assert_eq!(
643            decl, call,
644            "inherited call must resolve to the base's member def"
645        );
646    }
647
648    #[test]
649    fn inner_class_member_is_out_of_scope() {
650        // A method inside `class Inner:` must NOT share identity with a same-named top-level method
651        // (that would let rename cross between two unrelated classes). It is out of scope → None.
652        let src =
653            "class_name A\nfunc update():\n\tpass\nclass Inner:\n\tfunc update():\n\t\tpass\n";
654        let db = db_with(&[(0, src)]);
655        let top = at_nth(&db, 0, "update", 0, src).unwrap();
656        let inner = at_nth(&db, 0, "update", 1, src);
657        assert!(matches!(top, GodotDef::Member { .. }), "{top:?}");
658        assert_eq!(
659            inner, None,
660            "an inner-class member must not classify (out of scope), got {inner:?}"
661        );
662    }
663
664    #[test]
665    fn match_capture_classifies_as_local_distinct_from_member() {
666        // A `match`-captured `var cap` is a local that shadows a same-named member; a reference to
667        // it must resolve to the Local, not the member (else rename of the member would corrupt it).
668        let src = "var cap := 0\nfunc f(v):\n\tmatch v:\n\t\tvar cap:\n\t\t\tprint(cap)\n";
669        let db = db_with(&[(0, src)]);
670        let member = at_nth(&db, 0, "cap", 0, src).unwrap();
671        let capture = at_nth(&db, 0, "cap", 1, src).unwrap();
672        let usage = at_nth(&db, 0, "cap", 2, src).unwrap();
673        assert!(matches!(member, GodotDef::Member { .. }), "{member:?}");
674        assert!(matches!(capture, GodotDef::Local { .. }), "{capture:?}");
675        assert_eq!(
676            usage, capture,
677            "`print(cap)` must resolve to the match capture"
678        );
679        assert_ne!(usage, member);
680    }
681
682    #[test]
683    fn accessor_body_local_is_not_a_member() {
684        // A `var` inside a property `get`/`set` accessor is a local, never a class member.
685        let src = "var hp: int:\n\tget:\n\t\tvar tmp = 2\n\t\treturn tmp\n";
686        let db = db_with(&[(0, src)]);
687        let tmp = at_nth(&db, 0, "tmp", 0, src);
688        assert!(
689            !matches!(tmp, Some(GodotDef::Member { .. })),
690            "a local in a get/set body must not be a Member, got {tmp:?}"
691        );
692    }
693
694    #[test]
695    fn anon_enum_variant_classifies_as_member() {
696        // An anonymous-enum variant is a class-level constant; its declaration and a bare reference
697        // must classify to the same identity (so find-refs / goto reach it).
698        let src = "enum { FIRE, ICE }\nfunc f():\n\tprint(FIRE)\n";
699        let db = db_with(&[(0, src)]);
700        let decl = at_nth(&db, 0, "FIRE", 0, src).unwrap(); // enum { FIRE }
701        let usage = at_nth(&db, 0, "FIRE", 1, src).unwrap(); // print(FIRE)
702        assert!(matches!(decl, GodotDef::Member { .. }), "{decl:?}");
703        assert_eq!(
704            decl, usage,
705            "an anon-enum variant decl and use share identity"
706        );
707    }
708
709    #[test]
710    fn shadowed_local_reference_resolves_to_the_nearest_declaration() {
711        // A param `x` and a local `var x`: the `print(x)` reference must resolve to the LOCAL
712        // (nearest-preceding decl), not the param — else find-refs/rename conflates two locals.
713        let src = "func f(x):\n\tvar x := 2\n\tprint(x)\n";
714        let db = db_with(&[(0, src)]);
715        let param = at_nth(&db, 0, "x", 0, src).unwrap();
716        let local = at_nth(&db, 0, "x", 1, src).unwrap();
717        let usage = at_nth(&db, 0, "x", 2, src).unwrap();
718        assert!(matches!(param, GodotDef::Local { .. }), "{param:?}");
719        assert!(matches!(local, GodotDef::Local { .. }), "{local:?}");
720        assert_ne!(param, local, "param x and local x are distinct");
721        assert_eq!(
722            usage, local,
723            "the reference resolves to the nearest (local) declaration"
724        );
725    }
726}