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