Skip to main content

gdscript_ide/
lib.rs

1//! `gdscript-ide` — the public, engine-/protocol-neutral analysis API.
2//!
3//! Modeled on rust-analyzer's `ide::AnalysisHost` / `ide::Analysis`
4//! (`plans/01-ARCHITECTURE.md` §2). [`AnalysisHost`] is the single mutable owner of the
5//! input world; [`Analysis`] is a cheap, cloneable, `Send` snapshot whose queries take
6//! byte offsets and return plain `serde` result structs from `gdscript-base` — never
7//! `lsp-types`. Each client (LSP server, the guitkx adapter, the CLI, the WASM
8//! playground) maps these POD results to its own protocol.
9//!
10//! Phase 3 (M0) swaps the engine behind these types from a plain VFS map to a **salsa**
11//! query graph in [`gdscript_db`]: the input world is now `FileText` salsa inputs, mutated
12//! through `apply_change`; [`Analysis`] is a cloned database handle (salsa handles are
13//! `Clone + Send`, replacing the old `Arc<map>` snapshot). Cancellation is now *real* —
14//! a concurrent `apply_change` cancels in-flight reads on outstanding handles, which unwind
15//! into `Err(Cancelled)` at the query boundary (see [`catch`]). The public API shape is
16//! unchanged. The crate stays `wasm32`-safe (CI guards this).
17#![cfg_attr(docsrs, feature(doc_cfg))]
18
19use std::sync::Arc;
20
21use gdscript_base::{
22    Cancellable, CodeAction, CompletionItem, Diagnostic, DocumentSymbol, FileId, FilePosition,
23    FoldRange, HoverResult, InlayHint, SignatureHelp,
24};
25use gdscript_db::{Db, RootDatabase};
26use salsa::Durability;
27
28mod features;
29mod navigation;
30mod semantic;
31mod semantic_tokens;
32
33/// Run a read query, turning a salsa cancellation (a concurrent `apply_change` invalidated the
34/// snapshot) into `Err(Cancelled)`. The closure is `AssertUnwindSafe` because the database
35/// handle it borrows is shared, immutable for the duration of the read, and salsa's unwind is
36/// panic-safe by design.
37fn catch<T>(f: impl FnOnce() -> T) -> Cancellable<T> {
38    salsa::Cancelled::catch(std::panic::AssertUnwindSafe(f)).map_err(|_| gdscript_base::Cancelled)
39}
40
41/// The single mutable owner of analysis state — one per project/workspace.
42///
43/// The input world is a virtual file system (`FileId` → UTF-8 text) held as salsa inputs; the
44/// host never reads paths. Clients push text via [`AnalysisHost::apply_change`].
45#[derive(Debug, Clone, Default)]
46pub struct AnalysisHost {
47    db: RootDatabase,
48}
49
50/// A batch of input changes. `None` text removes the file.
51#[derive(Debug, Default)]
52pub struct Change {
53    /// Files to add/replace (`Some`) or remove (`None`).
54    pub files: Vec<(FileId, Option<Arc<str>>)>,
55    /// Each file's `res://` path (loader-supplied; M3 `preload`/`extends "res://…"` resolution).
56    /// Supply it when a file is **added**; it is stable across edits, so a keystroke change must
57    /// omit it (salsa bumps an input field's revision on *every* set, even an identical value, so
58    /// re-sending a path each edit would needlessly invalidate the `res_path_registry`).
59    pub paths: Vec<(FileId, String)>,
60    /// The project's `project.godot` text (loader-supplied; M4 `[autoload]` resolution). Set once
61    /// on project open / when it changes; omit on `.gd` keystrokes.
62    pub project_config: Option<Arc<str>>,
63}
64
65impl Change {
66    /// An empty change set.
67    #[must_use]
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Queue a file add/replace.
73    pub fn change_file(&mut self, file: FileId, text: impl Into<Arc<str>>) {
74        self.files.push((file, Some(text.into())));
75    }
76
77    /// Queue a file removal.
78    pub fn remove_file(&mut self, file: FileId) {
79        self.files.push((file, None));
80    }
81
82    /// Record a file's `res://` path (the project-relative resource path the loader assigns). Set
83    /// it once, when the file is first added; omit it on subsequent edits.
84    pub fn set_file_path(&mut self, file: FileId, path: impl Into<String>) {
85        self.paths.push((file, path.into()));
86    }
87
88    /// Record the project's `project.godot` text (M4 `[autoload]` resolution). Set on project open
89    /// / when it changes; omit on `.gd` keystrokes.
90    pub fn set_project_config(&mut self, text: impl Into<Arc<str>>) {
91        self.project_config = Some(text.into());
92    }
93}
94
95impl AnalysisHost {
96    /// A new, empty host.
97    #[must_use]
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Apply a batch of input changes. The **only** mutation entry point — each `set`/`remove`
103    /// bumps the salsa revision (and cancels any in-flight reads on outstanding [`Analysis`]
104    /// handles). Edited files are `LOW` durability (they change every keystroke).
105    pub fn apply_change(&mut self, change: Change) {
106        let mut structure_changed = false;
107        for (id, text) in change.files {
108            if let Some(t) = text {
109                // A file the project hasn't seen before changes the file *set*.
110                structure_changed |= self.db.file_text(id).is_none();
111                self.db.set_file_text(id, &t, Durability::LOW);
112            } else {
113                structure_changed |= self.db.file_text(id).is_some();
114                self.db.remove_file(id);
115            }
116        }
117        // Apply `res://` paths (loader-supplied, on add). `set_file_path` no-ops when the path is
118        // unchanged, so this never invalidates the `res_path_registry` on a redundant set; the
119        // FileText must already exist, so it runs after the text loop above.
120        for (id, path) in change.paths {
121            self.db.set_file_path(id, &path);
122        }
123        // The `project.godot` config (M4 autoloads) — its own MEDIUM input, guarded against no-op
124        // re-sets, so re-opening a project doesn't invalidate the autoload registry.
125        if let Some(text) = change.project_config {
126            self.db.set_project_config(&text);
127        }
128        // Rebuild the project file-set input ONLY on add/remove — never on a body edit — so the
129        // MEDIUM-durability registry stays firewalled against keystrokes.
130        if structure_changed {
131            self.db.sync_source_root();
132        }
133    }
134
135    /// Install a runtime-fetched engine model — the **wasm path** (an `extension_api` blob the host
136    /// `fetch`ed and brotli-decoded, decoded here via `EngineApi::from_bytes`). Native builds use the
137    /// bundled model and normally never call this. Returns `false` (rather than panicking) if the
138    /// bytes fail to decode, leaving the model unset. First install wins (load-once); installing it
139    /// **after** queries have already run correctly recomputes them — the wasm engine-generation
140    /// input invalidates the affected reads, so loading the blob async (after opening a document) is
141    /// safe, not just loading it first.
142    pub fn set_engine_api(&mut self, bytes: &[u8]) -> bool {
143        match gdscript_api::EngineApi::from_bytes(bytes) {
144            Ok(api) => {
145                self.db.set_engine_api(api);
146                true
147            }
148            Err(_) => false,
149        }
150    }
151
152    /// A cheap, cloneable, `Send` snapshot for read queries (a cloned salsa database handle).
153    #[must_use]
154    pub fn analysis(&self) -> Analysis {
155        Analysis {
156            db: self.db.clone(),
157        }
158    }
159}
160
161/// An immutable snapshot of the world — a cloned salsa handle. Every query is [`Cancellable`]:
162/// a concurrent `apply_change` cancels in-flight reads, which the client re-issues against the
163/// fresh snapshot.
164#[derive(Debug, Clone)]
165pub struct Analysis {
166    db: RootDatabase,
167}
168
169impl Analysis {
170    // ---- Tier-0 features: real data ----
171
172    /// A pretty-printed dump of the syntax tree (debugging / playground).
173    ///
174    /// # Errors
175    /// `Err(Cancelled)` if a concurrent `apply_change` invalidated this snapshot.
176    pub fn syntax_tree(&self, file: FileId) -> Cancellable<Option<String>> {
177        catch(|| {
178            self.db
179                .file_text(file)
180                .map(|ft| gdscript_db::parse(&self.db, ft).debug_tree())
181        })
182    }
183
184    /// Parse-error diagnostics ∪ the Phase-2 §5 type diagnostics.
185    ///
186    /// # Errors
187    /// See [`Analysis::syntax_tree`].
188    pub fn diagnostics(&self, file: FileId) -> Cancellable<Vec<Diagnostic>> {
189        catch(|| {
190            self.db
191                .file_text(file)
192                .map(|ft| {
193                    let mut diags = features::diagnostics(&self.db, ft);
194                    diags.extend(semantic::type_diagnostics(&self.db, ft));
195                    diags
196                })
197                .unwrap_or_default()
198        })
199    }
200
201    /// The document outline (classes, funcs, vars, consts, enums, signals, members).
202    ///
203    /// # Errors
204    /// See [`Analysis::syntax_tree`].
205    pub fn document_symbols(&self, file: FileId) -> Cancellable<Vec<DocumentSymbol>> {
206        catch(|| {
207            self.db
208                .file_text(file)
209                .map(|ft| features::document_symbols(&self.db, ft))
210                .unwrap_or_default()
211        })
212    }
213
214    /// Semantic-highlighting tokens: each meaningful token classified by its contextual role
215    /// (declarations, types, parameters, members, calls, literals, comments) — richer than a
216    /// grammar. In source order.
217    ///
218    /// # Errors
219    /// See [`Analysis::syntax_tree`].
220    pub fn semantic_tokens(&self, file: FileId) -> Cancellable<Vec<gdscript_base::SemanticToken>> {
221        catch(|| {
222            self.db
223                .file_text(file)
224                .map(|ft| semantic_tokens::semantic_tokens(&self.db, ft))
225                .unwrap_or_default()
226        })
227    }
228
229    /// Foldable ranges (blocks, `#region` pairs, multi-line brackets).
230    ///
231    /// # Errors
232    /// See [`Analysis::syntax_tree`].
233    pub fn folding_ranges(&self, file: FileId) -> Cancellable<Vec<FoldRange>> {
234        catch(|| {
235            self.db
236                .file_text(file)
237                .map(|ft| features::folding_ranges(&self.db, ft))
238                .unwrap_or_default()
239        })
240    }
241
242    /// Completions. After `receiver.` it offers the inferred member set; otherwise (or when
243    /// the receiver is `Variant`/`Unknown`) it falls back to the Tier-0 by-name completion
244    /// (keywords, annotations after `@`, document-local symbols) so it never regresses.
245    ///
246    /// # Errors
247    /// See [`Analysis::syntax_tree`].
248    pub fn completions(&self, pos: FilePosition) -> Cancellable<Vec<CompletionItem>> {
249        catch(|| {
250            self.db
251                .file_text(pos.file)
252                .map(|ft| {
253                    semantic::node_path_completions(&self.db, ft, pos.offset)
254                        .or_else(|| semantic::member_completions(&self.db, ft, pos.offset))
255                        .unwrap_or_else(|| features::completions(&self.db, ft, pos.offset))
256                })
257                .unwrap_or_default()
258        })
259    }
260
261    /// Hover: the inferred type of the expression / binding under the cursor (`Unknown`
262    /// elided). `None` when there is nothing typed there.
263    ///
264    /// # Errors
265    /// See [`Analysis::syntax_tree`].
266    pub fn hover(&self, pos: FilePosition) -> Cancellable<Option<HoverResult>> {
267        catch(|| {
268            self.db
269                .file_text(pos.file)
270                .and_then(|ft| semantic::hover(&self.db, ft, pos.offset))
271        })
272    }
273
274    /// Inlay `: T` hints on `:=` declarations + unannotated params / `for`-vars (suppressed
275    /// when the type is `Variant`/`Unknown`).
276    ///
277    /// # Errors
278    /// See [`Analysis::syntax_tree`].
279    pub fn inlay_hints(&self, file: FileId) -> Cancellable<Vec<InlayHint>> {
280        catch(|| {
281            self.db
282                .file_text(file)
283                .map(|ft| semantic::inlay_hints(&self.db, ft))
284                .unwrap_or_default()
285        })
286    }
287
288    /// Signature help at a call site (active parameter by top-level comma count).
289    ///
290    /// # Errors
291    /// See [`Analysis::syntax_tree`].
292    pub fn signature_help(&self, pos: FilePosition) -> Cancellable<Option<SignatureHelp>> {
293        catch(|| {
294            self.db
295                .file_text(pos.file)
296                .and_then(|ft| semantic::signature_help(&self.db, ft, pos.offset))
297        })
298    }
299
300    /// Code actions at a position (currently "add type annotation").
301    ///
302    /// # Errors
303    /// See [`Analysis::syntax_tree`].
304    pub fn code_actions(&self, pos: FilePosition) -> Cancellable<Vec<CodeAction>> {
305        catch(|| {
306            self.db
307                .file_text(pos.file)
308                .map(|ft| semantic::code_actions(&self.db, ft, pos.offset))
309                .unwrap_or_default()
310        })
311    }
312
313    /// Go-to-definition: the declaration target(s) of the symbol under the cursor (cross-file).
314    ///
315    /// # Errors
316    /// See [`Analysis::syntax_tree`].
317    pub fn goto_definition(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::NavTarget>> {
318        catch(|| navigation::goto_definition(&self.db, pos))
319    }
320
321    /// Find every reference to the symbol under the cursor, project-wide (incl. its declaration).
322    ///
323    /// # Errors
324    /// See [`Analysis::syntax_tree`].
325    pub fn find_references(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::Reference>> {
326        catch(|| navigation::find_references(&self.db, pos))
327    }
328
329    /// Rename the symbol under the cursor to `new_name` — a cross-file edit, or a refusal
330    /// ([`RenameError`](gdscript_base::RenameError)); never a partial edit.
331    ///
332    /// # Errors
333    /// `Err(Cancelled)` if a concurrent `apply_change` invalidated this snapshot. The rename's own
334    /// refusal is the `Result` *inside* the `Cancellable`.
335    pub fn rename(
336        &self,
337        pos: FilePosition,
338        new_name: &str,
339    ) -> Cancellable<Result<gdscript_base::SourceChange, gdscript_base::RenameError>> {
340        catch(|| navigation::rename(&self.db, pos, new_name))
341    }
342
343    /// Project-wide symbols matching `query` (fuzzy-ranked class names + members).
344    ///
345    /// # Errors
346    /// See [`Analysis::syntax_tree`].
347    pub fn workspace_symbols(&self, query: &str) -> Cancellable<Vec<gdscript_base::NavTarget>> {
348        catch(|| navigation::workspace_symbols(&self.db, query))
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn host_with(src: &str) -> (AnalysisHost, FileId) {
357        let mut host = AnalysisHost::new();
358        let file = FileId(0);
359        let mut change = Change::new();
360        change.change_file(file, src);
361        host.apply_change(change);
362        (host, file)
363    }
364
365    #[test]
366    fn snapshot_reads_applied_files() {
367        let (host, file) = host_with("func f():\n\tpass\n");
368        let analysis = host.analysis();
369        let symbols = analysis.document_symbols(file).unwrap();
370        assert_eq!(symbols.len(), 1);
371        assert_eq!(symbols[0].name, "f");
372    }
373
374    #[test]
375    fn preload_resolves_cross_file_through_the_public_api() {
376        // The real `guitkx.gd` pattern, end-to-end through `apply_change` + `set_file_path`:
377        // `const M = preload("res://…")` then `M.new().method()`.
378        let mut host = AnalysisHost::new();
379        let mut change = Change::new();
380        change.change_file(
381            FileId(0),
382            "class_name Markup\nfunc parse() -> int:\n\treturn 1\n",
383        );
384        change.set_file_path(FileId(0), "res://markup.gd");
385        change.change_file(
386            FileId(1),
387            "const M = preload(\"res://markup.gd\")\nfunc go():\n\tvar n := M.new().parse()\n",
388        );
389        change.set_file_path(FileId(1), "res://main.gd");
390        host.apply_change(change);
391        let analysis = host.analysis();
392
393        // Valid code → no diagnostics.
394        assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
395        // The cross-file preload resolved, so `n` is typed `int`; an inlay hint proves it (an
396        // *unresolved* preload would leave `n` on the seam, suppressing the hint).
397        let hints = analysis.inlay_hints(FileId(1)).unwrap();
398        assert!(
399            hints.iter().any(|h| h.label.contains("int")),
400            "expected an `: int` inlay on the preload-resolved binding, got {hints:?}",
401        );
402    }
403
404    #[test]
405    fn autoload_resolves_cross_file_through_the_public_api() {
406        // End-to-end through `apply_change` + `set_project_config`: a `*`-singleton autoload
407        // script (no class_name — resolved by path) used by its bare name.
408        let mut host = AnalysisHost::new();
409        let mut change = Change::new();
410        change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
411        change.set_file_path(FileId(0), "res://audio.gd");
412        change.change_file(FileId(1), "func go():\n\tvar v := Audio.volume()\n");
413        change.set_file_path(FileId(1), "res://main.gd");
414        change.set_project_config("[autoload]\nAudio=\"*res://audio.gd\"\n");
415        host.apply_change(change);
416        let analysis = host.analysis();
417
418        assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
419        // `Audio.volume()` resolved cross-file via the autoload singleton → `v : int` inlay.
420        let hints = analysis.inlay_hints(FileId(1)).unwrap();
421        assert!(
422            hints.iter().any(|h| h.label.contains("int")),
423            "expected an `: int` inlay on the autoload-resolved binding, got {hints:?}",
424        );
425    }
426
427    #[test]
428    fn scene_node_path_typing_through_the_public_api() {
429        // The Phase-4 killer feature end-to-end: a `.tscn` injected via `apply_change` + a script it
430        // attaches → `$Btn` types as `Button`, surfaced as an `: Button` inlay (zero annotations).
431        let mut host = AnalysisHost::new();
432        let mut change = Change::new();
433        change.change_file(
434            FileId(0),
435            "[gd_scene format=3]\n\
436             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
437             [node name=\"Root\" type=\"Control\"]\n\
438             script = ExtResource(\"1\")\n\
439             [node name=\"Btn\" type=\"Button\" parent=\".\"]\n",
440        );
441        change.set_file_path(FileId(0), "res://main.tscn");
442        change.change_file(
443            FileId(1),
444            "extends Control\nfunc _ready():\n\tvar b := $Btn\n",
445        );
446        change.set_file_path(FileId(1), "res://main.gd");
447        host.apply_change(change);
448        let analysis = host.analysis();
449
450        assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
451        let hints = analysis.inlay_hints(FileId(1)).unwrap();
452        assert!(
453            hints.iter().any(|h| h.label.contains("Button")),
454            "expected a `: Button` inlay on `var b := $Btn`, got {hints:?}",
455        );
456    }
457
458    #[test]
459    fn node_path_completion_offers_scene_children() {
460        // `$Panel/` offers Panel's children (typed by their `type=`); `$` offers the attach node's.
461        let mut host = AnalysisHost::new();
462        let mut change = Change::new();
463        change.change_file(
464            FileId(0),
465            "[gd_scene format=3]\n\
466             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
467             [node name=\"Root\" type=\"Control\"]\n\
468             script = ExtResource(\"1\")\n\
469             [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
470             [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n\
471             [node name=\"Cancel\" type=\"Button\" parent=\"Panel\"]\n",
472        );
473        change.set_file_path(FileId(0), "res://main.tscn");
474        let gd = "extends Control\nfunc _ready():\n\tvar b := $Panel/\n";
475        change.change_file(FileId(1), gd);
476        change.set_file_path(FileId(1), "res://main.gd");
477        host.apply_change(change);
478        let analysis = host.analysis();
479
480        let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
481        let items = analysis
482            .completions(FilePosition {
483                file: FileId(1),
484                offset,
485            })
486            .unwrap();
487        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
488        assert!(
489            labels.contains(&"Ok") && labels.contains(&"Cancel"),
490            "{labels:?}"
491        );
492        // node completions are typed by their `type=` and don't leak keywords/locals here.
493        assert!(
494            items
495                .iter()
496                .find(|i| i.label == "Ok")
497                .is_some_and(|i| i.detail.as_deref() == Some("Button")),
498            "{items:?}",
499        );
500        assert!(
501            !labels.contains(&"func"),
502            "should be node-path, not keyword, completion"
503        );
504    }
505
506    #[test]
507    fn node_path_completion_does_not_hijack_inside_a_string_literal() {
508        // A `$child/` that appears INSIDE a string literal must NOT trigger node-path completion
509        // (the byte scan has no lexer awareness; a `String` token at the cursor suppresses it).
510        let mut host = AnalysisHost::new();
511        let mut change = Change::new();
512        change.change_file(
513            FileId(0),
514            "[gd_scene format=3]\n\
515             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
516             [node name=\"Root\" type=\"Control\"]\n\
517             script = ExtResource(\"1\")\n\
518             [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
519             [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n",
520        );
521        change.set_file_path(FileId(0), "res://main.tscn");
522        let gd = "extends Control\nfunc _ready():\n\tvar s := \"$Panel/\"\n";
523        change.change_file(FileId(1), gd);
524        change.set_file_path(FileId(1), "res://main.gd");
525        host.apply_change(change);
526        let analysis = host.analysis();
527
528        // cursor right after the `/`, INSIDE the string literal.
529        let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
530        let items = analysis
531            .completions(FilePosition {
532                file: FileId(1),
533                offset,
534            })
535            .unwrap();
536        assert!(
537            !items.iter().any(|i| i.label == "Ok"),
538            "node names must not leak into a string literal: {items:?}",
539        );
540    }
541
542    #[test]
543    fn unique_node_path_completion_offers_children() {
544        // `%Box/` resolves the unique node `Box` scene-wide and offers its children, typed by `type=`.
545        let mut host = AnalysisHost::new();
546        let mut change = Change::new();
547        let scene = "[gd_scene format=3]\n\
548             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
549             [node name=\"Root\" type=\"Control\"]\n\
550             script = ExtResource(\"1\")\n\
551             [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
552             unique_name_in_owner = true\n\
553             [node name=\"Ok\" type=\"Button\" parent=\"Box\"]\n\
554             [node name=\"Cancel\" type=\"Button\" parent=\"Box\"]\n";
555        change.change_file(FileId(0), scene);
556        change.set_file_path(FileId(0), "res://main.tscn");
557        let gd = "extends Control\nfunc _ready():\n\tvar b := %Box/\n";
558        change.change_file(FileId(1), gd);
559        change.set_file_path(FileId(1), "res://main.gd");
560        host.apply_change(change);
561        let analysis = host.analysis();
562        let offset = u32::try_from(gd.find("%Box/").unwrap() + "%Box/".len()).unwrap();
563        let items = analysis
564            .completions(FilePosition {
565                file: FileId(1),
566                offset,
567            })
568            .unwrap();
569        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
570        assert!(
571            labels.contains(&"Ok") && labels.contains(&"Cancel"),
572            "{labels:?}"
573        );
574        assert!(
575            !labels.contains(&"func"),
576            "node-path, not keyword completion"
577        );
578    }
579
580    #[test]
581    fn bare_percent_offers_all_unique_nodes() {
582        // A bare `%` offers every unique node in the owning scene (scene-wide), not just children.
583        let mut host = AnalysisHost::new();
584        let mut change = Change::new();
585        let scene = "[gd_scene format=3]\n\
586             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
587             [node name=\"Root\" type=\"Control\"]\n\
588             script = ExtResource(\"1\")\n\
589             [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
590             unique_name_in_owner = true\n\
591             [node name=\"Hud\" type=\"Control\" parent=\".\"]\n\
592             unique_name_in_owner = true\n";
593        change.change_file(FileId(0), scene);
594        change.set_file_path(FileId(0), "res://main.tscn");
595        let gd = "extends Control\nfunc _ready():\n\tvar b := %\n";
596        change.change_file(FileId(1), gd);
597        change.set_file_path(FileId(1), "res://main.gd");
598        host.apply_change(change);
599        let analysis = host.analysis();
600        let offset = u32::try_from(gd.find("%\n").unwrap() + 1).unwrap();
601        let labels: Vec<_> = analysis
602            .completions(FilePosition {
603                file: FileId(1),
604                offset,
605            })
606            .unwrap()
607            .into_iter()
608            .map(|i| i.label)
609            .collect();
610        assert!(
611            labels.iter().any(|l| l == "Box") && labels.iter().any(|l| l == "Hud"),
612            "{labels:?}"
613        );
614    }
615
616    #[test]
617    fn percent_modulo_is_not_hijacked_as_a_unique_path() {
618        // `count % Box` is modulo, not a unique-node path — completion must stay by-name (the parsed
619        // `%` token's parent is `BinExpr`, not `UniqueNodeExpr`).
620        let mut host = AnalysisHost::new();
621        let mut change = Change::new();
622        let scene = "[gd_scene format=3]\n\
623             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
624             [node name=\"Root\" type=\"Control\"]\n\
625             script = ExtResource(\"1\")\n\
626             [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
627             unique_name_in_owner = true\n";
628        change.change_file(FileId(0), scene);
629        change.set_file_path(FileId(0), "res://main.tscn");
630        let gd = "extends Control\nfunc _ready():\n\tvar count := 10\n\tvar b := count %Box\n";
631        change.change_file(FileId(1), gd);
632        change.set_file_path(FileId(1), "res://main.gd");
633        host.apply_change(change);
634        let analysis = host.analysis();
635        let offset = u32::try_from(gd.find("%Box").unwrap() + "%Box".len()).unwrap();
636        let labels: Vec<_> = analysis
637            .completions(FilePosition {
638                file: FileId(1),
639                offset,
640            })
641            .unwrap()
642            .into_iter()
643            .map(|i| i.label)
644            .collect();
645        // By-name completion ran (keywords present), node-path did not hijack the modulo.
646        assert!(
647            labels.iter().any(|l| l == "func"),
648            "expected by-name completion: {labels:?}"
649        );
650    }
651
652    #[test]
653    fn completion_is_scope_aware_for_locals_and_params() {
654        // By-name completion must offer class members everywhere, but a parameter / local of one
655        // function must NOT leak into a sibling function. The enclosing function is found by
656        // indentation, so completing on a fresh (empty) indented line at the end of a body still
657        // sees that body's own params/locals (the case the CST-range approach regressed).
658        let mut host = AnalysisHost::new();
659        let mut change = Change::new();
660        let gd = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t\nfunc b(pb):\n\tvar lb := 2\n";
661        change.change_file(FileId(0), gd);
662        change.set_file_path(FileId(0), "res://m.gd");
663        host.apply_change(change);
664        let analysis = host.analysis();
665
666        // Cursor on the empty indented line inside a() (right after the body's tab).
667        let upto = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t";
668        let offset = u32::try_from(gd.find(upto).unwrap() + upto.len()).unwrap();
669        let items = analysis
670            .completions(FilePosition {
671                file: FileId(0),
672                offset,
673            })
674            .unwrap();
675        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
676        // Own param + own local + the class member + both func names are visible.
677        assert!(labels.contains(&"pa"), "own param `pa`: {labels:?}");
678        assert!(labels.contains(&"la"), "own local `la`: {labels:?}");
679        assert!(labels.contains(&"member_v"), "class member: {labels:?}");
680        assert!(
681            labels.contains(&"a") && labels.contains(&"b"),
682            "sibling func names: {labels:?}",
683        );
684        // b()'s param + local must NOT leak into a().
685        assert!(!labels.contains(&"pb"), "leaked b's param: {labels:?}");
686        assert!(!labels.contains(&"lb"), "leaked b's local: {labels:?}");
687    }
688
689    #[test]
690    fn completion_at_class_level_offers_members_not_locals() {
691        // At class level (no enclosing function) only members are offered — no function's locals.
692        let mut host = AnalysisHost::new();
693        let mut change = Change::new();
694        let gd = "var member_v := 0\nfunc a():\n\tvar la := 1\n\nm\n";
695        change.change_file(FileId(0), gd);
696        change.set_file_path(FileId(0), "res://m.gd");
697        host.apply_change(change);
698        let analysis = host.analysis();
699        // Cursor after the top-level `m` (class level, indent 0).
700        let offset = u32::try_from(gd.rfind('m').unwrap() + 1).unwrap();
701        let items = analysis
702            .completions(FilePosition {
703                file: FileId(0),
704                offset,
705            })
706            .unwrap();
707        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
708        assert!(
709            labels.contains(&"member_v") && labels.contains(&"a"),
710            "{labels:?}"
711        );
712        assert!(
713            !labels.contains(&"la"),
714            "a()'s local must not leak to class level: {labels:?}"
715        );
716    }
717
718    #[test]
719    fn completion_offers_params_in_lambda_setter_and_inline_bodies() {
720        // Regression (bug-hunt): the scope filter must offer a callable's own params inside its body
721        // for ALL callable kinds, not just multi-line `func`s: a top-level named lambda, a `get`/`set`
722        // accessor, and a one-line `func`. (The indentation-only scan missed these, hiding the param.)
723        let cases = [
724            // (source, the param that must be offered, a marker the cursor is placed right after)
725            ("var f := func(px):\n\treturn px\n", "px", "return "),
726            ("var x: int:\n\tset(sv):\n\t\t_x = sv\n", "sv", "_x = "),
727            ("func foo(ia): return ia\n", "ia", "return "),
728        ];
729        for (gd, param, marker) in cases {
730            let mut host = AnalysisHost::new();
731            let mut change = Change::new();
732            change.change_file(FileId(0), gd);
733            change.set_file_path(FileId(0), "res://m.gd");
734            host.apply_change(change);
735            let analysis = host.analysis();
736            let offset = u32::try_from(gd.find(marker).unwrap() + marker.len()).unwrap();
737            let labels: Vec<_> = analysis
738                .completions(FilePosition {
739                    file: FileId(0),
740                    offset,
741                })
742                .unwrap()
743                .into_iter()
744                .map(|i| i.label)
745                .collect();
746            assert!(
747                labels.iter().any(|l| l == param),
748                "param `{param}` should be offered inside its body for {gd:?}, got {labels:?}",
749            );
750        }
751    }
752
753    #[test]
754    fn goto_definition_on_a_node_path_jumps_into_the_tscn() {
755        // Cursor on `$Btn` → a NavTarget pointing at the `[node name="Btn" …]` line in the owning
756        // `.tscn` (the inverse of M1 typing; navigation the engine LSP cannot provide).
757        let mut host = AnalysisHost::new();
758        let mut change = Change::new();
759        let scene = "[gd_scene format=3]\n\
760             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
761             [node name=\"Root\" type=\"Control\"]\n\
762             script = ExtResource(\"1\")\n\
763             [node name=\"Btn\" type=\"Button\" parent=\".\"]\n";
764        let gd = "extends Control\nfunc _ready():\n\tvar b := $Btn\n";
765        change.change_file(FileId(0), scene);
766        change.set_file_path(FileId(0), "res://main.tscn");
767        change.change_file(FileId(1), gd);
768        change.set_file_path(FileId(1), "res://main.gd");
769        host.apply_change(change);
770        let analysis = host.analysis();
771
772        let offset = u32::try_from(gd.find("$Btn").unwrap() + 1).unwrap(); // on the `B`
773        let targets = analysis
774            .goto_definition(FilePosition {
775                file: FileId(1),
776                offset,
777            })
778            .unwrap();
779        assert_eq!(targets.len(), 1, "{targets:?}");
780        assert_eq!(targets[0].file, FileId(0), "jumps into the .tscn");
781        let focus =
782            &scene[targets[0].focus_range.start as usize..targets[0].focus_range.end as usize];
783        assert!(
784            focus.contains("Btn"),
785            "focus on the node name, got {focus:?}"
786        );
787    }
788
789    #[test]
790    fn find_refs_and_rename_cross_file_through_the_public_api() {
791        let mut host = AnalysisHost::new();
792        let mut change = Change::new();
793        change.change_file(
794            FileId(0),
795            "class_name Widget\nfunc make() -> int:\n\treturn 1\n",
796        );
797        change.set_file_path(FileId(0), "res://widget.gd");
798        change.change_file(
799            FileId(1),
800            "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n",
801        );
802        change.set_file_path(FileId(1), "res://main.gd");
803        host.apply_change(change);
804        let analysis = host.analysis();
805        // The `class_name Widget` declaration name starts at offset 11 (`"class_name "` is 11).
806        let at_decl = FilePosition {
807            file: FileId(0),
808            offset: 11,
809        };
810        // find-refs: declaration (f0) + annotation + `.new()` (f1) = 3.
811        let refs = analysis.find_references(at_decl).unwrap();
812        assert_eq!(refs.len(), 3, "{refs:?}");
813        // rename → a cross-file SourceChange touching both files.
814        let edit = analysis
815            .rename(at_decl, "Gadget")
816            .unwrap()
817            .expect("rename ok");
818        assert_eq!(edit.edits.len(), 2, "both files edited");
819    }
820
821    #[test]
822    fn removing_a_file_clears_it() {
823        let (mut host, file) = host_with("var x = 1\n");
824        let mut change = Change::new();
825        change.remove_file(file);
826        host.apply_change(change);
827        let analysis = host.analysis();
828        assert!(analysis.document_symbols(file).unwrap().is_empty());
829    }
830}