Skip to main content

gdscript_ide/
lib.rs

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