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    /// Format `file`'s source, returning the tidied text — or `None` if the file is unknown.
202    /// Safe by construction: it normalizes whitespace + indentation and never changes meaning,
203    /// falling back to the original on anything it can't safely reformat (see [`gdscript_fmt`]).
204    ///
205    /// # Errors
206    /// See [`Analysis::syntax_tree`].
207    pub fn format(&self, file: FileId) -> Cancellable<Option<String>> {
208        catch(|| {
209            self.db.file_text(file).map(|ft| {
210                gdscript_fmt::format(ft.text(&self.db), &gdscript_fmt::FmtConfig::default())
211            })
212        })
213    }
214
215    /// The document outline (classes, funcs, vars, consts, enums, signals, members).
216    ///
217    /// # Errors
218    /// See [`Analysis::syntax_tree`].
219    pub fn document_symbols(&self, file: FileId) -> Cancellable<Vec<DocumentSymbol>> {
220        catch(|| {
221            self.db
222                .file_text(file)
223                .map(|ft| features::document_symbols(&self.db, ft))
224                .unwrap_or_default()
225        })
226    }
227
228    /// Semantic-highlighting tokens: each meaningful token classified by its contextual role
229    /// (declarations, types, parameters, members, calls, literals, comments) — richer than a
230    /// grammar. In source order.
231    ///
232    /// # Errors
233    /// See [`Analysis::syntax_tree`].
234    pub fn semantic_tokens(&self, file: FileId) -> Cancellable<Vec<gdscript_base::SemanticToken>> {
235        catch(|| {
236            self.db
237                .file_text(file)
238                .map(|ft| semantic_tokens::semantic_tokens(&self.db, ft))
239                .unwrap_or_default()
240        })
241    }
242
243    /// Foldable ranges (blocks, `#region` pairs, multi-line brackets).
244    ///
245    /// # Errors
246    /// See [`Analysis::syntax_tree`].
247    pub fn folding_ranges(&self, file: FileId) -> Cancellable<Vec<FoldRange>> {
248        catch(|| {
249            self.db
250                .file_text(file)
251                .map(|ft| features::folding_ranges(&self.db, ft))
252                .unwrap_or_default()
253        })
254    }
255
256    /// Completions. After `receiver.` it offers the inferred member set; otherwise (or when
257    /// the receiver is `Variant`/`Unknown`) it falls back to the Tier-0 by-name completion
258    /// (keywords, annotations after `@`, document-local symbols) so it never regresses.
259    ///
260    /// # Errors
261    /// See [`Analysis::syntax_tree`].
262    pub fn completions(&self, pos: FilePosition) -> Cancellable<Vec<CompletionItem>> {
263        catch(|| {
264            self.db
265                .file_text(pos.file)
266                .map(|ft| {
267                    semantic::node_path_completions(&self.db, ft, pos.offset)
268                        .or_else(|| semantic::member_completions(&self.db, ft, pos.offset))
269                        .unwrap_or_else(|| features::completions(&self.db, ft, pos.offset))
270                })
271                .unwrap_or_default()
272        })
273    }
274
275    /// Hover: the inferred type of the expression / binding under the cursor (`Unknown`
276    /// elided). `None` when there is nothing typed there.
277    ///
278    /// # Errors
279    /// See [`Analysis::syntax_tree`].
280    pub fn hover(&self, pos: FilePosition) -> Cancellable<Option<HoverResult>> {
281        catch(|| {
282            self.db
283                .file_text(pos.file)
284                .and_then(|ft| semantic::hover(&self.db, ft, pos.offset))
285        })
286    }
287
288    /// Inlay `: T` hints on `:=` declarations + unannotated params / `for`-vars (suppressed
289    /// when the type is `Variant`/`Unknown`).
290    ///
291    /// # Errors
292    /// See [`Analysis::syntax_tree`].
293    pub fn inlay_hints(&self, file: FileId) -> Cancellable<Vec<InlayHint>> {
294        catch(|| {
295            self.db
296                .file_text(file)
297                .map(|ft| semantic::inlay_hints(&self.db, ft))
298                .unwrap_or_default()
299        })
300    }
301
302    /// Signature help at a call site (active parameter by top-level comma count).
303    ///
304    /// # Errors
305    /// See [`Analysis::syntax_tree`].
306    pub fn signature_help(&self, pos: FilePosition) -> Cancellable<Option<SignatureHelp>> {
307        catch(|| {
308            self.db
309                .file_text(pos.file)
310                .and_then(|ft| semantic::signature_help(&self.db, ft, pos.offset))
311        })
312    }
313
314    /// Code actions at a position (currently "add type annotation").
315    ///
316    /// # Errors
317    /// See [`Analysis::syntax_tree`].
318    pub fn code_actions(&self, pos: FilePosition) -> Cancellable<Vec<CodeAction>> {
319        catch(|| {
320            self.db
321                .file_text(pos.file)
322                .map(|ft| semantic::code_actions(&self.db, ft, pos.offset))
323                .unwrap_or_default()
324        })
325    }
326
327    /// Go-to-definition: the declaration target(s) of the symbol under the cursor (cross-file).
328    ///
329    /// # Errors
330    /// See [`Analysis::syntax_tree`].
331    pub fn goto_definition(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::NavTarget>> {
332        catch(|| navigation::goto_definition(&self.db, pos))
333    }
334
335    /// Find every reference to the symbol under the cursor, project-wide (incl. its declaration).
336    ///
337    /// # Errors
338    /// See [`Analysis::syntax_tree`].
339    pub fn find_references(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::Reference>> {
340        catch(|| navigation::find_references(&self.db, pos))
341    }
342
343    /// Rename the symbol under the cursor to `new_name` — a cross-file edit, or a refusal
344    /// ([`RenameError`](gdscript_base::RenameError)); never a partial edit.
345    ///
346    /// # Errors
347    /// `Err(Cancelled)` if a concurrent `apply_change` invalidated this snapshot. The rename's own
348    /// refusal is the `Result` *inside* the `Cancellable`.
349    pub fn rename(
350        &self,
351        pos: FilePosition,
352        new_name: &str,
353    ) -> Cancellable<Result<gdscript_base::SourceChange, gdscript_base::RenameError>> {
354        catch(|| navigation::rename(&self.db, pos, new_name))
355    }
356
357    /// Project-wide symbols matching `query` (fuzzy-ranked class names + members).
358    ///
359    /// # Errors
360    /// See [`Analysis::syntax_tree`].
361    pub fn workspace_symbols(&self, query: &str) -> Cancellable<Vec<gdscript_base::NavTarget>> {
362        catch(|| navigation::workspace_symbols(&self.db, query))
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    fn host_with(src: &str) -> (AnalysisHost, FileId) {
371        let mut host = AnalysisHost::new();
372        let file = FileId(0);
373        let mut change = Change::new();
374        change.change_file(file, src);
375        host.apply_change(change);
376        (host, file)
377    }
378
379    #[test]
380    fn snapshot_reads_applied_files() {
381        let (host, file) = host_with("func f():\n\tpass\n");
382        let analysis = host.analysis();
383        let symbols = analysis.document_symbols(file).unwrap();
384        assert_eq!(symbols.len(), 1);
385        assert_eq!(symbols[0].name, "f");
386    }
387
388    #[test]
389    fn preload_resolves_cross_file_through_the_public_api() {
390        // The real `guitkx.gd` pattern, end-to-end through `apply_change` + `set_file_path`:
391        // `const M = preload("res://…")` then `M.new().method()`.
392        let mut host = AnalysisHost::new();
393        let mut change = Change::new();
394        change.change_file(
395            FileId(0),
396            "class_name Markup\nfunc parse() -> int:\n\treturn 1\n",
397        );
398        change.set_file_path(FileId(0), "res://markup.gd");
399        change.change_file(
400            FileId(1),
401            "const M = preload(\"res://markup.gd\")\nfunc go():\n\tvar n := M.new().parse()\n\treturn n\n",
402        );
403        change.set_file_path(FileId(1), "res://main.gd");
404        host.apply_change(change);
405        let analysis = host.analysis();
406
407        // Valid code → no diagnostics.
408        assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
409        // The cross-file preload resolved, so `n` is typed `int`; an inlay hint proves it (an
410        // *unresolved* preload would leave `n` on the seam, suppressing the hint).
411        let hints = analysis.inlay_hints(FileId(1)).unwrap();
412        assert!(
413            hints.iter().any(|h| h.label.contains("int")),
414            "expected an `: int` inlay on the preload-resolved binding, got {hints:?}",
415        );
416    }
417
418    #[test]
419    fn autoload_resolves_cross_file_through_the_public_api() {
420        // End-to-end through `apply_change` + `set_project_config`: a `*`-singleton autoload
421        // script (no class_name — resolved by path) used by its bare name.
422        let mut host = AnalysisHost::new();
423        let mut change = Change::new();
424        change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
425        change.set_file_path(FileId(0), "res://audio.gd");
426        change.change_file(
427            FileId(1),
428            "func go():\n\tvar v := Audio.volume()\n\treturn v\n",
429        );
430        change.set_file_path(FileId(1), "res://main.gd");
431        change.set_project_config("[autoload]\nAudio=\"*res://audio.gd\"\n");
432        host.apply_change(change);
433        let analysis = host.analysis();
434
435        assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
436        // `Audio.volume()` resolved cross-file via the autoload singleton → `v : int` inlay.
437        let hints = analysis.inlay_hints(FileId(1)).unwrap();
438        assert!(
439            hints.iter().any(|h| h.label.contains("int")),
440            "expected an `: int` inlay on the autoload-resolved binding, got {hints:?}",
441        );
442    }
443
444    #[test]
445    fn scene_node_path_typing_through_the_public_api() {
446        // The Phase-4 killer feature end-to-end: a `.tscn` injected via `apply_change` + a script it
447        // attaches → `$Btn` types as `Button`, surfaced as an `: Button` inlay (zero annotations).
448        let mut host = AnalysisHost::new();
449        let mut change = Change::new();
450        change.change_file(
451            FileId(0),
452            "[gd_scene format=3]\n\
453             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
454             [node name=\"Root\" type=\"Control\"]\n\
455             script = ExtResource(\"1\")\n\
456             [node name=\"Btn\" type=\"Button\" parent=\".\"]\n",
457        );
458        change.set_file_path(FileId(0), "res://main.tscn");
459        change.change_file(
460            FileId(1),
461            "extends Control\nfunc _ready():\n\tvar b := $Btn\n\tb.show()\n",
462        );
463        change.set_file_path(FileId(1), "res://main.gd");
464        host.apply_change(change);
465        let analysis = host.analysis();
466
467        assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
468        let hints = analysis.inlay_hints(FileId(1)).unwrap();
469        assert!(
470            hints.iter().any(|h| h.label.contains("Button")),
471            "expected a `: Button` inlay on `var b := $Btn`, got {hints:?}",
472        );
473    }
474
475    #[test]
476    fn node_path_completion_offers_scene_children() {
477        // `$Panel/` offers Panel's children (typed by their `type=`); `$` offers the attach node's.
478        let mut host = AnalysisHost::new();
479        let mut change = Change::new();
480        change.change_file(
481            FileId(0),
482            "[gd_scene format=3]\n\
483             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
484             [node name=\"Root\" type=\"Control\"]\n\
485             script = ExtResource(\"1\")\n\
486             [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
487             [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n\
488             [node name=\"Cancel\" type=\"Button\" parent=\"Panel\"]\n",
489        );
490        change.set_file_path(FileId(0), "res://main.tscn");
491        let gd = "extends Control\nfunc _ready():\n\tvar b := $Panel/\n";
492        change.change_file(FileId(1), gd);
493        change.set_file_path(FileId(1), "res://main.gd");
494        host.apply_change(change);
495        let analysis = host.analysis();
496
497        let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
498        let items = analysis
499            .completions(FilePosition {
500                file: FileId(1),
501                offset,
502            })
503            .unwrap();
504        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
505        assert!(
506            labels.contains(&"Ok") && labels.contains(&"Cancel"),
507            "{labels:?}"
508        );
509        // node completions are typed by their `type=` and don't leak keywords/locals here.
510        assert!(
511            items
512                .iter()
513                .find(|i| i.label == "Ok")
514                .is_some_and(|i| i.detail.as_deref() == Some("Button")),
515            "{items:?}",
516        );
517        assert!(
518            !labels.contains(&"func"),
519            "should be node-path, not keyword, completion"
520        );
521    }
522
523    #[test]
524    fn node_path_completion_does_not_hijack_inside_a_string_literal() {
525        // A `$child/` that appears INSIDE a string literal must NOT trigger node-path completion
526        // (the byte scan has no lexer awareness; a `String` token at the cursor suppresses it).
527        let mut host = AnalysisHost::new();
528        let mut change = Change::new();
529        change.change_file(
530            FileId(0),
531            "[gd_scene format=3]\n\
532             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
533             [node name=\"Root\" type=\"Control\"]\n\
534             script = ExtResource(\"1\")\n\
535             [node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
536             [node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n",
537        );
538        change.set_file_path(FileId(0), "res://main.tscn");
539        let gd = "extends Control\nfunc _ready():\n\tvar s := \"$Panel/\"\n";
540        change.change_file(FileId(1), gd);
541        change.set_file_path(FileId(1), "res://main.gd");
542        host.apply_change(change);
543        let analysis = host.analysis();
544
545        // cursor right after the `/`, INSIDE the string literal.
546        let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
547        let items = analysis
548            .completions(FilePosition {
549                file: FileId(1),
550                offset,
551            })
552            .unwrap();
553        assert!(
554            !items.iter().any(|i| i.label == "Ok"),
555            "node names must not leak into a string literal: {items:?}",
556        );
557    }
558
559    #[test]
560    fn unique_node_path_completion_offers_children() {
561        // `%Box/` resolves the unique node `Box` scene-wide and offers its children, typed by `type=`.
562        let mut host = AnalysisHost::new();
563        let mut change = Change::new();
564        let scene = "[gd_scene format=3]\n\
565             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
566             [node name=\"Root\" type=\"Control\"]\n\
567             script = ExtResource(\"1\")\n\
568             [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
569             unique_name_in_owner = true\n\
570             [node name=\"Ok\" type=\"Button\" parent=\"Box\"]\n\
571             [node name=\"Cancel\" type=\"Button\" parent=\"Box\"]\n";
572        change.change_file(FileId(0), scene);
573        change.set_file_path(FileId(0), "res://main.tscn");
574        let gd = "extends Control\nfunc _ready():\n\tvar b := %Box/\n";
575        change.change_file(FileId(1), gd);
576        change.set_file_path(FileId(1), "res://main.gd");
577        host.apply_change(change);
578        let analysis = host.analysis();
579        let offset = u32::try_from(gd.find("%Box/").unwrap() + "%Box/".len()).unwrap();
580        let items = analysis
581            .completions(FilePosition {
582                file: FileId(1),
583                offset,
584            })
585            .unwrap();
586        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
587        assert!(
588            labels.contains(&"Ok") && labels.contains(&"Cancel"),
589            "{labels:?}"
590        );
591        assert!(
592            !labels.contains(&"func"),
593            "node-path, not keyword completion"
594        );
595    }
596
597    #[test]
598    fn bare_percent_offers_all_unique_nodes() {
599        // A bare `%` offers every unique node in the owning scene (scene-wide), not just children.
600        let mut host = AnalysisHost::new();
601        let mut change = Change::new();
602        let scene = "[gd_scene format=3]\n\
603             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
604             [node name=\"Root\" type=\"Control\"]\n\
605             script = ExtResource(\"1\")\n\
606             [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
607             unique_name_in_owner = true\n\
608             [node name=\"Hud\" type=\"Control\" parent=\".\"]\n\
609             unique_name_in_owner = true\n";
610        change.change_file(FileId(0), scene);
611        change.set_file_path(FileId(0), "res://main.tscn");
612        let gd = "extends Control\nfunc _ready():\n\tvar b := %\n";
613        change.change_file(FileId(1), gd);
614        change.set_file_path(FileId(1), "res://main.gd");
615        host.apply_change(change);
616        let analysis = host.analysis();
617        let offset = u32::try_from(gd.find("%\n").unwrap() + 1).unwrap();
618        let labels: Vec<_> = analysis
619            .completions(FilePosition {
620                file: FileId(1),
621                offset,
622            })
623            .unwrap()
624            .into_iter()
625            .map(|i| i.label)
626            .collect();
627        assert!(
628            labels.iter().any(|l| l == "Box") && labels.iter().any(|l| l == "Hud"),
629            "{labels:?}"
630        );
631    }
632
633    #[test]
634    fn percent_modulo_is_not_hijacked_as_a_unique_path() {
635        // `count % Box` is modulo, not a unique-node path — completion must stay by-name (the parsed
636        // `%` token's parent is `BinExpr`, not `UniqueNodeExpr`).
637        let mut host = AnalysisHost::new();
638        let mut change = Change::new();
639        let scene = "[gd_scene format=3]\n\
640             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
641             [node name=\"Root\" type=\"Control\"]\n\
642             script = ExtResource(\"1\")\n\
643             [node name=\"Box\" type=\"Panel\" parent=\".\"]\n\
644             unique_name_in_owner = true\n";
645        change.change_file(FileId(0), scene);
646        change.set_file_path(FileId(0), "res://main.tscn");
647        let gd = "extends Control\nfunc _ready():\n\tvar count := 10\n\tvar b := count %Box\n";
648        change.change_file(FileId(1), gd);
649        change.set_file_path(FileId(1), "res://main.gd");
650        host.apply_change(change);
651        let analysis = host.analysis();
652        let offset = u32::try_from(gd.find("%Box").unwrap() + "%Box".len()).unwrap();
653        let labels: Vec<_> = analysis
654            .completions(FilePosition {
655                file: FileId(1),
656                offset,
657            })
658            .unwrap()
659            .into_iter()
660            .map(|i| i.label)
661            .collect();
662        // By-name completion ran (keywords present), node-path did not hijack the modulo.
663        assert!(
664            labels.iter().any(|l| l == "func"),
665            "expected by-name completion: {labels:?}"
666        );
667    }
668
669    #[test]
670    fn completion_is_scope_aware_for_locals_and_params() {
671        // By-name completion must offer class members everywhere, but a parameter / local of one
672        // function must NOT leak into a sibling function. The enclosing function is found by
673        // indentation, so completing on a fresh (empty) indented line at the end of a body still
674        // sees that body's own params/locals (the case the CST-range approach regressed).
675        let mut host = AnalysisHost::new();
676        let mut change = Change::new();
677        let gd = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t\nfunc b(pb):\n\tvar lb := 2\n";
678        change.change_file(FileId(0), gd);
679        change.set_file_path(FileId(0), "res://m.gd");
680        host.apply_change(change);
681        let analysis = host.analysis();
682
683        // Cursor on the empty indented line inside a() (right after the body's tab).
684        let upto = "var member_v := 0\nfunc a(pa):\n\tvar la := 1\n\t";
685        let offset = u32::try_from(gd.find(upto).unwrap() + upto.len()).unwrap();
686        let items = analysis
687            .completions(FilePosition {
688                file: FileId(0),
689                offset,
690            })
691            .unwrap();
692        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
693        // Own param + own local + the class member + both func names are visible.
694        assert!(labels.contains(&"pa"), "own param `pa`: {labels:?}");
695        assert!(labels.contains(&"la"), "own local `la`: {labels:?}");
696        assert!(labels.contains(&"member_v"), "class member: {labels:?}");
697        assert!(
698            labels.contains(&"a") && labels.contains(&"b"),
699            "sibling func names: {labels:?}",
700        );
701        // b()'s param + local must NOT leak into a().
702        assert!(!labels.contains(&"pb"), "leaked b's param: {labels:?}");
703        assert!(!labels.contains(&"lb"), "leaked b's local: {labels:?}");
704    }
705
706    #[test]
707    fn completion_at_class_level_offers_members_not_locals() {
708        // At class level (no enclosing function) only members are offered — no function's locals.
709        let mut host = AnalysisHost::new();
710        let mut change = Change::new();
711        let gd = "var member_v := 0\nfunc a():\n\tvar la := 1\n\nm\n";
712        change.change_file(FileId(0), gd);
713        change.set_file_path(FileId(0), "res://m.gd");
714        host.apply_change(change);
715        let analysis = host.analysis();
716        // Cursor after the top-level `m` (class level, indent 0).
717        let offset = u32::try_from(gd.rfind('m').unwrap() + 1).unwrap();
718        let items = analysis
719            .completions(FilePosition {
720                file: FileId(0),
721                offset,
722            })
723            .unwrap();
724        let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
725        assert!(
726            labels.contains(&"member_v") && labels.contains(&"a"),
727            "{labels:?}"
728        );
729        assert!(
730            !labels.contains(&"la"),
731            "a()'s local must not leak to class level: {labels:?}"
732        );
733    }
734
735    #[test]
736    fn completion_offers_params_in_lambda_setter_and_inline_bodies() {
737        // Regression (bug-hunt): the scope filter must offer a callable's own params inside its body
738        // for ALL callable kinds, not just multi-line `func`s: a top-level named lambda, a `get`/`set`
739        // accessor, and a one-line `func`. (The indentation-only scan missed these, hiding the param.)
740        let cases = [
741            // (source, the param that must be offered, a marker the cursor is placed right after)
742            ("var f := func(px):\n\treturn px\n", "px", "return "),
743            ("var x: int:\n\tset(sv):\n\t\t_x = sv\n", "sv", "_x = "),
744            ("func foo(ia): return ia\n", "ia", "return "),
745        ];
746        for (gd, param, marker) in cases {
747            let mut host = AnalysisHost::new();
748            let mut change = Change::new();
749            change.change_file(FileId(0), gd);
750            change.set_file_path(FileId(0), "res://m.gd");
751            host.apply_change(change);
752            let analysis = host.analysis();
753            let offset = u32::try_from(gd.find(marker).unwrap() + marker.len()).unwrap();
754            let labels: Vec<_> = analysis
755                .completions(FilePosition {
756                    file: FileId(0),
757                    offset,
758                })
759                .unwrap()
760                .into_iter()
761                .map(|i| i.label)
762                .collect();
763            assert!(
764                labels.iter().any(|l| l == param),
765                "param `{param}` should be offered inside its body for {gd:?}, got {labels:?}",
766            );
767        }
768    }
769
770    #[test]
771    fn goto_definition_on_a_node_path_jumps_into_the_tscn() {
772        // Cursor on `$Btn` → a NavTarget pointing at the `[node name="Btn" …]` line in the owning
773        // `.tscn` (the inverse of M1 typing; navigation the engine LSP cannot provide).
774        let mut host = AnalysisHost::new();
775        let mut change = Change::new();
776        let scene = "[gd_scene format=3]\n\
777             [ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
778             [node name=\"Root\" type=\"Control\"]\n\
779             script = ExtResource(\"1\")\n\
780             [node name=\"Btn\" type=\"Button\" parent=\".\"]\n";
781        let gd = "extends Control\nfunc _ready():\n\tvar b := $Btn\n";
782        change.change_file(FileId(0), scene);
783        change.set_file_path(FileId(0), "res://main.tscn");
784        change.change_file(FileId(1), gd);
785        change.set_file_path(FileId(1), "res://main.gd");
786        host.apply_change(change);
787        let analysis = host.analysis();
788
789        let offset = u32::try_from(gd.find("$Btn").unwrap() + 1).unwrap(); // on the `B`
790        let targets = analysis
791            .goto_definition(FilePosition {
792                file: FileId(1),
793                offset,
794            })
795            .unwrap();
796        assert_eq!(targets.len(), 1, "{targets:?}");
797        assert_eq!(targets[0].file, FileId(0), "jumps into the .tscn");
798        let focus =
799            &scene[targets[0].focus_range.start as usize..targets[0].focus_range.end as usize];
800        assert!(
801            focus.contains("Btn"),
802            "focus on the node name, got {focus:?}"
803        );
804    }
805
806    #[test]
807    fn find_refs_and_rename_cross_file_through_the_public_api() {
808        let mut host = AnalysisHost::new();
809        let mut change = Change::new();
810        change.change_file(
811            FileId(0),
812            "class_name Widget\nfunc make() -> int:\n\treturn 1\n",
813        );
814        change.set_file_path(FileId(0), "res://widget.gd");
815        change.change_file(
816            FileId(1),
817            "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n",
818        );
819        change.set_file_path(FileId(1), "res://main.gd");
820        host.apply_change(change);
821        let analysis = host.analysis();
822        // The `class_name Widget` declaration name starts at offset 11 (`"class_name "` is 11).
823        let at_decl = FilePosition {
824            file: FileId(0),
825            offset: 11,
826        };
827        // find-refs: declaration (f0) + annotation + `.new()` (f1) = 3.
828        let refs = analysis.find_references(at_decl).unwrap();
829        assert_eq!(refs.len(), 3, "{refs:?}");
830        // rename → a cross-file SourceChange touching both files.
831        let edit = analysis
832            .rename(at_decl, "Gadget")
833            .unwrap()
834            .expect("rename ok");
835        assert_eq!(edit.edits.len(), 2, "both files edited");
836    }
837
838    #[test]
839    fn removing_a_file_clears_it() {
840        let (mut host, file) = host_with("var x = 1\n");
841        let mut change = Change::new();
842        change.remove_file(file);
843        host.apply_change(change);
844        let analysis = host.analysis();
845        assert!(analysis.document_symbols(file).unwrap().is_empty());
846    }
847}