1#![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
33fn catch<T>(f: impl FnOnce() -> T) -> Cancellable<T> {
38 salsa::Cancelled::catch(std::panic::AssertUnwindSafe(f)).map_err(|_| gdscript_base::Cancelled)
39}
40
41#[derive(Debug, Clone, Default)]
46pub struct AnalysisHost {
47 db: RootDatabase,
48}
49
50#[derive(Debug, Default)]
52pub struct Change {
53 pub files: Vec<(FileId, Option<Arc<str>>)>,
55 pub paths: Vec<(FileId, String)>,
60 pub project_config: Option<Arc<str>>,
63}
64
65impl Change {
66 #[must_use]
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 pub fn change_file(&mut self, file: FileId, text: impl Into<Arc<str>>) {
74 self.files.push((file, Some(text.into())));
75 }
76
77 pub fn remove_file(&mut self, file: FileId) {
79 self.files.push((file, None));
80 }
81
82 pub fn set_file_path(&mut self, file: FileId, path: impl Into<String>) {
85 self.paths.push((file, path.into()));
86 }
87
88 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 #[must_use]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 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 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 for (id, path) in change.paths {
121 self.db.set_file_path(id, &path);
122 }
123 if let Some(text) = change.project_config {
126 self.db.set_project_config(&text);
127 }
128 if structure_changed {
131 self.db.sync_source_root();
132 }
133 }
134
135 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 #[must_use]
154 pub fn analysis(&self) -> Analysis {
155 Analysis {
156 db: self.db.clone(),
157 }
158 }
159}
160
161#[derive(Debug, Clone)]
165pub struct Analysis {
166 db: RootDatabase,
167}
168
169impl Analysis {
170 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 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 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 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 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 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 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 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 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 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 pub fn goto_definition(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::NavTarget>> {
318 catch(|| navigation::goto_definition(&self.db, pos))
319 }
320
321 pub fn find_references(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::Reference>> {
326 catch(|| navigation::find_references(&self.db, pos))
327 }
328
329 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 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 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 assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
395 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 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 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 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 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 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 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 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 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(); 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 let at_decl = FilePosition {
596 file: FileId(0),
597 offset: 11,
598 };
599 let refs = analysis.find_references(at_decl).unwrap();
601 assert_eq!(refs.len(), 3, "{refs:?}");
602 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}