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