#![cfg_attr(docsrs, feature(doc_cfg))]
use std::sync::Arc;
use gdscript_base::{
Cancellable, CodeAction, CompletionItem, Diagnostic, DocumentSymbol, FileId, FilePosition,
FoldRange, HoverResult, InlayHint, SignatureHelp,
};
use gdscript_db::{Db, RootDatabase};
use salsa::Durability;
mod features;
mod navigation;
mod semantic;
mod semantic_tokens;
fn catch<T>(f: impl FnOnce() -> T) -> Cancellable<T> {
salsa::Cancelled::catch(std::panic::AssertUnwindSafe(f)).map_err(|_| gdscript_base::Cancelled)
}
#[derive(Debug, Clone, Default)]
pub struct AnalysisHost {
db: RootDatabase,
}
#[derive(Debug, Default)]
pub struct Change {
pub files: Vec<(FileId, Option<Arc<str>>)>,
pub paths: Vec<(FileId, String)>,
pub project_config: Option<Arc<str>>,
}
impl Change {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn change_file(&mut self, file: FileId, text: impl Into<Arc<str>>) {
self.files.push((file, Some(text.into())));
}
pub fn remove_file(&mut self, file: FileId) {
self.files.push((file, None));
}
pub fn set_file_path(&mut self, file: FileId, path: impl Into<String>) {
self.paths.push((file, path.into()));
}
pub fn set_project_config(&mut self, text: impl Into<Arc<str>>) {
self.project_config = Some(text.into());
}
}
impl AnalysisHost {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn apply_change(&mut self, change: Change) {
let mut structure_changed = false;
for (id, text) in change.files {
if let Some(t) = text {
structure_changed |= self.db.file_text(id).is_none();
self.db.set_file_text(id, &t, Durability::LOW);
} else {
structure_changed |= self.db.file_text(id).is_some();
self.db.remove_file(id);
}
}
for (id, path) in change.paths {
self.db.set_file_path(id, &path);
}
if let Some(text) = change.project_config {
self.db.set_project_config(&text);
}
if structure_changed {
self.db.sync_source_root();
}
}
pub fn set_engine_api(&mut self, bytes: &[u8]) -> bool {
match gdscript_api::EngineApi::from_bytes(bytes) {
Ok(api) => {
self.db.set_engine_api(api);
true
}
Err(_) => false,
}
}
#[must_use]
pub fn analysis(&self) -> Analysis {
Analysis {
db: self.db.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct Analysis {
db: RootDatabase,
}
impl Analysis {
pub fn syntax_tree(&self, file: FileId) -> Cancellable<Option<String>> {
catch(|| {
self.db
.file_text(file)
.map(|ft| gdscript_db::parse(&self.db, ft).debug_tree())
})
}
pub fn diagnostics(&self, file: FileId) -> Cancellable<Vec<Diagnostic>> {
catch(|| {
self.db
.file_text(file)
.map(|ft| {
let mut diags = features::diagnostics(&self.db, ft);
diags.extend(semantic::type_diagnostics(&self.db, ft));
diags
})
.unwrap_or_default()
})
}
pub fn document_symbols(&self, file: FileId) -> Cancellable<Vec<DocumentSymbol>> {
catch(|| {
self.db
.file_text(file)
.map(|ft| features::document_symbols(&self.db, ft))
.unwrap_or_default()
})
}
pub fn semantic_tokens(&self, file: FileId) -> Cancellable<Vec<gdscript_base::SemanticToken>> {
catch(|| {
self.db
.file_text(file)
.map(|ft| semantic_tokens::semantic_tokens(&self.db, ft))
.unwrap_or_default()
})
}
pub fn folding_ranges(&self, file: FileId) -> Cancellable<Vec<FoldRange>> {
catch(|| {
self.db
.file_text(file)
.map(|ft| features::folding_ranges(&self.db, ft))
.unwrap_or_default()
})
}
pub fn completions(&self, pos: FilePosition) -> Cancellable<Vec<CompletionItem>> {
catch(|| {
self.db
.file_text(pos.file)
.map(|ft| {
semantic::node_path_completions(&self.db, ft, pos.offset)
.or_else(|| semantic::member_completions(&self.db, ft, pos.offset))
.unwrap_or_else(|| features::completions(&self.db, ft, pos.offset))
})
.unwrap_or_default()
})
}
pub fn hover(&self, pos: FilePosition) -> Cancellable<Option<HoverResult>> {
catch(|| {
self.db
.file_text(pos.file)
.and_then(|ft| semantic::hover(&self.db, ft, pos.offset))
})
}
pub fn inlay_hints(&self, file: FileId) -> Cancellable<Vec<InlayHint>> {
catch(|| {
self.db
.file_text(file)
.map(|ft| semantic::inlay_hints(&self.db, ft))
.unwrap_or_default()
})
}
pub fn signature_help(&self, pos: FilePosition) -> Cancellable<Option<SignatureHelp>> {
catch(|| {
self.db
.file_text(pos.file)
.and_then(|ft| semantic::signature_help(&self.db, ft, pos.offset))
})
}
pub fn code_actions(&self, pos: FilePosition) -> Cancellable<Vec<CodeAction>> {
catch(|| {
self.db
.file_text(pos.file)
.map(|ft| semantic::code_actions(&self.db, ft, pos.offset))
.unwrap_or_default()
})
}
pub fn goto_definition(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::NavTarget>> {
catch(|| navigation::goto_definition(&self.db, pos))
}
pub fn find_references(&self, pos: FilePosition) -> Cancellable<Vec<gdscript_base::Reference>> {
catch(|| navigation::find_references(&self.db, pos))
}
pub fn rename(
&self,
pos: FilePosition,
new_name: &str,
) -> Cancellable<Result<gdscript_base::SourceChange, gdscript_base::RenameError>> {
catch(|| navigation::rename(&self.db, pos, new_name))
}
pub fn workspace_symbols(&self, query: &str) -> Cancellable<Vec<gdscript_base::NavTarget>> {
catch(|| navigation::workspace_symbols(&self.db, query))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn host_with(src: &str) -> (AnalysisHost, FileId) {
let mut host = AnalysisHost::new();
let file = FileId(0);
let mut change = Change::new();
change.change_file(file, src);
host.apply_change(change);
(host, file)
}
#[test]
fn snapshot_reads_applied_files() {
let (host, file) = host_with("func f():\n\tpass\n");
let analysis = host.analysis();
let symbols = analysis.document_symbols(file).unwrap();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "f");
}
#[test]
fn preload_resolves_cross_file_through_the_public_api() {
let mut host = AnalysisHost::new();
let mut change = Change::new();
change.change_file(
FileId(0),
"class_name Markup\nfunc parse() -> int:\n\treturn 1\n",
);
change.set_file_path(FileId(0), "res://markup.gd");
change.change_file(
FileId(1),
"const M = preload(\"res://markup.gd\")\nfunc go():\n\tvar n := M.new().parse()\n",
);
change.set_file_path(FileId(1), "res://main.gd");
host.apply_change(change);
let analysis = host.analysis();
assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
let hints = analysis.inlay_hints(FileId(1)).unwrap();
assert!(
hints.iter().any(|h| h.label.contains("int")),
"expected an `: int` inlay on the preload-resolved binding, got {hints:?}",
);
}
#[test]
fn autoload_resolves_cross_file_through_the_public_api() {
let mut host = AnalysisHost::new();
let mut change = Change::new();
change.change_file(FileId(0), "func volume() -> int:\n\treturn 50\n");
change.set_file_path(FileId(0), "res://audio.gd");
change.change_file(FileId(1), "func go():\n\tvar v := Audio.volume()\n");
change.set_file_path(FileId(1), "res://main.gd");
change.set_project_config("[autoload]\nAudio=\"*res://audio.gd\"\n");
host.apply_change(change);
let analysis = host.analysis();
assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
let hints = analysis.inlay_hints(FileId(1)).unwrap();
assert!(
hints.iter().any(|h| h.label.contains("int")),
"expected an `: int` inlay on the autoload-resolved binding, got {hints:?}",
);
}
#[test]
fn scene_node_path_typing_through_the_public_api() {
let mut host = AnalysisHost::new();
let mut change = Change::new();
change.change_file(
FileId(0),
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Btn\" type=\"Button\" parent=\".\"]\n",
);
change.set_file_path(FileId(0), "res://main.tscn");
change.change_file(
FileId(1),
"extends Control\nfunc _ready():\n\tvar b := $Btn\n",
);
change.set_file_path(FileId(1), "res://main.gd");
host.apply_change(change);
let analysis = host.analysis();
assert!(analysis.diagnostics(FileId(1)).unwrap().is_empty());
let hints = analysis.inlay_hints(FileId(1)).unwrap();
assert!(
hints.iter().any(|h| h.label.contains("Button")),
"expected a `: Button` inlay on `var b := $Btn`, got {hints:?}",
);
}
#[test]
fn node_path_completion_offers_scene_children() {
let mut host = AnalysisHost::new();
let mut change = Change::new();
change.change_file(
FileId(0),
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
[node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n\
[node name=\"Cancel\" type=\"Button\" parent=\"Panel\"]\n",
);
change.set_file_path(FileId(0), "res://main.tscn");
let gd = "extends Control\nfunc _ready():\n\tvar b := $Panel/\n";
change.change_file(FileId(1), gd);
change.set_file_path(FileId(1), "res://main.gd");
host.apply_change(change);
let analysis = host.analysis();
let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
let items = analysis
.completions(FilePosition {
file: FileId(1),
offset,
})
.unwrap();
let labels: Vec<_> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"Ok") && labels.contains(&"Cancel"),
"{labels:?}"
);
assert!(
items
.iter()
.find(|i| i.label == "Ok")
.is_some_and(|i| i.detail.as_deref() == Some("Button")),
"{items:?}",
);
assert!(
!labels.contains(&"func"),
"should be node-path, not keyword, completion"
);
}
#[test]
fn node_path_completion_does_not_hijack_inside_a_string_literal() {
let mut host = AnalysisHost::new();
let mut change = Change::new();
change.change_file(
FileId(0),
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
[node name=\"Ok\" type=\"Button\" parent=\"Panel\"]\n",
);
change.set_file_path(FileId(0), "res://main.tscn");
let gd = "extends Control\nfunc _ready():\n\tvar s := \"$Panel/\"\n";
change.change_file(FileId(1), gd);
change.set_file_path(FileId(1), "res://main.gd");
host.apply_change(change);
let analysis = host.analysis();
let offset = u32::try_from(gd.find("$Panel/").unwrap() + "$Panel/".len()).unwrap();
let items = analysis
.completions(FilePosition {
file: FileId(1),
offset,
})
.unwrap();
assert!(
!items.iter().any(|i| i.label == "Ok"),
"node names must not leak into a string literal: {items:?}",
);
}
#[test]
fn goto_definition_on_a_node_path_jumps_into_the_tscn() {
let mut host = AnalysisHost::new();
let mut change = Change::new();
let scene = "[gd_scene format=3]\n\
[ext_resource type=\"Script\" path=\"res://main.gd\" id=\"1\"]\n\
[node name=\"Root\" type=\"Control\"]\n\
script = ExtResource(\"1\")\n\
[node name=\"Btn\" type=\"Button\" parent=\".\"]\n";
let gd = "extends Control\nfunc _ready():\n\tvar b := $Btn\n";
change.change_file(FileId(0), scene);
change.set_file_path(FileId(0), "res://main.tscn");
change.change_file(FileId(1), gd);
change.set_file_path(FileId(1), "res://main.gd");
host.apply_change(change);
let analysis = host.analysis();
let offset = u32::try_from(gd.find("$Btn").unwrap() + 1).unwrap(); let targets = analysis
.goto_definition(FilePosition {
file: FileId(1),
offset,
})
.unwrap();
assert_eq!(targets.len(), 1, "{targets:?}");
assert_eq!(targets[0].file, FileId(0), "jumps into the .tscn");
let focus =
&scene[targets[0].focus_range.start as usize..targets[0].focus_range.end as usize];
assert!(
focus.contains("Btn"),
"focus on the node name, got {focus:?}"
);
}
#[test]
fn find_refs_and_rename_cross_file_through_the_public_api() {
let mut host = AnalysisHost::new();
let mut change = Change::new();
change.change_file(
FileId(0),
"class_name Widget\nfunc make() -> int:\n\treturn 1\n",
);
change.set_file_path(FileId(0), "res://widget.gd");
change.change_file(
FileId(1),
"func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n",
);
change.set_file_path(FileId(1), "res://main.gd");
host.apply_change(change);
let analysis = host.analysis();
let at_decl = FilePosition {
file: FileId(0),
offset: 11,
};
let refs = analysis.find_references(at_decl).unwrap();
assert_eq!(refs.len(), 3, "{refs:?}");
let edit = analysis
.rename(at_decl, "Gadget")
.unwrap()
.expect("rename ok");
assert_eq!(edit.edits.len(), 2, "both files edited");
}
#[test]
fn removing_a_file_clears_it() {
let (mut host, file) = host_with("var x = 1\n");
let mut change = Change::new();
change.remove_file(file);
host.apply_change(change);
let analysis = host.analysis();
assert!(analysis.document_symbols(file).unwrap().is_empty());
}
}