use cstree::util::NodeOrToken;
use gdscript_api::gdscript_layer::LayerTy;
use gdscript_api::{BuiltinId, ClassId, EngineApi};
use gdscript_db::Db;
use gdscript_syntax::{GdNode, SyntaxKind};
use rustc_hash::FxHashMap;
use smol_str::SmolStr;
use crate::item_tree::{ExtendsRef, ItemTree, Member};
use crate::ty::{EnumRef, ScriptRefId, Ty};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExternalRef {
ClassName(SmolStr),
ExtendsPath(SmolStr),
Preload(SmolStr),
Autoload(SmolStr),
}
#[must_use]
pub fn resolve_external(db: &dyn Db, r: &ExternalRef) -> Ty {
match r {
ExternalRef::ClassName(name) => resolve_class_name(db, name),
ExternalRef::Preload(path) => resolve_res_path(db, path),
ExternalRef::ExtendsPath(path) if is_resource_path(path) => resolve_res_path(db, path),
ExternalRef::Autoload(name) => resolve_autoload(db, name),
ExternalRef::ExtendsPath(_) => Ty::Unknown,
}
}
fn resolve_autoload(db: &dyn Db, name: &str) -> Ty {
let Some(config) = db.project_config() else {
return Ty::Unknown;
};
let Some(path) = crate::queries::autoload_registry(db, config)
.resolve_path(name)
.cloned()
else {
return Ty::Unknown;
};
if is_gdscript_path(&path) {
resolve_res_path(db, &path)
} else if is_scene_path(&path) {
resolve_scene_autoload(db, &path)
} else {
Ty::Unknown
}
}
fn resolve_scene_autoload(db: &dyn Db, scene_path: &str) -> Ty {
let Some(root) = db.source_root() else {
return Ty::Unknown;
};
let Some(&scene_file) = crate::queries::res_path_registry(db, root).get(scene_path) else {
return Ty::Unknown; };
let Some(ft) = db.file_text(scene_file) else {
return Ty::Unknown;
};
let scene = crate::queries::scene_model(db, ft);
if let Some(script_path) = scene
.root
.and_then(|idx| scene.node(idx))
.and_then(|root_node| root_node.script.as_ref())
.and_then(|id| scene.ext_resources.get(id))
.and_then(|ext| ext.path.as_deref())
{
let ty = resolve_res_path(db, script_path);
if !ty.is_uninformative() {
return ty;
}
}
for class_name in [scene.script_class.as_ref(), scene.resource_type.as_ref()]
.into_iter()
.flatten()
{
let ty = resolve_external(db, &ExternalRef::ClassName(class_name.clone()));
if !ty.is_uninformative() {
return ty;
}
}
Ty::Unknown
}
fn is_scene_path(p: &str) -> bool {
p.rsplit('.')
.next()
.is_some_and(|ext| ext.eq_ignore_ascii_case("tscn") || ext.eq_ignore_ascii_case("tres"))
}
fn is_gdscript_path(p: &str) -> bool {
p.rsplit('.')
.next()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gd"))
}
fn is_resource_path(p: &str) -> bool {
p.starts_with("res://") || p.starts_with("user://")
}
#[must_use]
pub fn anchor_res_path(importing: Option<&str>, raw: &str) -> Option<SmolStr> {
if is_resource_path(raw) {
return Some(SmolStr::new(raw));
}
let (scheme, rest) = importing?.split_once("://")?;
let dir = rest.rsplit_once('/').map_or("", |(d, _)| d);
let joined = if dir.is_empty() {
format!("{scheme}://{raw}")
} else {
format!("{scheme}://{dir}/{raw}")
};
Some(SmolStr::new(simplify_resource_path(&joined)))
}
fn simplify_resource_path(path: &str) -> String {
let (scheme, rest) = path.split_once("://").unwrap_or(("res", path));
let mut out: Vec<&str> = Vec::new();
for seg in rest.split('/') {
match seg {
"" | "." => {}
".." => {
out.pop();
}
s => out.push(s),
}
}
format!("{scheme}://{}", out.join("/"))
}
fn resolve_res_path(db: &dyn Db, path: &str) -> Ty {
if !is_gdscript_path(path) {
return Ty::Unknown;
}
let Some(root) = db.source_root() else {
return Ty::Unknown;
};
match crate::queries::res_path_registry(db, root).get(path) {
Some(file) => Ty::ScriptRef(ScriptRefId(file.0)),
None => Ty::Unknown,
}
}
fn resolve_class_name(db: &dyn Db, name: &str) -> Ty {
let Some(root) = db.source_root() else {
return Ty::Unknown;
};
match crate::queries::global_registry(db, root).resolve(name) {
Some(file) => Ty::ScriptRef(ScriptRefId(file.file_id(db).0)),
None => Ty::Unknown,
}
}
#[must_use]
pub fn resolve_type_ref(db: &dyn Db, api: &EngineApi, node: &GdNode) -> Ty {
let names: Vec<String> = node
.children_with_tokens()
.filter_map(NodeOrToken::into_token)
.filter(|t| matches!(t.kind(), SyntaxKind::Ident | SyntaxKind::VoidKw))
.map(|t| t.text().to_owned())
.collect();
let args: Vec<GdNode> = node
.children()
.filter(|c| c.kind() == SyntaxKind::TypeRef)
.cloned()
.collect();
resolve_named(db, api, &names, &args)
}
#[must_use]
pub fn resolve_type_name(db: &dyn Db, api: &EngineApi, name: &str) -> Ty {
resolve_named(db, api, std::slice::from_ref(&name.to_owned()), &[])
}
fn resolve_named(db: &dyn Db, api: &EngineApi, names: &[String], args: &[GdNode]) -> Ty {
let Some(head) = names.first() else {
return Ty::Variant;
};
if names.len() == 1 {
match head.as_str() {
"void" => return Ty::Void,
"Variant" => return Ty::Variant,
"Callable" => return Ty::Callable,
"Signal" => return Ty::Signal(None),
"Array" => return Ty::Array(Box::new(elem_arg(db, api, args, 0))),
"Dictionary" => {
return Ty::Dict(
Box::new(elem_arg(db, api, args, 0)),
Box::new(elem_arg(db, api, args, 1)),
);
}
_ => {}
}
if let Some(b) = api.builtin_by_name(head) {
return Ty::Builtin(b);
}
if let Some(c) = api.class_by_name(head) {
return Ty::Object(c);
}
if let Some(e) = api.global_enum(head) {
return Ty::Enum(EnumRef {
qualified: SmolStr::new(head),
bitfield: e.is_bitfield,
});
}
return resolve_external(db, &ExternalRef::ClassName(SmolStr::new(head)));
}
if names.len() == 2
&& let Some(c) = api.class_by_name(&names[0])
&& let Some(e) = api.class(c).enums.iter().find(|e| e.name == names[1])
{
return Ty::Enum(EnumRef {
qualified: SmolStr::new(names.join(".")),
bitfield: e.is_bitfield,
});
}
resolve_external(db, &ExternalRef::ExtendsPath(SmolStr::new(names.join("."))))
}
fn elem_arg(db: &dyn Db, api: &EngineApi, args: &[GdNode], i: usize) -> Ty {
match args.get(i) {
Some(node) => match resolve_type_ref(db, api, node) {
Ty::Array(_) | Ty::Dict(..) => Ty::Variant,
other => other,
},
None => Ty::Variant,
}
}
#[must_use]
pub fn layer_to_ty(api: &EngineApi, lt: LayerTy) -> Ty {
match lt {
LayerTy::Float => builtin(api, "float"),
LayerTy::Int => builtin(api, "int"),
LayerTy::Bool => builtin(api, "bool"),
LayerTy::Str => builtin(api, "String"),
LayerTy::Array => Ty::array_of_variant(),
LayerTy::Variant => Ty::Variant,
LayerTy::Unknown => Ty::Unknown,
LayerTy::Void => Ty::Void,
}
}
fn builtin(api: &EngineApi, name: &str) -> Ty {
api.builtin_by_name(name).map_or(Ty::Variant, Ty::Builtin)
}
#[must_use]
pub fn resolve_base(db: &dyn Db, api: &EngineApi, tree: &ItemTree, anchor: Option<&str>) -> Ty {
match &tree.extends {
None => api
.class_by_name("RefCounted")
.map_or(Ty::Unknown, Ty::Object),
Some(ExtendsRef::Name(n)) => api.class_by_name(n).map_or_else(
|| resolve_external(db, &ExternalRef::ClassName(n.clone())),
Ty::Object,
),
Some(ExtendsRef::ScriptPath(p)) => match anchor_res_path(anchor, p) {
Some(abs) => resolve_external(db, &ExternalRef::ExtendsPath(abs)),
None => Ty::Unknown,
},
Some(ExtendsRef::Path(p)) => resolve_external(db, &ExternalRef::ExtendsPath(p.clone())),
Some(ExtendsRef::ScriptPathInner(_)) => Ty::Unknown,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClassItem {
Member(usize),
EnumVariant,
}
#[derive(Debug, Clone)]
pub struct ClassScope<'a> {
pub tree: &'a ItemTree,
pub base: Ty,
pub self_ty: Ty,
pub member_types: FxHashMap<SmolStr, Ty>,
members: FxHashMap<SmolStr, ClassItem>,
}
impl<'a> ClassScope<'a> {
#[must_use]
pub fn new(db: &dyn Db, api: &EngineApi, tree: &'a ItemTree, anchor: Option<&str>) -> Self {
let mut members = FxHashMap::default();
for (i, m) in tree.members.iter().enumerate() {
match m {
Member::Enum(e) if e.name.is_none() => {
for v in &e.variants {
members.insert(v.clone(), ClassItem::EnumVariant);
}
}
_ => {
if let Some(name) = m.name() {
members
.entry(SmolStr::new(name))
.or_insert(ClassItem::Member(i));
}
}
}
}
let base = resolve_base(db, api, tree, anchor);
Self {
tree,
self_ty: base.clone(),
base,
member_types: FxHashMap::default(),
members,
}
}
#[must_use]
pub fn lookup(&self, name: &str) -> Option<ClassItem> {
self.members.get(name).copied()
}
#[must_use]
pub fn member(&self, item: ClassItem) -> Option<&'a Member> {
match item {
ClassItem::Member(i) => self.tree.members.get(i),
ClassItem::EnumVariant => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GlobalDef {
Const(Ty),
Singleton(ClassId),
Builtin,
Utility,
BuiltinType(BuiltinId),
ClassType(ClassId),
GlobalEnum,
}
#[must_use]
pub fn resolve_global(api: &EngineApi, name: &str) -> Option<GlobalDef> {
if let Some(gc) = api.global_const(name) {
return Some(GlobalDef::Const(layer_to_ty(api, gc.ty)));
}
if let Some(cid) = api.singleton(name) {
return Some(GlobalDef::Singleton(cid));
}
if api.gdscript_builtin(name).is_some() {
return Some(GlobalDef::Builtin);
}
if api.utility(name).is_some() {
return Some(GlobalDef::Utility);
}
if let Some(bid) = api.builtin_by_name(name) {
return Some(GlobalDef::BuiltinType(bid));
}
if let Some(cid) = api.class_by_name(name) {
return Some(GlobalDef::ClassType(cid));
}
if api.global_enum(name).is_some() {
return Some(GlobalDef::GlobalEnum);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item_tree::item_tree;
use gdscript_syntax::parse;
fn api() -> &'static EngineApi {
gdscript_api::bundled()
}
fn db() -> gdscript_db::RootDatabase {
gdscript_db::RootDatabase::default()
}
fn ty_of_annotation(src: &str) -> Ty {
let parse = parse(src);
let root = parse.syntax_node();
let type_ref = gdscript_syntax::ast::descendants(&root)
.into_iter()
.find(|n| n.kind() == SyntaxKind::TypeRef)
.expect("a TypeRef node");
resolve_type_ref(&db(), api(), &type_ref)
}
#[test]
fn seam_is_unknown() {
assert_eq!(
resolve_external(&db(), &ExternalRef::ClassName(SmolStr::new("MyClass"))),
Ty::Unknown
);
}
#[test]
fn builtin_and_class_annotations() {
assert_eq!(
ty_of_annotation("var x: int\n"),
Ty::Builtin(api().builtin_by_name("int").unwrap())
);
assert_eq!(
ty_of_annotation("var n: Node\n"),
Ty::Object(api().class_by_name("Node").unwrap())
);
assert_eq!(ty_of_annotation("func f() -> void:\n\tpass\n"), Ty::Void);
}
#[test]
fn typed_container_annotations() {
let int = Ty::Builtin(api().builtin_by_name("int").unwrap());
assert_eq!(
ty_of_annotation("var a: Array[int]\n"),
Ty::Array(Box::new(int.clone()))
);
assert_eq!(ty_of_annotation("var a: Array\n"), Ty::array_of_variant());
assert_eq!(
ty_of_annotation("var d: Dictionary[String, int]\n"),
Ty::Dict(
Box::new(Ty::Builtin(api().builtin_by_name("String").unwrap())),
Box::new(int)
)
);
assert_eq!(
ty_of_annotation("var a: Array[Array[int]]\n"),
Ty::Array(Box::new(Ty::Variant))
);
}
#[test]
fn unknown_annotation_is_seam_not_error() {
assert_eq!(ty_of_annotation("var p: MyPlayer\n"), Ty::Unknown);
}
#[test]
fn base_resolution() {
let extends_node = item_tree(&parse("extends Node2D\n").syntax_node());
assert_eq!(
resolve_base(&db(), api(), &extends_node, None),
Ty::Object(api().class_by_name("Node2D").unwrap())
);
let no_extends = item_tree(&parse("var x = 1\n").syntax_node());
assert_eq!(
resolve_base(&db(), api(), &no_extends, None),
Ty::Object(api().class_by_name("RefCounted").unwrap())
);
let script_base = item_tree(&parse("extends \"res://b.gd\"\n").syntax_node());
assert_eq!(resolve_base(&db(), api(), &script_base, None), Ty::Unknown);
}
#[test]
fn anchor_res_path_absolute_passes_through() {
assert_eq!(
anchor_res_path(Some("res://a/b.gd"), "res://x.gd").as_deref(),
Some("res://x.gd")
);
assert_eq!(
anchor_res_path(None, "user://x.gd").as_deref(),
Some("user://x.gd")
);
}
#[test]
fn anchor_res_path_relative_anchors_to_importing_dir() {
let from = Some("res://entities/player.gd");
assert_eq!(
anchor_res_path(from, "enemy.gd").as_deref(),
Some("res://entities/enemy.gd")
);
assert_eq!(
anchor_res_path(from, "../core/hooks.gd").as_deref(),
Some("res://core/hooks.gd")
);
assert_eq!(
anchor_res_path(from, "./util.gd").as_deref(),
Some("res://entities/util.gd")
);
assert_eq!(
anchor_res_path(Some("res://main.gd"), "util.gd").as_deref(),
Some("res://util.gd")
);
}
#[test]
fn anchor_res_path_relative_without_anchor_is_seam() {
assert_eq!(anchor_res_path(None, "sibling.gd"), None);
}
#[test]
fn class_scope_members_and_anon_enum() {
let tree = item_tree(
&parse(
"var hp := 10\nfunc attack():\n\tpass\nenum { FIRE, ICE }\nenum Named { A, B }\n",
)
.syntax_node(),
);
let scope = ClassScope::new(&db(), api(), &tree, None);
assert!(matches!(scope.lookup("hp"), Some(ClassItem::Member(_))));
assert!(matches!(scope.lookup("attack"), Some(ClassItem::Member(_))));
assert_eq!(scope.lookup("FIRE"), Some(ClassItem::EnumVariant));
assert_eq!(scope.lookup("ICE"), Some(ClassItem::EnumVariant));
assert!(matches!(scope.lookup("Named"), Some(ClassItem::Member(_))));
assert_eq!(scope.lookup("A"), None);
}
#[test]
fn globals() {
assert!(matches!(
resolve_global(api(), "PI"),
Some(GlobalDef::Const(_))
));
assert!(matches!(
resolve_global(api(), "Input"),
Some(GlobalDef::Singleton(_))
));
assert!(matches!(
resolve_global(api(), "preload"),
Some(GlobalDef::Builtin)
));
assert!(matches!(
resolve_global(api(), "Vector2"),
Some(GlobalDef::BuiltinType(_))
));
assert!(matches!(
resolve_global(api(), "Node"),
Some(GlobalDef::ClassType(_))
));
assert!(resolve_global(api(), "definitely_not_a_global").is_none());
}
}