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