use gdscript_base::{FileId, FilePosition, TextRange};
use gdscript_db::{Db, FileText, parse};
use gdscript_syntax::{GdNode, GdToken, SyntaxKind, ast};
use smol_str::SmolStr;
use crate::cst;
use crate::ty::Ty;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GodotDef {
Global {
decl_file: FileId,
name: SmolStr,
},
Member {
owner_file: FileId,
name: SmolStr,
},
Local {
body_file: FileId,
body_range: TextRange,
decl_name_range: TextRange,
},
Autoload {
name: SmolStr,
target_file: Option<FileId>,
},
Engine {
name: SmolStr,
},
}
impl GodotDef {
#[must_use]
pub fn name(&self) -> &str {
match self {
Self::Global { name, .. }
| Self::Member { name, .. }
| Self::Autoload { name, .. }
| Self::Engine { name } => name,
Self::Local { .. } => "", }
}
#[must_use]
pub fn is_renameable(&self) -> bool {
!matches!(self, Self::Engine { .. })
}
}
#[must_use]
pub fn classify(db: &dyn Db, pos: FilePosition) -> Option<GodotDef> {
let ft = db.file_text(pos.file)?;
let root = parse(db, ft).syntax_node();
let tok = ast::token_at(&root, pos.offset.into())?;
if tok.kind() != SyntaxKind::Ident {
return None; }
let name = SmolStr::new(tok.text());
let tok_range = cst::token_range(&tok);
let parent = tok.parent();
if parent.kind() == SyntaxKind::Name
&& let Some(def) = classify_decl(db, ft, pos.file, parent, &name, tok_range)
{
return Some(def);
}
if let Some(head) = cst::extends_head_token(parent)
&& cst::token_range(&head) == tok_range
{
return classify_type_name(db, &name);
}
if parent.kind() == SyntaxKind::EnumVariant && in_anon_enum(parent) {
return Some(GodotDef::Member {
owner_file: pos.file,
name,
});
}
if has_ancestor(&tok, SyntaxKind::TypeRef) {
return classify_type_name(db, &name);
}
classify_body_ref(db, ft, pos.file, pos.offset, &name)
}
fn classify_decl(
db: &dyn Db,
ft: FileText,
file: FileId,
name_node: &GdNode,
name: &SmolStr,
tok_range: TextRange,
) -> Option<GodotDef> {
let decl = name_node.parent()?;
let in_body = node_has_ancestor(decl, SyntaxKind::FuncDecl)
|| node_has_ancestor(decl, SyntaxKind::Getter)
|| node_has_ancestor(decl, SyntaxKind::Setter)
|| node_has_ancestor(decl, SyntaxKind::LambdaExpr);
let in_inner_class = node_has_ancestor(decl, SyntaxKind::InnerClassDecl);
match decl.kind() {
SyntaxKind::ClassNameDecl => Some(GodotDef::Global {
decl_file: file,
name: name.clone(),
}),
SyntaxKind::Param | SyntaxKind::ForStmt | SyntaxKind::PatternBind => {
local_def(db, ft, file, tok_range)
}
SyntaxKind::VarDecl | SyntaxKind::ConstDecl if in_body => {
local_def(db, ft, file, tok_range)
}
SyntaxKind::FuncDecl
| SyntaxKind::SignalDecl
| SyntaxKind::EnumDecl
| SyntaxKind::InnerClassDecl
| SyntaxKind::VarDecl
| SyntaxKind::ConstDecl
if !in_inner_class =>
{
Some(GodotDef::Member {
owner_file: file,
name: name.clone(),
})
}
_ => None,
}
}
fn local_def(db: &dyn Db, ft: FileText, file: FileId, tok_range: TextRange) -> Option<GodotDef> {
let fi = crate::queries::analyze_file(db, ft);
let unit = fi.unit_at(tok_range.start)?;
let binding = unit.result.binding_at(tok_range.start)?;
Some(GodotDef::Local {
body_file: file,
body_range: unit.range,
decl_name_range: trim_range(ft.text(db), binding.name_range),
})
}
fn trim_range(text: &str, nr: TextRange) -> TextRange {
match text.get(nr.start as usize..nr.end as usize) {
Some(s) => {
let lead = u32::try_from(s.len() - s.trim_start().len()).unwrap_or(0);
let len = u32::try_from(s.trim().len()).unwrap_or(0);
TextRange::new(nr.start + lead, nr.start + lead + len)
}
None => nr,
}
}
fn classify_type_name(db: &dyn Db, name: &SmolStr) -> Option<GodotDef> {
let api = db.engine()?;
match crate::resolve::resolve_type_name(db, api, name) {
Ty::ScriptRef(sref) => Some(GodotDef::Global {
decl_file: FileId(sref.0),
name: name.clone(),
}),
Ty::Object(_) | Ty::Builtin(_) => Some(GodotDef::Engine { name: name.clone() }),
_ => None,
}
}
fn classify_body_ref(
db: &dyn Db,
ft: FileText,
file: FileId,
offset: u32,
name: &SmolStr,
) -> Option<GodotDef> {
let fi = crate::queries::analyze_file(db, ft);
let unit = fi.unit_at(offset)?;
let eid = unit.body.source_map.expr_at_offset(offset)?;
match unit.body.expr(eid) {
crate::body::Expr::Name(n) if n == name => {
resolve_name_to_def(db, ft, file, offset, unit, name)
}
crate::body::Expr::Field {
receiver,
name: fname,
name_range,
} if fname == name && name_range.start <= offset && offset < name_range.end => {
if matches!(unit.body.expr(*receiver), crate::body::Expr::SelfExpr) {
return member_owner(db, crate::ty::ScriptRefId(file.0), name, 0).map(|owner| {
GodotDef::Member {
owner_file: owner,
name: name.clone(),
}
});
}
let recv_ty = unit.result.type_of(*receiver)?;
match recv_ty {
Ty::ScriptRef(sref) => {
member_owner(db, *sref, name, 0).map(|owner| GodotDef::Member {
owner_file: owner,
name: name.clone(),
})
}
Ty::Object(_) | Ty::Builtin(_) => Some(GodotDef::Engine { name: name.clone() }),
_ => None, }
}
_ => None,
}
}
fn resolve_name_to_def(
db: &dyn Db,
ft: FileText,
file: FileId,
offset: u32,
unit: &crate::infer::Unit,
name: &SmolStr,
) -> Option<GodotDef> {
let text = ft.text(db);
let mut best: Option<TextRange> = None;
for b in &unit.result.bindings {
if !matches!(
b.kind,
crate::infer::BindingKind::Var
| crate::infer::BindingKind::Param
| crate::infer::BindingKind::ForVar
| crate::infer::BindingKind::MatchBind
) {
continue;
}
let nr = trim_range(text, b.name_range);
if text.get(nr.start as usize..nr.end as usize) != Some(name.as_str()) {
continue;
}
if nr.start <= offset && best.is_none_or(|cur| nr.start >= cur.start) {
best = Some(nr);
}
}
if let Some(nr) = best {
return Some(GodotDef::Local {
body_file: file,
body_range: unit.range,
decl_name_range: nr,
});
}
if let Some(owner) = member_owner(db, crate::ty::ScriptRefId(file.0), name, 0) {
return Some(GodotDef::Member {
owner_file: owner,
name: name.clone(),
});
}
if let Some(api) = db.engine()
&& crate::resolve::resolve_global(api, name).is_some()
{
return Some(GodotDef::Engine { name: name.clone() });
}
if let Some(root) = db.source_root()
&& let Some(decl) = crate::queries::global_registry(db, root).resolve(name)
{
return Some(GodotDef::Global {
decl_file: decl.file_id(db),
name: name.clone(),
});
}
if let Some(config) = db.project_config()
&& let Some(path) = crate::queries::autoload_registry(db, config)
.resolve_path(name)
.cloned()
{
let target = db.source_root().and_then(|root| {
crate::queries::res_path_registry(db, root)
.get(path.as_str())
.copied()
});
return Some(GodotDef::Autoload {
name: name.clone(),
target_file: target,
});
}
None
}
fn member_owner(
db: &dyn Db,
sref: crate::ty::ScriptRefId,
name: &str,
depth: u32,
) -> Option<FileId> {
if depth > 32 {
return None;
}
let file = db.file_text(FileId(sref.0))?;
let tree = crate::queries::item_tree(db, file);
if tree.member(name).is_some() || anon_enum_has_variant(&tree, name) {
return Some(file.file_id(db));
}
match crate::queries::script_class(db, file).base() {
Ty::ScriptRef(base) => member_owner(db, *base, name, depth + 1),
_ => None, }
}
fn anon_enum_has_variant(tree: &crate::item_tree::ItemTree, name: &str) -> bool {
tree.members.iter().any(|m| {
matches!(m, crate::item_tree::Member::Enum(e)
if e.name.is_none() && e.variants.iter().any(|v| v == name))
})
}
fn in_anon_enum(enum_variant: &GdNode) -> bool {
enum_variant.parent().is_some_and(|enum_decl| {
enum_decl.kind() == SyntaxKind::EnumDecl
&& !enum_decl.children().any(|c| c.kind() == SyntaxKind::Name)
})
}
fn has_ancestor(tok: &GdToken, kind: SyntaxKind) -> bool {
node_has_ancestor_or_self(tok.parent(), kind)
}
fn node_has_ancestor(node: &GdNode, kind: SyntaxKind) -> bool {
node.parent()
.is_some_and(|p| node_has_ancestor_or_self(p, kind))
}
fn node_has_ancestor_or_self(node: &GdNode, kind: SyntaxKind) -> bool {
let mut cur = Some(node.clone());
while let Some(n) = cur {
if n.kind() == kind {
return true;
}
cur = n.parent().cloned();
}
false
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodePathTarget {
pub scene: FileId,
pub node_name: SmolStr,
pub header_span: TextRange,
pub name_span: TextRange,
}
#[must_use]
pub fn node_path_target(db: &dyn Db, pos: FilePosition) -> Option<NodePathTarget> {
let ft = db.file_text(pos.file)?;
let fi = crate::queries::analyze_file(db, ft);
let unit = fi.unit_at(pos.offset)?;
let eid = unit.body.source_map.expr_at_offset(pos.offset)?;
let crate::body::Expr::GetNode {
path: Some(path),
unique,
} = unit.body.expr(eid)
else {
return None;
};
let ctx = crate::queries::scene_context(db, ft)?;
let idx = if *unique {
ctx.model.resolve_unique(path)
} else {
ctx.model.resolve_path_from(ctx.attach, path)
}?;
let node = ctx.model.node(idx)?;
Some(NodePathTarget {
scene: ctx.scene,
node_name: node.name.clone(),
header_span: node.header_span,
name_span: node.name_span,
})
}
#[cfg(test)]
mod tests {
use super::*;
use gdscript_db::RootDatabase;
use salsa::Durability;
fn db_with(files: &[(u32, &str)]) -> RootDatabase {
let mut db = RootDatabase::default();
for (id, src) in files {
db.set_file_text(FileId(*id), src, Durability::LOW);
}
db.sync_source_root();
db
}
fn at(db: &RootDatabase, file: u32, needle: &str, src: &str) -> Option<GodotDef> {
let offset = u32::try_from(src.find(needle).expect("needle")).unwrap();
classify(
db,
FilePosition {
file: FileId(file),
offset,
},
)
}
fn at_nth(db: &RootDatabase, file: u32, needle: &str, n: usize, src: &str) -> Option<GodotDef> {
let off = src.match_indices(needle).nth(n).expect("nth needle").0;
classify(
db,
FilePosition {
file: FileId(file),
offset: u32::try_from(off).unwrap(),
},
)
}
#[test]
fn two_unrelated_locals_are_distinct() {
let src =
"func a():\n\tvar i := 1\n\tvar ra := i\nfunc b():\n\tvar i := 2\n\tvar rb := i\n";
let db = db_with(&[(0, src)]);
let off_a = u32::try_from(src.match_indices(":= i").next().unwrap().0 + 3).unwrap();
let off_b = u32::try_from(src.match_indices(":= i").nth(1).unwrap().0 + 3).unwrap();
let da = classify(
&db,
FilePosition {
file: FileId(0),
offset: off_a,
},
)
.unwrap();
let dbf = classify(
&db,
FilePosition {
file: FileId(0),
offset: off_b,
},
)
.unwrap();
assert!(matches!(da, GodotDef::Local { .. }), "{da:?}");
assert!(matches!(dbf, GodotDef::Local { .. }), "{dbf:?}");
assert_ne!(da, dbf, "two unrelated `i`s must be distinct locals");
}
#[test]
fn local_shadowing_a_member_is_distinct() {
let src = "var pos := 1\nfunc f():\n\tvar pos := 2\n\tprint(pos)\n";
let db = db_with(&[(0, src)]);
let member = at_nth(&db, 0, "pos", 0, src).unwrap();
let local = at_nth(&db, 0, "pos", 1, src).unwrap();
assert!(matches!(member, GodotDef::Member { .. }), "{member:?}");
assert!(matches!(local, GodotDef::Local { .. }), "{local:?}");
assert_ne!(member, local);
let r = at_nth(&db, 0, "pos", 2, src).unwrap();
assert_eq!(r, local);
}
#[test]
fn same_named_members_of_different_classes_are_distinct() {
let a = "class_name A\nfunc update():\n\tpass\n";
let b = "class_name B\nfunc update():\n\tpass\n";
let db = db_with(&[(0, a), (1, b)]);
let ua = at(&db, 0, "update", a).unwrap();
let ub = at(&db, 1, "update", b).unwrap();
assert!(matches!(ua, GodotDef::Member { .. }));
assert!(matches!(ub, GodotDef::Member { .. }));
assert_ne!(ua, ub, "A.update and B.update must be distinct");
}
#[test]
fn class_name_decl_and_reference_classify_to_the_same_global() {
let widget = "class_name Widget\nfunc make() -> int:\n\treturn 1\n";
let user = "func f():\n\tvar w: Widget\n\tvar x := Widget.new()\n";
let db = db_with(&[(0, widget), (1, user)]);
let decl = at(&db, 0, "Widget", widget).unwrap();
let ann = at(&db, 1, "Widget\n", user).unwrap(); let ctor = at(&db, 1, "Widget.new", user).unwrap();
assert!(matches!(
decl,
GodotDef::Global {
decl_file: FileId(0),
..
}
));
assert_eq!(decl, ann, "annotation must resolve to the class_name def");
assert_eq!(
decl, ctor,
"`Widget.new()` must resolve to the class_name def"
);
}
#[test]
fn extends_user_class_classifies_to_the_global() {
let base = "class_name Base\nfunc m():\n\tpass\n";
let derived = "class_name Derived\nextends Base\n";
let db = db_with(&[(0, base), (1, derived)]);
let decl = at(&db, 0, "Base", base).unwrap();
let ext = at(&db, 1, "Base", derived).unwrap(); assert!(matches!(
decl,
GodotDef::Global {
decl_file: FileId(0),
..
}
));
assert_eq!(
decl, ext,
"`extends Base` must classify to Base's class_name def"
);
}
#[test]
fn inherited_member_resolves_to_the_declaring_base() {
let base = "class_name Base\nfunc base_m() -> int:\n\treturn 1\n";
let derived = "class_name Derived\nextends Base\nfunc use_it():\n\tself.base_m()\n";
let db = db_with(&[(0, base), (1, derived)]);
let decl = at(&db, 0, "base_m", base).unwrap();
let call = at(&db, 1, "base_m()", derived).unwrap();
assert!(matches!(
decl,
GodotDef::Member {
owner_file: FileId(0),
..
}
));
assert_eq!(
decl, call,
"inherited call must resolve to the base's member def"
);
}
#[test]
fn inner_class_member_is_out_of_scope() {
let src =
"class_name A\nfunc update():\n\tpass\nclass Inner:\n\tfunc update():\n\t\tpass\n";
let db = db_with(&[(0, src)]);
let top = at_nth(&db, 0, "update", 0, src).unwrap();
let inner = at_nth(&db, 0, "update", 1, src);
assert!(matches!(top, GodotDef::Member { .. }), "{top:?}");
assert_eq!(
inner, None,
"an inner-class member must not classify (out of scope), got {inner:?}"
);
}
#[test]
fn match_capture_classifies_as_local_distinct_from_member() {
let src = "var cap := 0\nfunc f(v):\n\tmatch v:\n\t\tvar cap:\n\t\t\tprint(cap)\n";
let db = db_with(&[(0, src)]);
let member = at_nth(&db, 0, "cap", 0, src).unwrap();
let capture = at_nth(&db, 0, "cap", 1, src).unwrap();
let usage = at_nth(&db, 0, "cap", 2, src).unwrap();
assert!(matches!(member, GodotDef::Member { .. }), "{member:?}");
assert!(matches!(capture, GodotDef::Local { .. }), "{capture:?}");
assert_eq!(
usage, capture,
"`print(cap)` must resolve to the match capture"
);
assert_ne!(usage, member);
}
#[test]
fn accessor_body_local_is_not_a_member() {
let src = "var hp: int:\n\tget:\n\t\tvar tmp = 2\n\t\treturn tmp\n";
let db = db_with(&[(0, src)]);
let tmp = at_nth(&db, 0, "tmp", 0, src);
assert!(
!matches!(tmp, Some(GodotDef::Member { .. })),
"a local in a get/set body must not be a Member, got {tmp:?}"
);
}
#[test]
fn anon_enum_variant_classifies_as_member() {
let src = "enum { FIRE, ICE }\nfunc f():\n\tprint(FIRE)\n";
let db = db_with(&[(0, src)]);
let decl = at_nth(&db, 0, "FIRE", 0, src).unwrap(); let usage = at_nth(&db, 0, "FIRE", 1, src).unwrap(); assert!(matches!(decl, GodotDef::Member { .. }), "{decl:?}");
assert_eq!(
decl, usage,
"an anon-enum variant decl and use share identity"
);
}
#[test]
fn shadowed_local_reference_resolves_to_the_nearest_declaration() {
let src = "func f(x):\n\tvar x := 2\n\tprint(x)\n";
let db = db_with(&[(0, src)]);
let param = at_nth(&db, 0, "x", 0, src).unwrap();
let local = at_nth(&db, 0, "x", 1, src).unwrap();
let usage = at_nth(&db, 0, "x", 2, src).unwrap();
assert!(matches!(param, GodotDef::Local { .. }), "{param:?}");
assert!(matches!(local, GodotDef::Local { .. }), "{local:?}");
assert_ne!(param, local, "param x and local x are distinct");
assert_eq!(
usage, local,
"the reference resolves to the nearest (local) declaration"
);
}
}