Skip to main content

lean_ctx/core/deep_queries/
mod.rs

1//! Tree-sitter deep queries for extracting imports, call sites, and type definitions.
2//!
3//! Replaces regex-based extraction in `deps.rs` with precise AST parsing.
4//! Supported languages are gated by `get_language` (and kept in sync with
5//! `core::language_capabilities`): the TypeScript/JavaScript family, Python,
6//! Rust, Go, Java, C/C++, Ruby, C#, Kotlin, Swift, PHP, Bash, Dart, Scala,
7//! Elixir, Zig, and GDScript.
8
9mod calls;
10mod imports;
11mod type_defs;
12mod types;
13
14pub use types::*;
15
16#[cfg(feature = "tree-sitter")]
17use tree_sitter::{Language, Node, Parser};
18
19pub fn analyze(content: &str, ext: &str) -> DeepAnalysis {
20    #[cfg(feature = "tree-sitter")]
21    {
22        if let Some(result) = analyze_with_tree_sitter(content, ext) {
23            return result;
24        }
25    }
26
27    let _ = (content, ext);
28    DeepAnalysis::empty()
29}
30
31#[cfg(feature = "tree-sitter")]
32fn analyze_with_tree_sitter(content: &str, ext: &str) -> Option<DeepAnalysis> {
33    let language = get_language(ext)?;
34
35    thread_local! {
36        static PARSER: std::cell::RefCell<Parser> = std::cell::RefCell::new(Parser::new());
37    }
38
39    let tree = PARSER.with(|p| {
40        let mut parser = p.borrow_mut();
41        let _ = parser.set_language(&language);
42        parser.parse(content.as_bytes(), None)
43    })?;
44    let root = tree.root_node();
45
46    let imports = imports::extract_imports(root, content, ext);
47    let calls = calls::extract_calls(root, content, ext);
48    let types = type_defs::extract_types(root, content, ext);
49    let exports = type_defs::extract_exports(root, content, ext);
50
51    Some(DeepAnalysis {
52        imports,
53        calls,
54        types,
55        exports,
56    })
57}
58
59#[cfg(feature = "tree-sitter")]
60fn get_language(ext: &str) -> Option<Language> {
61    match ext {
62        "rs" => Some(tree_sitter_rust::LANGUAGE.into()),
63        "ts" | "tsx" => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
64        "js" | "jsx" => Some(tree_sitter_javascript::LANGUAGE.into()),
65        "py" => Some(tree_sitter_python::LANGUAGE.into()),
66        "go" => Some(tree_sitter_go::LANGUAGE.into()),
67        "java" => Some(tree_sitter_java::LANGUAGE.into()),
68        "c" | "h" => Some(tree_sitter_c::LANGUAGE.into()),
69        "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Some(tree_sitter_cpp::LANGUAGE.into()),
70        "rb" => Some(tree_sitter_ruby::LANGUAGE.into()),
71        "cs" => Some(tree_sitter_c_sharp::LANGUAGE.into()),
72        "kt" | "kts" => Some(tree_sitter_kotlin_ng::LANGUAGE.into()),
73        "swift" => Some(tree_sitter_swift::LANGUAGE.into()),
74        "php" => Some(tree_sitter_php::LANGUAGE_PHP.into()),
75        "sh" | "bash" => Some(tree_sitter_bash::LANGUAGE.into()),
76        "dart" => Some(tree_sitter_dart::LANGUAGE.into()),
77        "scala" | "sc" => Some(tree_sitter_scala::LANGUAGE.into()),
78        "ex" | "exs" => Some(tree_sitter_elixir::LANGUAGE.into()),
79        "zig" => Some(tree_sitter_zig::LANGUAGE.into()),
80        "gd" => Some(tree_sitter_gdscript::LANGUAGE.into()),
81        _ => None,
82    }
83}
84
85// ---------------------------------------------------------------------------
86// Shared helpers (accessible by child modules via `super::`)
87// ---------------------------------------------------------------------------
88
89#[cfg(feature = "tree-sitter")]
90fn node_text<'a>(node: Node, src: &'a str) -> &'a str {
91    &src[node.byte_range()]
92}
93
94#[cfg(feature = "tree-sitter")]
95fn find_child_by_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
96    let mut cursor = node.walk();
97    let result = node.children(&mut cursor).find(|c| c.kind() == kind);
98    result
99}
100
101#[cfg(feature = "tree-sitter")]
102fn find_descendant_by_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
103    // Iterative (heap-stack) search — see core::ast_walk (#378 SIGABRT).
104    crate::core::ast_walk::find_descendant_by_kind(node, kind)
105}
106
107// ---------------------------------------------------------------------------
108// Tests
109// ---------------------------------------------------------------------------
110
111#[cfg(test)]
112#[cfg(feature = "tree-sitter")]
113mod tests {
114    use super::*;
115
116    /// Indexing a deeply nested AST must not overflow the worker-thread stack
117    /// (the #378 SIGABRT) through the real `analyze` entry point. The depth is
118    /// well past what a recursive walk survives on a default stack, yet because
119    /// every walk is iterative now it returns normally. (The dedicated, much
120    /// deeper overflow guard lives in `core::ast_walk`.)
121    #[test]
122    fn deeply_nested_source_does_not_overflow() {
123        let depth = 12_000;
124        // Nested Rust call expressions drive the call walk through the real
125        // entry point at a depth far past what a recursive walk survives on a
126        // default stack; it returns normally because the walks are iterative.
127        let rs = format!(
128            "fn m() {{ let _ = {}0{}; }}",
129            "f(".repeat(depth),
130            ")".repeat(depth)
131        );
132        let analysis = analyze(&rs, "rs");
133        assert!(!analysis.calls.is_empty());
134    }
135
136    #[test]
137    fn ts_named_import() {
138        let src = r"import { useState, useEffect } from 'react';";
139        let analysis = analyze(src, "ts");
140        assert_eq!(analysis.imports.len(), 1);
141        assert_eq!(analysis.imports[0].source, "react");
142        assert_eq!(analysis.imports[0].names, vec!["useState", "useEffect"]);
143    }
144
145    #[test]
146    fn ts_default_import() {
147        let src = r"import React from 'react';";
148        let analysis = analyze(src, "ts");
149        assert_eq!(analysis.imports.len(), 1);
150        assert_eq!(analysis.imports[0].kind, ImportKind::Default);
151        assert_eq!(analysis.imports[0].names, vec!["React"]);
152    }
153
154    #[test]
155    fn ts_star_import() {
156        let src = r"import * as path from 'path';";
157        let analysis = analyze(src, "ts");
158        assert_eq!(analysis.imports.len(), 1);
159        assert_eq!(analysis.imports[0].kind, ImportKind::Star);
160    }
161
162    #[test]
163    fn ts_side_effect_import() {
164        let src = r"import './styles.css';";
165        let analysis = analyze(src, "ts");
166        assert_eq!(analysis.imports.len(), 1);
167        assert_eq!(analysis.imports[0].kind, ImportKind::SideEffect);
168        assert_eq!(analysis.imports[0].source, "./styles.css");
169    }
170
171    #[test]
172    fn ts_type_only_import() {
173        let src = r"import type { User } from './types';";
174        let analysis = analyze(src, "ts");
175        assert_eq!(analysis.imports.len(), 1);
176        assert!(analysis.imports[0].is_type_only);
177    }
178
179    #[test]
180    fn ts_reexport() {
181        let src = r"export { foo, bar } from './utils';";
182        let analysis = analyze(src, "ts");
183        assert_eq!(analysis.imports.len(), 1);
184        assert_eq!(analysis.imports[0].kind, ImportKind::Reexport);
185    }
186
187    #[test]
188    fn ts_call_sites() {
189        let src = r"
190const x = foo(1);
191const y = obj.method(2);
192";
193        let analysis = analyze(src, "ts");
194        assert!(analysis.calls.len() >= 2);
195        let fns: Vec<&str> = analysis.calls.iter().map(|c| c.callee.as_str()).collect();
196        assert!(fns.contains(&"foo"));
197        assert!(fns.contains(&"method"));
198    }
199
200    #[test]
201    fn ts_interface() {
202        let src = r"
203export interface User {
204    name: string;
205    age: number;
206}
207";
208        let analysis = analyze(src, "ts");
209        assert_eq!(analysis.types.len(), 1);
210        assert_eq!(analysis.types[0].name, "User");
211        assert_eq!(analysis.types[0].kind, TypeDefKind::Interface);
212    }
213
214    #[test]
215    fn ts_type_alias_union() {
216        let src = r"type Result = Success | Error;";
217        let analysis = analyze(src, "ts");
218        assert_eq!(analysis.types.len(), 1);
219        assert_eq!(analysis.types[0].kind, TypeDefKind::Union);
220    }
221
222    #[test]
223    fn rust_use_statements() {
224        let src = r"
225use crate::core::session;
226use anyhow::Result;
227use std::collections::HashMap;
228";
229        let analysis = analyze(src, "rs");
230        assert_eq!(analysis.imports.len(), 2);
231        let sources: Vec<&str> = analysis.imports.iter().map(|i| i.source.as_str()).collect();
232        assert!(sources.contains(&"crate::core::session"));
233        assert!(sources.contains(&"anyhow::Result"));
234    }
235
236    #[test]
237    fn rust_pub_use_reexport() {
238        let src = r"pub use crate::tools::ctx_read;";
239        let analysis = analyze(src, "rs");
240        assert_eq!(analysis.imports.len(), 1);
241        assert_eq!(analysis.imports[0].kind, ImportKind::Reexport);
242    }
243
244    #[test]
245    fn rust_struct_and_trait() {
246        let src = r"
247pub struct Config {
248    pub name: String,
249}
250
251pub trait Service {
252    fn run(&self);
253}
254";
255        let analysis = analyze(src, "rs");
256        assert_eq!(analysis.types.len(), 2);
257        let names: Vec<&str> = analysis.types.iter().map(|t| t.name.as_str()).collect();
258        assert!(names.contains(&"Config"));
259        assert!(names.contains(&"Service"));
260    }
261
262    #[test]
263    fn rust_call_sites() {
264        let src = r"
265fn main() {
266    let x = calculate(42);
267    let y = self.process();
268    Vec::new();
269}
270";
271        let analysis = analyze(src, "rs");
272        assert!(analysis.calls.len() >= 2);
273        let fns: Vec<&str> = analysis.calls.iter().map(|c| c.callee.as_str()).collect();
274        assert!(fns.contains(&"calculate"));
275    }
276
277    #[test]
278    fn python_imports() {
279        let src = r"
280import os
281from pathlib import Path
282from . import utils
283from ..models import User, Role
284";
285        let analysis = analyze(src, "py");
286        assert!(analysis.imports.len() >= 3);
287    }
288
289    #[test]
290    fn python_class_protocol() {
291        let src = r"
292class MyProtocol(Protocol):
293    def method(self) -> None: ...
294
295class User:
296    name: str
297";
298        let analysis = analyze(src, "py");
299        assert_eq!(analysis.types.len(), 2);
300        assert_eq!(analysis.types[0].kind, TypeDefKind::Protocol);
301        assert_eq!(analysis.types[1].kind, TypeDefKind::Class);
302    }
303
304    #[test]
305    fn python_call_sites() {
306        // Regression for GH #365: Python uses a bare `call` node, so class
307        // instantiation and method calls must both be extracted as call sites.
308        let src = r"
309from models.engine import Engine
310
311def boot():
312    engine = Engine(power=100)
313    engine.run()
314    return engine
315";
316        let analysis = analyze(src, "py");
317        let callees: Vec<&str> = analysis.calls.iter().map(|c| c.callee.as_str()).collect();
318        assert!(
319            callees.contains(&"Engine"),
320            "class instantiation should be a call site, got {callees:?}"
321        );
322        assert!(
323            callees.contains(&"run"),
324            "method call must resolve to the method name (not the receiver), got {callees:?}"
325        );
326    }
327
328    #[test]
329    fn java_object_creation_is_a_call_site() {
330        let src = r"
331class App {
332    void boot() {
333        Engine e = new Engine(100);
334        e.run();
335    }
336}
337";
338        let analysis = analyze(src, "java");
339        let callees: Vec<&str> = analysis.calls.iter().map(|c| c.callee.as_str()).collect();
340        assert!(
341            callees.contains(&"Engine"),
342            "`new Engine()` should be a call site, got {callees:?}"
343        );
344        assert!(
345            callees.contains(&"run"),
346            "method call expected, got {callees:?}"
347        );
348    }
349
350    #[test]
351    fn go_imports() {
352        let src = r#"
353package main
354
355import (
356    "fmt"
357    "net/http"
358    _ "github.com/lib/pq"
359)
360"#;
361        let analysis = analyze(src, "go");
362        assert!(analysis.imports.len() >= 3);
363        let side_effect = analysis.imports.iter().find(|i| i.source.contains("pq"));
364        assert!(side_effect.is_some());
365        assert_eq!(side_effect.unwrap().kind, ImportKind::SideEffect);
366    }
367
368    #[test]
369    fn go_struct_and_interface() {
370        let src = r"
371package main
372
373type Server struct {
374    Port int
375}
376
377type Handler interface {
378    Handle(r *Request)
379}
380";
381        let analysis = analyze(src, "go");
382        assert_eq!(analysis.types.len(), 2);
383        let kinds: Vec<&TypeDefKind> = analysis.types.iter().map(|t| &t.kind).collect();
384        assert!(kinds.contains(&&TypeDefKind::Struct));
385        assert!(kinds.contains(&&TypeDefKind::Interface));
386    }
387
388    #[test]
389    fn java_imports() {
390        let src = r"
391import java.util.List;
392import java.util.Map;
393import static org.junit.Assert.*;
394";
395        let analysis = analyze(src, "java");
396        assert!(analysis.imports.len() >= 2);
397    }
398
399    #[test]
400    fn java_class_and_interface() {
401        let src = r"
402public class UserService {
403    public void save(User u) {}
404}
405
406public interface Repository<T> {
407    T findById(int id);
408}
409
410public enum Status { ACTIVE, INACTIVE }
411
412public record Point(int x, int y) {}
413";
414        let analysis = analyze(src, "java");
415        assert!(analysis.types.len() >= 3);
416        let kinds: Vec<&TypeDefKind> = analysis.types.iter().map(|t| &t.kind).collect();
417        assert!(kinds.contains(&&TypeDefKind::Class));
418        assert!(kinds.contains(&&TypeDefKind::Interface));
419        assert!(kinds.contains(&&TypeDefKind::Enum));
420    }
421
422    #[test]
423    fn kotlin_imports_and_aliases() {
424        let src = r"
425package com.example.app
426
427import com.example.services.UserService
428import com.example.factories.WidgetFactory as Factory
429import com.example.shared.*
430";
431        let analysis = analyze(src, "kt");
432        assert_eq!(analysis.imports.len(), 3);
433        assert_eq!(
434            analysis.imports[0].source,
435            "com.example.services.UserService"
436        );
437        assert_eq!(analysis.imports[1].names, vec!["Factory"]);
438        assert_eq!(analysis.imports[2].kind, ImportKind::Star);
439    }
440
441    #[test]
442    fn kotlin_call_sites() {
443        let src = r"
444class UserService {
445    fun run() {
446        prepare()
447        repository.save(user)
448        Factory.create()
449    }
450}
451";
452        let analysis = analyze(src, "kt");
453        let callees: Vec<&str> = analysis.calls.iter().map(|c| c.callee.as_str()).collect();
454        assert!(callees.contains(&"prepare"));
455        assert!(callees.contains(&"save"));
456        assert!(callees.contains(&"create"));
457    }
458
459    #[test]
460    fn kotlin_types_and_visibility() {
461        let src = r"
462sealed interface Handler
463data class User(val id: String)
464enum class Status { ACTIVE, INACTIVE }
465object Registry
466private typealias UserId = String
467";
468        let analysis = analyze(src, "kt");
469        let names: Vec<&str> = analysis.types.iter().map(|t| t.name.as_str()).collect();
470        assert!(names.contains(&"Handler"));
471        assert!(names.contains(&"User"));
472        assert!(names.contains(&"Status"));
473        assert!(names.contains(&"Registry"));
474        assert!(names.contains(&"UserId"));
475        let handler = analysis.types.iter().find(|t| t.name == "Handler").unwrap();
476        assert_eq!(handler.kind, TypeDefKind::Interface);
477        let alias = analysis.types.iter().find(|t| t.name == "UserId").unwrap();
478        assert!(!alias.is_exported);
479    }
480
481    #[test]
482    fn ts_generics_extracted() {
483        let src = r"interface Result<T, E> { ok: T; err: E; }";
484        let analysis = analyze(src, "ts");
485        assert_eq!(analysis.types.len(), 1);
486        assert!(!analysis.types[0].generics.is_empty());
487    }
488
489    #[test]
490    fn mixed_analysis_ts() {
491        let src = r"
492import { Request, Response } from 'express';
493import type { User } from './models';
494
495export interface Handler {
496    handle(req: Request): Response;
497}
498
499export class Router {
500    register(path: string, handler: Handler) {
501        this.handlers.set(path, handler);
502    }
503}
504
505const app = express();
506app.listen(3000);
507";
508        let analysis = analyze(src, "ts");
509        assert!(analysis.imports.len() >= 2, "Should find imports");
510        assert!(!analysis.types.is_empty(), "Should find types");
511        assert!(!analysis.calls.is_empty(), "Should find calls");
512    }
513
514    #[test]
515    fn empty_file() {
516        let analysis = analyze("", "ts");
517        assert!(analysis.imports.is_empty());
518        assert!(analysis.calls.is_empty());
519        assert!(analysis.types.is_empty());
520    }
521
522    #[test]
523    fn unsupported_extension() {
524        let analysis = analyze("some content", "txt");
525        assert!(analysis.imports.is_empty());
526    }
527
528    #[test]
529    fn c_include_import() {
530        let src = r#"
531#include "foo/bar.h"
532#include <stdio.h>
533"#;
534        let analysis = analyze(src, "c");
535        assert!(analysis.imports.iter().any(|i| i.source == "foo/bar.h"));
536    }
537
538    #[test]
539    fn bash_source_import() {
540        let src = r#"
541source "./scripts/env.sh"
542. ../common.sh
543"#;
544        let analysis = analyze(src, "sh");
545        assert!(
546            analysis
547                .imports
548                .iter()
549                .any(|i| i.source.contains("scripts/env.sh")),
550            "expected source import"
551        );
552    }
553
554    #[test]
555    fn zig_at_import() {
556        let src = r#"
557const m = @import("lib/math.zig");
558const std = @import("std");
559"#;
560        let analysis = analyze(src, "zig");
561        assert!(analysis.imports.iter().any(|i| i.source == "lib/math.zig"));
562    }
563
564    #[test]
565    fn gdscript_imports_extends_and_preload() {
566        let src = r#"
567extends "res://actors/base_actor.gd"
568
569const Bullet = preload("res://weapons/bullet.gd")
570var sfx = load("res://audio/shot.wav")
571"#;
572        let analysis = analyze(src, "gd");
573        let sources: Vec<&str> = analysis.imports.iter().map(|i| i.source.as_str()).collect();
574        assert!(
575            sources.contains(&"res://actors/base_actor.gd"),
576            "expected extends import, got {sources:?}"
577        );
578        assert!(
579            sources.contains(&"res://weapons/bullet.gd"),
580            "expected preload import, got {sources:?}"
581        );
582        assert!(
583            sources.contains(&"res://audio/shot.wav"),
584            "expected load import, got {sources:?}"
585        );
586    }
587
588    #[test]
589    fn gdscript_types_class_name_and_enum() {
590        let src = r"
591class_name Player
592
593enum State { IDLE, RUNNING }
594
595class Inventory:
596    var items = []
597";
598        let analysis = analyze(src, "gd");
599        let names: Vec<&str> = analysis.types.iter().map(|t| t.name.as_str()).collect();
600        assert!(
601            names.contains(&"Player"),
602            "expected class_name, got {names:?}"
603        );
604        assert!(names.contains(&"State"), "expected enum, got {names:?}");
605        assert!(
606            names.contains(&"Inventory"),
607            "expected inner class, got {names:?}"
608        );
609        let player = analysis.types.iter().find(|t| t.name == "Player").unwrap();
610        assert_eq!(player.kind, TypeDefKind::Class);
611        assert!(player.is_exported);
612        let state = analysis.types.iter().find(|t| t.name == "State").unwrap();
613        assert_eq!(state.kind, TypeDefKind::Enum);
614    }
615
616    #[test]
617    fn csharp_imports_all_using_forms() {
618        let src = r"
619using System;
620using System.Collections.Generic;
621global using MyApp.Core;
622using static System.Math;
623using Json = Newtonsoft.Json;
624namespace MyApp.Services {
625    using MyApp.Data.Repositories;
626}
627";
628        let analysis = analyze(src, "cs");
629        let sources: Vec<&str> = analysis.imports.iter().map(|i| i.source.as_str()).collect();
630        assert!(sources.contains(&"System"), "plain using, got {sources:?}");
631        assert!(
632            sources.contains(&"System.Collections.Generic"),
633            "dotted using, got {sources:?}"
634        );
635        assert!(
636            sources.contains(&"MyApp.Core"),
637            "global using must drop the `global` keyword, got {sources:?}"
638        );
639        assert!(
640            sources.contains(&"System.Math"),
641            "using static must drop the `static` keyword, got {sources:?}"
642        );
643        assert!(
644            sources.contains(&"Newtonsoft.Json"),
645            "alias using must keep the right-hand namespace, got {sources:?}"
646        );
647        assert!(
648            sources.contains(&"MyApp.Data.Repositories"),
649            "using nested inside a namespace block must be found, got {sources:?}"
650        );
651    }
652
653    #[test]
654    fn csharp_types_and_visibility() {
655        let src = r"
656namespace App
657{
658    public class UserService { }
659    internal class Helper { }
660    public interface IRepository { }
661    public struct Point { public int X; }
662    public enum Status { Active, Inactive }
663    public record Money(decimal Amount, string Currency);
664}
665";
666        let analysis = analyze(src, "cs");
667        let names: Vec<&str> = analysis.types.iter().map(|t| t.name.as_str()).collect();
668        assert!(names.contains(&"UserService"), "class, got {names:?}");
669        assert!(names.contains(&"Helper"), "internal class, got {names:?}");
670        assert!(names.contains(&"IRepository"), "interface, got {names:?}");
671        assert!(names.contains(&"Point"), "struct, got {names:?}");
672        assert!(names.contains(&"Status"), "enum, got {names:?}");
673        assert!(names.contains(&"Money"), "record, got {names:?}");
674
675        let kind_of = |n: &str| {
676            analysis
677                .types
678                .iter()
679                .find(|t| t.name == n)
680                .map(|t| t.kind.clone())
681        };
682        assert_eq!(kind_of("UserService"), Some(TypeDefKind::Class));
683        assert_eq!(kind_of("IRepository"), Some(TypeDefKind::Interface));
684        assert_eq!(kind_of("Point"), Some(TypeDefKind::Struct));
685        assert_eq!(kind_of("Status"), Some(TypeDefKind::Enum));
686        assert_eq!(kind_of("Money"), Some(TypeDefKind::Record));
687
688        let exported = |n: &str| {
689            analysis
690                .types
691                .iter()
692                .find(|t| t.name == n)
693                .is_some_and(|t| t.is_exported)
694        };
695        assert!(exported("UserService"), "public class is exported");
696        assert!(
697            !exported("Helper"),
698            "internal class must not be marked exported"
699        );
700        assert!(analysis.exports.contains(&"UserService".to_string()));
701    }
702
703    #[test]
704    fn csharp_call_sites() {
705        let src = r"
706namespace App
707{
708    public class Boot
709    {
710        public void Run()
711        {
712            Prepare();
713            _repository.Save(user);
714            var engine = new Engine(100);
715            Factory.Create<Widget>();
716        }
717    }
718}
719";
720        let analysis = analyze(src, "cs");
721        let callees: Vec<&str> = analysis.calls.iter().map(|c| c.callee.as_str()).collect();
722        assert!(
723            callees.contains(&"Prepare"),
724            "direct invocation, got {callees:?}"
725        );
726        assert!(
727            callees.contains(&"Save"),
728            "member invocation must resolve to the method name, got {callees:?}"
729        );
730        assert!(
731            callees.contains(&"Engine"),
732            "`new Engine()` should reference the constructed type, got {callees:?}"
733        );
734        assert!(
735            callees.contains(&"Create"),
736            "generic member call must reduce to the identifier, got {callees:?}"
737        );
738
739        let save = analysis.calls.iter().find(|c| c.callee == "Save").unwrap();
740        assert_eq!(save.receiver.as_deref(), Some("_repository"));
741        assert!(save.is_method);
742    }
743
744    #[test]
745    fn gdscript_calls_method_and_instantiation() {
746        let src = r"
747func _ready():
748    var mgr = MapDataManager.new()
749    mgr.load_map_data()
750    update_state()
751";
752        let analysis = analyze(src, "gd");
753        let callees: Vec<&str> = analysis.calls.iter().map(|c| c.callee.as_str()).collect();
754        // `MapDataManager.new()` registers a reference to the class itself.
755        assert!(
756            callees.contains(&"MapDataManager"),
757            "expected instantiation to reference class, got {callees:?}"
758        );
759        assert!(
760            callees.contains(&"load_map_data"),
761            "expected method call, got {callees:?}"
762        );
763        assert!(
764            callees.contains(&"update_state"),
765            "expected direct call, got {callees:?}"
766        );
767    }
768}