use gdscript_api::{EngineApi, MemberRef, TyRef};
use gdscript_base::{Diagnostic, DiagnosticSource, FileId, Severity, TextRange};
use gdscript_db::Db;
use gdscript_scene::{SceneModel, SceneNode};
use gdscript_syntax::GdNode;
use rustc_hash::FxHashMap;
use smol_str::SmolStr;
use std::sync::Arc;
use crate::body::{self, BinOp, Body, Expr, ExprId, Literal, ParamBinding, Stmt, UnOp};
use crate::cst::{self, AstPtr};
use crate::item_tree::{ItemTree, Member, item_tree};
use crate::resolve::{self, ClassItem, ClassScope, GlobalDef};
use crate::ty::{self, Assign, EnumRef, ScriptRefId, Ty};
pub const INFERENCE_ON_VARIANT: &str = "INFERENCE_ON_VARIANT";
pub const TYPE_MISMATCH: &str = "TYPE_MISMATCH";
pub const NARROWING_CONVERSION: &str = "NARROWING_CONVERSION";
pub const INTEGER_DIVISION: &str = "INTEGER_DIVISION";
pub const UNSAFE_PROPERTY_ACCESS: &str = "UNSAFE_PROPERTY_ACCESS";
pub const UNSAFE_METHOD_ACCESS: &str = "UNSAFE_METHOD_ACCESS";
pub const UNSAFE_CALL_ARGUMENT: &str = "UNSAFE_CALL_ARGUMENT";
pub const INVALID_NODE_PATH: &str = "INVALID_NODE_PATH";
pub const SHADOWED_GLOBAL_IDENTIFIER: &str = "SHADOWED_GLOBAL_IDENTIFIER";
pub const CYCLIC_INHERITANCE: &str = "CYCLIC_INHERITANCE";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindingKind {
Var,
Param,
ForVar,
MatchBind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Binding {
pub name_range: TextRange,
pub ty: Ty,
pub init: Option<ExprId>,
pub annotated: bool,
pub inferred_colon_eq: bool,
pub kind: BindingKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InferenceResult {
pub expr_ty: FxHashMap<ExprId, Ty>,
pub bindings: Vec<Binding>,
pub diagnostics: Vec<Diagnostic>,
}
impl InferenceResult {
#[must_use]
pub fn type_of(&self, id: ExprId) -> Option<&Ty> {
self.expr_ty.get(&id)
}
#[must_use]
pub fn binding_at(&self, offset: u32) -> Option<&Binding> {
self.bindings
.iter()
.find(|b| b.name_range.start <= offset && offset < b.name_range.end)
}
}
#[must_use]
pub fn infer(
db: &dyn Db,
api: &EngineApi,
root: &GdNode,
class: &ClassScope,
body: &Body,
return_ty: Ty,
) -> InferenceResult {
let self_ty = class.self_ty.clone();
let mut cx = Cx {
db,
api,
root,
body,
class,
self_ty,
return_ty,
expr_ty: FxHashMap::default(),
bindings: Vec::new(),
diagnostics: Vec::new(),
locals: FxHashMap::default(),
narrowing: FxHashMap::default(),
};
let params = body.params.clone();
for p in ¶ms {
let ty = cx.param_ty(p);
cx.bindings.push(Binding {
name_range: p.name_range,
ty: ty.clone(),
init: None,
annotated: p.type_ref.is_some(),
inferred_colon_eq: false,
kind: BindingKind::Param,
});
cx.locals.insert(p.name.clone(), ty);
}
if let Some(tail) = body.tail {
cx.infer_expr(tail, &Expectation::None);
}
let block = body.block.clone();
cx.infer_block(&block);
InferenceResult {
expr_ty: cx.expr_ty,
bindings: cx.bindings,
diagnostics: cx.diagnostics,
}
}
#[must_use]
pub fn infer_func(
db: &dyn Db,
api: &EngineApi,
root: &GdNode,
class: &ClassScope,
ptr: AstPtr,
) -> InferenceResult {
let Some(node) = ptr.to_node(root) else {
return InferenceResult::default();
};
let body = body::body_of_func(&node);
let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
.map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
infer(db, api, root, class, &body, return_ty)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unit {
pub range: TextRange,
pub body: Body,
pub result: InferenceResult,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FileInference {
pub tree: Arc<ItemTree>,
pub units: Vec<Unit>,
pub diagnostics: Vec<Diagnostic>,
}
impl FileInference {
#[must_use]
pub fn unit_at(&self, offset: u32) -> Option<&Unit> {
self.units
.iter()
.filter(|u| u.range.start <= offset && offset < u.range.end)
.min_by_key(|u| u.range.end - u.range.start)
}
}
#[must_use]
pub fn analyze_file(db: &dyn Db, api: &EngineApi, root: &GdNode, file_id: FileId) -> FileInference {
let tree = item_tree(root);
let mut units = Vec::new();
let mut diagnostics = Vec::new();
let mut member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
let self_ref = Ty::ScriptRef(ScriptRefId(file_id.0));
let res_path = db.file_text(file_id).and_then(|ft| ft.res_path(db));
if let Some(name) = tree.class_name.clone() {
let collides = collisions_contains(db, &name)
|| resolve::resolve_global(api, &name).is_some()
|| is_autoload_singleton(db, &name);
if collides && let Some(range) = class_name_decl_range(root) {
diagnostics.push(Diagnostic {
range,
severity: Severity::Warning,
code: SHADOWED_GLOBAL_IDENTIFIER.to_owned(),
message: format!(
"The global class \"{name}\" hides a built-in/native/global/autoload."
),
source: DiagnosticSource::Type,
fixes: Vec::new(),
});
}
}
if extends_chain_is_cyclic(db, file_id)
&& let Some(range) = extends_decl_range(root)
{
diagnostics.push(Diagnostic {
range,
severity: Severity::Warning,
code: CYCLIC_INHERITANCE.to_owned(),
message: "Cyclic class hierarchy: this class's `extends` chain returns to itself."
.to_owned(),
source: DiagnosticSource::Type,
fixes: Vec::new(),
});
}
{
const MAX_ROUNDS: usize = 4;
let mut final_units: Vec<Unit> = Vec::new();
let mut final_diagnostics: Vec<Diagnostic> = Vec::new();
for _ in 0..MAX_ROUNDS {
let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
class.self_ty = self_ref.clone();
class.member_types.clone_from(&member_types);
let mut next_member_types: FxHashMap<SmolStr, Ty> = FxHashMap::default();
final_units = Vec::new();
final_diagnostics = Vec::new();
for m in &tree.members {
let (ptr, range) = match m {
Member::Var(v) => (v.ptr, v.range),
Member::Const(c) => (c.ptr, c.range),
_ => continue,
};
if let Some(unit) = unit_from_decl(db, api, root, &class, ptr, range) {
if let (Some(name), Some(b)) = (m.name(), unit.result.bindings.first()) {
next_member_types.insert(SmolStr::new(name), b.ty.clone());
}
final_diagnostics.extend(unit.result.diagnostics.iter().cloned());
final_units.push(unit);
}
}
if next_member_types == member_types {
break;
}
member_types = next_member_types;
}
diagnostics.extend(final_diagnostics);
units.extend(final_units);
}
{
let mut class = ClassScope::new(db, api, &tree, res_path.as_deref());
class.member_types = member_types;
class.self_ty = self_ref.clone();
for m in &tree.members {
let Member::Func(f) = m else { continue };
let Some(node) = f.ptr.to_node(root) else {
continue;
};
let body = body::body_of_func(&node);
let return_ty = cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
.map_or(Ty::Variant, |t| resolve::resolve_type_ref(db, api, &t));
let result = infer(db, api, root, &class, &body, return_ty);
diagnostics.extend(result.diagnostics.iter().cloned());
units.push(Unit {
range: f.range,
body,
result,
});
}
}
FileInference {
tree,
units,
diagnostics,
}
}
fn collisions_contains(db: &dyn Db, name: &SmolStr) -> bool {
db.source_root()
.is_some_and(|root| crate::queries::class_name_collisions(db, root).contains(name))
}
fn is_autoload_singleton(db: &dyn Db, name: &str) -> bool {
db.project_config().is_some_and(|config| {
crate::queries::autoload_registry(db, config)
.resolve_path(name)
.is_some()
})
}
fn class_name_decl_range(root: &GdNode) -> Option<TextRange> {
use gdscript_syntax::SyntaxKind;
let decl = gdscript_syntax::ast::descendants(root)
.into_iter()
.find(|n| n.kind() == SyntaxKind::ClassNameDecl)?;
let name_node = decl.children().find(|c| c.kind() == SyntaxKind::Name)?;
let r = cst::text_range_of(name_node);
let text = name_node.text().to_string();
let lead = u32::try_from(text.len() - text.trim_start().len()).unwrap_or(0);
let len = u32::try_from(text.trim().len()).unwrap_or(0);
Some(TextRange::new(r.start + lead, r.start + lead + len))
}
fn extends_decl_range(root: &GdNode) -> Option<TextRange> {
use gdscript_syntax::SyntaxKind;
for child in root.children() {
match child.kind() {
SyntaxKind::ExtendsClause => return Some(cst::text_range_of(child)),
SyntaxKind::ClassNameDecl => {
if let Some(kw) = child.children().find(|c| c.kind() == SyntaxKind::ExtendsKw) {
let start = cst::text_range_of(kw).start;
let end = cst::text_range_of(child).end;
return Some(TextRange::new(start, end));
}
}
_ => {}
}
}
None
}
fn extends_chain_is_cyclic(db: &dyn Db, start: FileId) -> bool {
use std::collections::HashSet;
let mut visited: HashSet<FileId> = HashSet::new();
visited.insert(start);
let mut current = start;
for _ in 0..=64 {
let Some(file) = db.file_text(current) else {
return false;
};
let base = crate::queries::script_class(db, file).base().clone();
let Ty::ScriptRef(next) = base else {
return false; };
let next_id = FileId(next.0);
if !visited.insert(next_id) {
return true;
}
current = next_id;
}
false
}
fn unit_from_decl(
db: &dyn Db,
api: &EngineApi,
root: &GdNode,
class: &ClassScope,
ptr: AstPtr,
range: TextRange,
) -> Option<Unit> {
let node = ptr.to_node(root)?;
let body = body::body_of_decl_stmt(&node);
let result = infer(db, api, root, class, &body, Ty::Variant);
Some(Unit {
range,
body,
result,
})
}
enum Expectation {
None,
Has(Ty),
}
struct Cx<'a> {
db: &'a dyn Db,
api: &'a EngineApi,
root: &'a GdNode,
body: &'a Body,
class: &'a ClassScope<'a>,
self_ty: Ty,
return_ty: Ty,
expr_ty: FxHashMap<ExprId, Ty>,
bindings: Vec<Binding>,
diagnostics: Vec<Diagnostic>,
locals: FxHashMap<SmolStr, Ty>,
narrowing: FxHashMap<String, Ty>,
}
impl Cx<'_> {
fn builtin(&self, name: &str) -> Ty {
self.api
.builtin_by_name(name)
.map_or(Ty::Variant, Ty::Builtin)
}
fn int_ty(&self) -> Ty {
self.builtin("int")
}
fn float_ty(&self) -> Ty {
self.builtin("float")
}
fn bool_ty(&self) -> Ty {
self.builtin("bool")
}
fn is_int(&self, ty: &Ty) -> bool {
matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "int")
}
fn is_float(&self, ty: &Ty) -> bool {
matches!(ty, Ty::Builtin(b) if self.api.builtin(*b).name == "float")
}
fn is_numeric(&self, ty: &Ty) -> bool {
self.is_int(ty) || self.is_float(ty)
}
fn emit(&mut self, range: TextRange, severity: Severity, code: &str, message: String) {
self.diagnostics.push(Diagnostic {
range,
severity,
code: code.to_owned(),
message,
source: DiagnosticSource::Type,
fixes: Vec::new(),
});
}
fn range_of(&self, id: ExprId) -> TextRange {
self.body.source_map.expr_range(id)
}
fn check_assign(&mut self, from: &Ty, to: &Ty, range: TextRange) {
match ty::is_assignable(self.api, from, to) {
Assign::Narrowing => self.emit(
range,
Severity::Warning,
NARROWING_CONVERSION,
"Narrowing conversion (float is converted to int and loses precision).".to_owned(),
),
Assign::No => {
let to_label = to.label(self.api).unwrap_or_else(|| "?".to_owned());
let from_label = from.label(self.api).unwrap_or_else(|| "?".to_owned());
self.emit(
range,
Severity::Error,
TYPE_MISMATCH,
format!(
"Cannot assign a value of type \"{from_label}\" to a target of type \"{to_label}\"."
),
);
}
Assign::Ok | Assign::OkUnsafe | Assign::IntAsEnum => {}
}
}
fn infer_block(&mut self, block: &[body::StmtId]) {
for &stmt in block {
self.infer_stmt(stmt);
}
}
fn infer_stmt(&mut self, id: body::StmtId) {
match self.body.stmt(id).clone() {
Stmt::Expr(e) => {
self.infer_expr(e, &Expectation::None);
}
Stmt::Var(v) => self.infer_local_var(&v),
Stmt::Return(e) => {
if let Some(e) = e {
let expected = if self.return_ty.is_uninformative() {
Expectation::None
} else {
Expectation::Has(self.return_ty.clone())
};
let t = self.infer_expr(e, &expected);
if let Expectation::Has(ret) = expected {
self.check_assign(&t, &ret, self.range_of(e));
}
}
}
Stmt::If {
cond,
then_branch,
elifs,
else_branch,
} => {
self.infer_expr(cond, &Expectation::None);
self.in_branch(|cx| {
cx.apply_narrowing(cond);
cx.infer_block(&then_branch);
});
for (econd, eblock) in elifs {
self.infer_expr(econd, &Expectation::None);
self.in_branch(|cx| {
cx.apply_narrowing(econd);
cx.infer_block(&eblock);
});
}
if let Some(eb) = else_branch {
self.in_branch(|cx| cx.infer_block(&eb));
}
}
Stmt::While { cond, body } => {
self.infer_expr(cond, &Expectation::None);
self.in_branch(|cx| cx.infer_block(&body));
}
Stmt::For(f) => {
let iter_ty = self.infer_expr(f.iter, &Expectation::None);
let var_ty = f.var_type.as_ref().map_or_else(
|| self.loop_var_ty(&iter_ty),
|ptr| self.resolve_ptr_ty(*ptr),
);
self.bindings.push(Binding {
name_range: f.var_range,
ty: var_ty.clone(),
init: None,
annotated: f.var_type.is_some(),
inferred_colon_eq: false,
kind: BindingKind::ForVar,
});
self.locals.insert(f.var.clone(), var_ty);
self.in_branch(|cx| cx.infer_block(&f.body));
}
Stmt::Match { scrutinee, arms } => {
self.infer_expr(scrutinee, &Expectation::None);
for arm in arms {
self.in_branch(|cx| {
for b in &arm.binds {
cx.bindings.push(Binding {
name_range: b.range,
ty: Ty::Variant,
init: None,
annotated: false,
inferred_colon_eq: false,
kind: BindingKind::MatchBind,
});
cx.locals.insert(b.name.clone(), Ty::Variant);
}
if let Some(g) = arm.guard {
cx.infer_expr(g, &Expectation::None);
}
cx.infer_block(&arm.body);
});
}
}
Stmt::Break | Stmt::Continue | Stmt::Pass => {}
Stmt::Assert(cond) => {
if let Some(cond) = cond {
self.infer_expr(cond, &Expectation::None);
}
}
}
}
fn infer_local_var(&mut self, v: &body::LocalVar) {
let annotated = v.type_ref.map(|p| self.resolve_ptr_ty(p));
let init_ty = v.init.map(|e| {
let expected = annotated
.as_ref()
.map_or(Expectation::None, |t| Expectation::Has(t.clone()));
self.infer_expr(e, &expected)
});
let range = v.init.map_or(v.name_range, |e| self.range_of(e));
let binding_ty = match (&annotated, &init_ty) {
(Some(t), Some(init)) => {
self.check_assign(init, t, range);
t.clone()
}
(Some(t), None) => t.clone(),
(None, Some(init)) if v.is_inferred => {
if init.is_variant() {
self.emit(
range,
Severity::Error,
INFERENCE_ON_VARIANT,
inference_on_variant_msg(if v.is_const { "constant" } else { "variable" }),
);
Ty::Variant
} else {
init.clone()
}
}
(None, Some(init)) => {
if v.is_const {
init.clone()
} else {
Ty::Variant
}
}
(None, None) => Ty::Variant,
};
self.bindings.push(Binding {
name_range: v.name_range,
ty: binding_ty.clone(),
init: v.init,
annotated: v.type_ref.is_some(),
inferred_colon_eq: v.is_inferred,
kind: BindingKind::Var,
});
self.narrowing.remove(v.name.as_str());
self.locals.insert(v.name.clone(), binding_ty);
}
fn infer_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
let ty = self.synth_expr(id, expected);
self.expr_ty.insert(id, ty.clone());
ty
}
#[allow(clippy::too_many_lines)]
fn synth_expr(&mut self, id: ExprId, expected: &Expectation) -> Ty {
match self.body.expr(id).clone() {
Expr::Missing => Ty::Error,
Expr::Literal(lit) => self.literal_ty(lit),
Expr::Name(name) => self.resolve_name(id, &name),
Expr::SelfExpr => self.self_ty.clone(),
Expr::Super => self.class.base.clone(),
Expr::Paren(inner) => self.infer_expr(inner, expected),
Expr::Bin { op, lhs, rhs } => self.infer_bin(id, op, lhs, rhs),
Expr::Unary { op, operand } => {
let t = self.infer_expr(operand, &Expectation::None);
match op {
UnOp::Not => self.bool_ty(),
UnOp::BitNot => self.int_ty(),
UnOp::Neg | UnOp::Pos => {
if t.is_uninformative() || self.is_numeric(&t) {
t
} else {
Ty::Variant
}
}
}
}
Expr::Ternary {
cond,
then_branch,
else_branch,
} => {
self.infer_expr(cond, &Expectation::None);
let a = self.infer_expr(then_branch, expected);
let b = self.infer_expr(else_branch, expected);
if self.is_null(else_branch) {
a
} else if self.is_null(then_branch) {
b
} else {
self.join(&a, &b)
}
}
Expr::Call { callee, args } => self.infer_call(callee, &args),
Expr::Field {
receiver,
name,
name_range,
} => {
self.infer_field(receiver, &name, name_range, false)
}
Expr::Index { base, index } => {
let base_ty = self.infer_expr(base, &Expectation::None);
self.infer_expr(index, &Expectation::None);
self.index_ty(&base_ty)
}
Expr::Is { operand, .. } => {
self.infer_expr(operand, &Expectation::None);
self.bool_ty()
}
Expr::Cast { operand, ty } => {
self.infer_expr(operand, &Expectation::None);
ty.map_or(Ty::Variant, |p| self.resolve_ptr_ty(p))
}
Expr::In { lhs, rhs, .. } => {
self.infer_expr(lhs, &Expectation::None);
self.infer_expr(rhs, &Expectation::None);
self.bool_ty()
}
Expr::Await(operand) => {
let operand_ty = self.infer_expr(operand, &Expectation::None);
if matches!(operand_ty, Ty::Signal(_)) {
Ty::Unknown
} else {
operand_ty
}
}
Expr::Array(elems) => {
let pushed = match expected {
Expectation::Has(Ty::Array(e)) => Some((**e).clone()),
_ => None,
};
let elem_exp = pushed.clone().map_or(Expectation::None, Expectation::Has);
for e in elems {
self.infer_expr(e, &elem_exp);
}
pushed.map_or_else(Ty::array_of_variant, |e| Ty::Array(Box::new(e)))
}
Expr::Dict(entries) => {
let pushed = match expected {
Expectation::Has(Ty::Dict(k, v)) => Some(((**k).clone(), (**v).clone())),
_ => None,
};
let (kx, vx) = pushed
.clone()
.map_or((Expectation::None, Expectation::None), |(k, v)| {
(Expectation::Has(k), Expectation::Has(v))
});
for (k, v) in entries {
self.infer_expr(k, &kx);
if let Some(v) = v {
self.infer_expr(v, &vx);
}
}
pushed.map_or_else(Ty::dict_of_variant, |(k, v)| {
Ty::Dict(Box::new(k), Box::new(v))
})
}
Expr::Lambda { params, body } => {
self.infer_lambda(¶ms, &body);
Ty::Callable
}
Expr::Preload { arg, path } => {
if let Some(arg) = arg {
self.infer_expr(arg, &Expectation::None);
}
match path {
Some(p) => {
match resolve::anchor_res_path(self.self_res_path().as_deref(), &p) {
Some(abs) => resolve::resolve_external(
self.db,
&resolve::ExternalRef::Preload(abs),
),
None => Ty::Unknown,
}
}
None => Ty::Unknown,
}
}
Expr::GetNode { path, unique } => self.resolve_node_path(id, path.as_deref(), unique),
}
}
fn is_null(&self, id: ExprId) -> bool {
matches!(self.body.expr(id), Expr::Literal(Literal::Null))
}
fn literal_ty(&self, lit: Literal) -> Ty {
match lit {
Literal::Int => self.int_ty(),
Literal::Float | Literal::MathConst => self.float_ty(),
Literal::Bool => self.bool_ty(),
Literal::Str => self.builtin("String"),
Literal::StringName => self.builtin("StringName"),
Literal::NodePath => self.builtin("NodePath"),
Literal::Null => Ty::Variant,
}
}
fn node_ty(&self) -> Ty {
self.api
.class_by_name("Node")
.map_or(Ty::Unknown, Ty::Object)
}
fn resolve_node_path(&mut self, id: ExprId, path: Option<&str>, unique: bool) -> Ty {
use gdscript_scene::NodePathResolution as R;
let fallback = self.node_ty();
let Some(path) = path else {
return fallback; };
let Some(ctx) = self.owning_scene() else {
return fallback; };
let resolution = if unique {
ctx.model.classify_unique(path)
} else {
ctx.model.classify_path_from(ctx.attach, path)
};
match resolution {
R::Resolved(idx) => ctx
.model
.node(idx)
.and_then(|n| self.scene_node_ty(&ctx.model, n, 0))
.unwrap_or(fallback),
R::Missing if !ctx.ambiguous => {
let what = if unique { "unique name" } else { "node path" };
let sigil = if unique { "%" } else { "$" };
self.emit(
self.range_of(id),
Severity::Warning,
INVALID_NODE_PATH,
format!("no {what} `{sigil}{path}` in the owning scene"),
);
fallback
}
R::IntoInstance => {
let walked = if unique {
ctx.model.resolve_unique_into_instance(path)
} else {
ctx.model.resolve_into_instance(ctx.attach, path)
};
walked
.and_then(|(inst, tail)| {
let inst_node = ctx.model.node(inst)?;
self.resolve_into_instance_ty(&ctx.model, inst_node, &tail, 0)
})
.unwrap_or(fallback)
}
_ => fallback,
}
}
fn owning_scene(&self) -> Option<crate::queries::SceneContext> {
let Ty::ScriptRef(sref) = &self.self_ty else {
return None;
};
let ft = self.db.file_text(FileId(sref.0))?;
crate::queries::scene_context(self.db, ft)
}
fn self_res_path(&self) -> Option<SmolStr> {
let Ty::ScriptRef(sref) = &self.self_ty else {
return None;
};
self.db.file_text(FileId(sref.0))?.res_path(self.db)
}
fn scene_node_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
if let Some(script_ty) = self.node_script_ref(scene, node) {
return Some(script_ty);
}
if let Some(decl) = node.decl_type.as_ref() {
let ty = resolve::resolve_type_name(self.db, self.api, decl);
if !ty.is_uninformative() {
return Some(ty);
}
}
self.instance_root_ty(scene, node, depth)
}
fn instance_root_ty(&self, scene: &SceneModel, node: &SceneNode, depth: u32) -> Option<Ty> {
if depth >= 16 {
return None;
}
let (sub, sub_root) = self.instance_subscene(scene, node)?;
let root_node = sub.node(sub_root)?;
self.scene_node_ty(&sub, root_node, depth + 1)
}
fn instance_subscene(
&self,
scene: &SceneModel,
node: &SceneNode,
) -> Option<(Arc<SceneModel>, gdscript_scene::NodeIdx)> {
let inst = node.instance.as_ref()?;
let path = scene.ext_resources.get(inst)?.path.as_ref()?;
let root = self.db.source_root()?;
let file = crate::queries::res_path_registry(self.db, root)
.get(path.as_str())
.copied()?;
let ft = self.db.file_text(file)?;
let sub = crate::queries::scene_model(self.db, ft);
let sub_root = sub.root?;
Some((sub, sub_root))
}
fn resolve_into_instance_ty(
&self,
scene: &SceneModel,
instance_node: &SceneNode,
tail: &str,
depth: u32,
) -> Option<Ty> {
if depth >= 16 {
return None;
}
let (sub, sub_root) = self.instance_subscene(scene, instance_node)?;
if let Some(idx) = sub.resolve_path_from(sub_root, tail) {
let n = sub.node(idx)?;
return self.scene_node_ty(&sub, n, depth + 1);
}
let (inner, inner_tail) = sub.resolve_into_instance(sub_root, tail)?;
let inner_node = sub.node(inner)?;
self.resolve_into_instance_ty(&sub, inner_node, &inner_tail, depth + 1)
}
fn node_script_ref(&self, scene: &SceneModel, node: &SceneNode) -> Option<Ty> {
let path = scene
.ext_resources
.get(node.script.as_ref()?)?
.path
.as_ref()?;
let root = self.db.source_root()?;
let file = crate::queries::res_path_registry(self.db, root)
.get(path.as_str())
.copied()?;
Some(Ty::ScriptRef(ScriptRefId(file.0)))
}
fn infer_bin(&mut self, id: ExprId, op: BinOp, lhs: ExprId, rhs: ExprId) -> Ty {
if op == BinOp::Assign {
return self.infer_assign(lhs, rhs);
}
let lt = self.infer_expr(lhs, &Expectation::None);
let rt = self.infer_expr(rhs, &Expectation::None);
if op.is_boolean() {
return self.bool_ty();
}
if op == BinOp::Div && self.is_int(<) && self.is_int(&rt) {
self.emit(
self.range_of(id),
Severity::Warning,
INTEGER_DIVISION,
"Integer division. Decimal part will be discarded.".to_owned(),
);
return self.int_ty();
}
self.bin_result(op, <, &rt)
}
fn infer_assign(&mut self, lhs: ExprId, rhs: ExprId) -> Ty {
let slot = self.infer_expr(lhs, &Expectation::None);
let expected = if slot.is_uninformative() {
Expectation::None
} else {
Expectation::Has(slot.clone())
};
let value = self.infer_expr(rhs, &expected);
if !slot.is_uninformative() {
self.check_assign(&value, &slot, self.range_of(rhs));
}
if let Some(key) = self.narrow_key(lhs) {
let narrowed = if slot.is_uninformative() {
value.clone()
} else {
slot.clone()
};
self.narrowing.insert(key, narrowed);
}
slot
}
fn bin_result(&self, op: BinOp, lt: &Ty, rt: &Ty) -> Ty {
if let (Ty::Builtin(b), Some(sym)) = (lt, op_symbol(op)) {
for o in self.api.builtin_operators(*b) {
if o.op == sym
&& let Some(right) = &o.right
&& self.tyref_matches(right, rt)
{
return ty::resolve_tyref(self.api, &o.result);
}
}
}
if self.is_numeric(lt) && self.is_numeric(rt) {
return if self.is_float(lt) || self.is_float(rt) {
self.float_ty()
} else {
self.int_ty()
};
}
if lt.is_unknown() || rt.is_unknown() || lt.is_error() || rt.is_error() {
return Ty::Unknown;
}
Ty::Variant
}
fn tyref_matches(&self, tyref: &TyRef, ty: &Ty) -> bool {
let resolved = ty::resolve_tyref(self.api, tyref);
resolved.is_variant() || &resolved == ty
}
fn infer_call(&mut self, callee: ExprId, args: &[ExprId]) -> Ty {
for &a in args {
self.infer_expr(a, &Expectation::None);
}
let ret = match self.body.expr(callee).clone() {
Expr::Field {
receiver,
name,
name_range,
} => {
self.infer_field(receiver, &name, name_range, true)
}
Expr::Name(name) => {
let ret = self.resolve_call_name(&name);
self.expr_ty.insert(callee, Ty::Callable);
ret
}
_ => {
self.infer_expr(callee, &Expectation::None);
Ty::Unknown
}
};
self.check_call_args(callee, args);
ret
}
fn check_call_args(&mut self, callee: ExprId, args: &[ExprId]) {
let Some(params) = self.call_param_tys(callee) else {
return;
};
for (i, &arg) in args.iter().enumerate() {
let Some(param_ty) = params.get(i) else {
break; };
if param_ty.is_uninformative() || param_ty.is_variant() {
continue; }
let arg_ty = self.expr_ty.get(&arg).cloned().unwrap_or(Ty::Unknown);
if ty::is_assignable(self.api, &arg_ty, param_ty) == Assign::OkUnsafe {
let pl = param_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
let al = arg_ty.label(self.api).unwrap_or_else(|| "?".to_owned());
self.emit(
self.range_of(arg),
Severity::Warning,
UNSAFE_CALL_ARGUMENT,
format!(
"The argument {} requires a value of type \"{pl}\" but is passed \"{al}\", which is unsafe.",
i + 1
),
);
}
}
}
fn call_param_tys(&self, callee: ExprId) -> Option<Vec<Ty>> {
match self.body.expr(callee) {
Expr::Name(name) => self.name_call_param_tys(name),
Expr::Field { receiver, name, .. } => match self.expr_ty.get(receiver)? {
Ty::Object(class) => match self.api.lookup_member(*class, name)? {
MemberRef::Method(sig) => Some(
sig.params
.iter()
.map(|p| ty::resolve_tyref(self.api, &p.ty))
.collect(),
),
_ => None,
},
_ => None,
},
_ => None,
}
}
fn name_call_param_tys(&self, name: &str) -> Option<Vec<Ty>> {
if let Some(item) = self.class.lookup(name)
&& let Some(Member::Func(f)) = self.class.member(item)
{
return Some(
f.params
.iter()
.map(|p| {
p.type_ref.as_deref().map_or(Ty::Variant, |t| {
resolve::resolve_type_name(self.db, self.api, t)
})
})
.collect(),
);
}
if let Ty::Object(base) = self.class.base
&& let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
{
return Some(
sig.params
.iter()
.map(|p| ty::resolve_tyref(self.api, &p.ty))
.collect(),
);
}
None
}
fn resolve_call_name(&self, name: &str) -> Ty {
if let Some(item) = self.class.lookup(name)
&& let Some(Member::Func(f)) = self.class.member(item)
{
return self.func_return_ty(f.return_type.as_deref());
}
if let Ty::Object(base) = self.class.base
&& let Some(MemberRef::Method(sig)) = self.api.lookup_member(base, name)
{
return ty::resolve_tyref(self.api, &sig.return_ty);
}
if let Some(u) = self.api.utility(name) {
return ty::resolve_tyref(self.api, &u.return_ty);
}
if let Some(f) = self.api.gdscript_builtin(name) {
return resolve::layer_to_ty(self.api, f.ret);
}
if let Some(b) = self.api.builtin_by_name(name) {
return ty::resolve_tyref(self.api, &TyRef::Builtin(b));
}
Ty::Unknown
}
fn func_return_ty(&self, annotation: Option<&str>) -> Ty {
annotation.map_or(Ty::Variant, |t| {
resolve::resolve_type_name(self.db, self.api, t)
})
}
fn infer_field(
&mut self,
receiver: ExprId,
name: &str,
name_range: TextRange,
as_method: bool,
) -> Ty {
let is_self = matches!(self.body.expr(receiver), Expr::SelfExpr);
let recv_ty = self.infer_expr(receiver, &Expectation::None);
if is_self && let Some(item) = self.class.lookup(name) {
return self.own_member_ty(item, as_method);
}
match &recv_ty {
t if t.is_uninformative() => recv_ty.clone(),
Ty::Object(class) => {
if name == "new" {
recv_ty.clone()
} else if let Some(m) = self.api.lookup_member(*class, name) {
self.member_ref_ty(&m, as_method)
} else if let Some(t) = self.class_enum_value(*class, name) {
t
} else {
self.emit_unsafe(name, &recv_ty, name_range, as_method);
Ty::Variant
}
}
Ty::Builtin(_) | Ty::Array(_) | Ty::Dict(..) | Ty::Callable | Ty::Signal(_) => {
self.builtin_member_ty(&recv_ty, name, name_range, as_method)
}
Ty::Enum(_) => self.int_ty(),
Ty::ScriptRef(sref) => self.script_member_ty(*sref, name, as_method),
_ => Ty::Variant,
}
}
fn script_member_ty(&self, sref: ScriptRefId, name: &str, as_method: bool) -> Ty {
if name == "new" {
return Ty::ScriptRef(sref);
}
self.script_member_walk(sref, name, as_method, 0)
.unwrap_or(Ty::Unknown)
}
fn script_member_walk(
&self,
sref: ScriptRefId,
name: &str,
as_method: bool,
depth: u32,
) -> Option<Ty> {
if depth > 32 {
return None;
}
let file = self.db.file_text(FileId(sref.0))?;
let sc = crate::queries::script_class(self.db, file);
if let Some(m) = sc.member(name) {
return Some(match m {
crate::queries::MemberSig::Method(ret) => {
if as_method {
ret.clone()
} else {
Ty::Callable
}
}
crate::queries::MemberSig::Field(t) => t.clone(),
crate::queries::MemberSig::Signal => Ty::Signal(None),
});
}
match sc.base() {
Ty::ScriptRef(base) => self.script_member_walk(*base, name, as_method, depth + 1),
Ty::Object(class) => self
.api
.lookup_member(*class, name)
.map(|m| self.member_ref_ty(&m, as_method)),
_ => None,
}
}
fn is_subtype(&self, sub: &Ty, sup: &Ty) -> bool {
match (sub, sup) {
(Ty::Object(a), Ty::Object(b)) => self.api.is_subclass(*a, *b),
(Ty::ScriptRef(a), Ty::ScriptRef(b)) => self.script_is_subtype(*a, *b, 0),
(Ty::ScriptRef(a), Ty::Object(b)) => self.script_extends_engine(*a, *b, 0),
_ => false,
}
}
fn script_is_subtype(&self, sub: ScriptRefId, sup: ScriptRefId, depth: u32) -> bool {
if depth > 32 {
return false;
}
if sub == sup {
return true;
}
let Some(file) = self.db.file_text(FileId(sub.0)) else {
return false;
};
match crate::queries::script_class(self.db, file).base() {
Ty::ScriptRef(base) => self.script_is_subtype(*base, sup, depth + 1),
_ => false,
}
}
fn script_extends_engine(
&self,
sub: ScriptRefId,
sup_native: gdscript_api::ClassId,
depth: u32,
) -> bool {
if depth > 32 {
return false;
}
let Some(file) = self.db.file_text(FileId(sub.0)) else {
return false;
};
match crate::queries::script_class(self.db, file).base() {
Ty::ScriptRef(base) => self.script_extends_engine(*base, sup_native, depth + 1),
Ty::Object(native) => self.api.is_subclass(*native, sup_native),
_ => false,
}
}
fn emit_unsafe(&mut self, name: &str, recv: &Ty, range: TextRange, as_method: bool) {
let recv_label = recv.label(self.api).unwrap_or_else(|| "?".to_owned());
let (code, message) = if as_method {
(
UNSAFE_METHOD_ACCESS,
format!(
"The method \"{name}()\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
),
)
} else {
(
UNSAFE_PROPERTY_ACCESS,
format!(
"The property \"{name}\" is not present on the inferred type \"{recv_label}\" (but may be present on a subtype)."
),
)
};
self.emit(range, Severity::Warning, code, message);
}
fn member_ref_ty(&self, m: &MemberRef, as_method: bool) -> Ty {
match m {
MemberRef::Method(sig) => {
if as_method {
ty::resolve_tyref(self.api, &sig.return_ty)
} else {
Ty::Callable
}
}
MemberRef::Property(p) => p.enum_of.as_ref().map_or_else(
|| ty::resolve_tyref(self.api, &p.ty),
|q| {
Ty::Enum(EnumRef {
qualified: SmolStr::new(q),
bitfield: false,
})
},
),
MemberRef::Const(c) => ty::resolve_tyref(self.api, &c.ty),
MemberRef::Signal(_) => Ty::Signal(None),
MemberRef::Enum(_) => Ty::Variant,
}
}
fn builtin_member_ty(
&mut self,
recv: &Ty,
name: &str,
range: TextRange,
as_method: bool,
) -> Ty {
let Some(bid) = self.builtin_id_of(recv) else {
return Ty::Variant;
};
if as_method {
return if let Some(sig) = self.api.builtin_method(bid, name) {
ty::resolve_tyref(self.api, &sig.return_ty)
} else {
self.emit_unsafe(name, recv, range, true);
Ty::Variant
};
}
if let Some(member) = self.api.builtin_member(bid, name) {
return ty::resolve_tyref(self.api, &member.ty);
}
let data = self.api.builtin(bid);
if let Some(c) = data.constants.iter().find(|c| c.name == name) {
return ty::resolve_tyref(self.api, &c.ty);
}
if data
.enums
.iter()
.any(|e| e.values.iter().any(|v| v.name == name))
{
return self.int_ty();
}
if self.api.builtin_method(bid, name).is_some() {
return Ty::Callable;
}
self.emit_unsafe(name, recv, range, false);
Ty::Variant
}
fn class_enum_value(&self, class: gdscript_api::ClassId, name: &str) -> Option<Ty> {
let mut cur = Some(class);
while let Some(cid) = cur {
let c = self.api.class(cid);
if c.enums
.iter()
.any(|e| e.values.iter().any(|v| v.name == name))
{
return Some(self.int_ty());
}
cur = c.base;
}
None
}
fn builtin_id_of(&self, ty: &Ty) -> Option<gdscript_api::BuiltinId> {
match ty {
Ty::Builtin(b) => Some(*b),
Ty::Array(_) => self.api.builtin_by_name("Array"),
Ty::Dict(..) => self.api.builtin_by_name("Dictionary"),
Ty::Callable => self.api.builtin_by_name("Callable"),
Ty::Signal(_) => self.api.builtin_by_name("Signal"),
_ => None,
}
}
fn index_ty(&self, base: &Ty) -> Ty {
match base {
Ty::Array(elem) => (**elem).clone(),
Ty::Builtin(b) => self
.api
.builtin(*b)
.indexing_return
.as_ref()
.map_or(Ty::Variant, |r| ty::resolve_tyref(self.api, r)),
Ty::Unknown => Ty::Unknown,
Ty::Error => Ty::Error,
_ => Ty::Variant,
}
}
fn loop_var_ty(&self, iter: &Ty) -> Ty {
match iter {
Ty::Array(elem) => (**elem).clone(),
Ty::Builtin(b) => {
let data = self.api.builtin(*b);
if data.name == "int" {
self.int_ty()
} else if let Some(r) = &data.indexing_return {
ty::resolve_tyref(self.api, r)
} else {
Ty::Variant
}
}
Ty::Unknown => Ty::Unknown,
Ty::Error => Ty::Error,
_ => Ty::Variant,
}
}
fn infer_lambda(&mut self, params: &[ParamBinding], body: &[body::StmtId]) {
let saved_locals = self.locals.clone();
let saved_ret = std::mem::replace(&mut self.return_ty, Ty::Variant);
for p in params {
let ty = self.param_ty(p);
self.bindings.push(Binding {
name_range: p.name_range,
ty: ty.clone(),
init: None,
annotated: p.type_ref.is_some(),
inferred_colon_eq: false,
kind: BindingKind::Param,
});
self.locals.insert(p.name.clone(), ty);
}
self.infer_block(body);
self.return_ty = saved_ret;
self.locals = saved_locals;
}
fn param_ty(&mut self, p: &ParamBinding) -> Ty {
if let Some(ptr) = p.type_ref {
return self.resolve_ptr_ty(ptr);
}
p.default
.map_or(Ty::Variant, |e| self.infer_expr(e, &Expectation::None))
}
fn resolve_name(&mut self, id: ExprId, name: &str) -> Ty {
if let Some(key) = self.narrow_key(id)
&& let Some(t) = self.narrowing.get(&key)
{
return t.clone();
}
if let Some(t) = self.locals.get(name) {
return t.clone();
}
if let Some(item) = self.class.lookup(name) {
return self.own_member_ty(item, false);
}
match self.class.base.clone() {
Ty::Object(base) => {
if let Some(m) = self.api.lookup_member(base, name) {
return self.member_ref_ty(&m, false);
}
}
Ty::ScriptRef(base) => {
if let Some(t) = self.script_member_walk(base, name, false, 0) {
return t;
}
}
_ => {}
}
if let Some(g) = resolve::resolve_global(self.api, name) {
return global_ty(&g);
}
let by_class = resolve::resolve_external(
self.db,
&resolve::ExternalRef::ClassName(SmolStr::new(name)),
);
if !by_class.is_unknown() {
return by_class;
}
resolve::resolve_external(self.db, &resolve::ExternalRef::Autoload(SmolStr::new(name)))
}
fn own_member_ty(&self, item: ClassItem, as_method: bool) -> Ty {
match item {
ClassItem::EnumVariant => self.int_ty(),
ClassItem::Member(_) => match self.class.member(item) {
Some(Member::Var(v)) => self.field_ty(&v.name, v.ptr),
Some(Member::Const(c)) => self.field_ty(&c.name, c.ptr),
Some(Member::Func(f)) => {
if as_method {
self.func_return_ty(f.return_type.as_deref())
} else {
Ty::Callable
}
}
Some(Member::Signal(_)) => Ty::Signal(None),
Some(Member::Class(_)) => Ty::Unknown,
Some(Member::Enum(_)) | None => Ty::Variant,
},
}
}
fn field_ty(&self, name: &str, ptr: AstPtr) -> Ty {
if let Some(t) = self.class.member_types.get(name) {
return t.clone();
}
self.resolve_decl_annotation(ptr)
}
fn resolve_decl_annotation(&self, ptr: AstPtr) -> Ty {
let Some(node) = ptr.to_node(self.root) else {
return Ty::Variant;
};
cst::first_child(&node, |k| k == gdscript_syntax::SyntaxKind::TypeRef)
.map_or(Ty::Variant, |t| {
resolve::resolve_type_ref(self.db, self.api, &t)
})
}
fn apply_narrowing(&mut self, cond: ExprId) {
let Expr::Is {
operand,
ty: Some(ptr),
negated: false,
} = self.body.expr(cond).clone()
else {
return;
};
let Some(key) = self.narrow_key(operand) else {
return;
};
let narrowed = self.resolve_ptr_ty(ptr);
if narrowed.is_uninformative() {
return;
}
let cur = self.expr_ty.get(&operand).cloned().unwrap_or(Ty::Variant);
if cur.is_uninformative() || self.is_subtype(&narrowed, &cur) {
self.narrowing.insert(key, narrowed);
}
}
fn narrow_key(&self, id: ExprId) -> Option<String> {
match self.body.expr(id) {
Expr::Name(n) => Some(n.to_string()),
Expr::SelfExpr => Some("self".to_owned()),
Expr::Paren(inner) => self.narrow_key(*inner),
Expr::Field { receiver, name, .. } => {
Some(format!("{}.{name}", self.narrow_key(*receiver)?))
}
_ => None,
}
}
fn resolve_ptr_ty(&self, ptr: AstPtr) -> Ty {
ptr.to_node(self.root).map_or(Ty::Variant, |n| {
resolve::resolve_type_ref(self.db, self.api, &n)
})
}
fn join(&self, a: &Ty, b: &Ty) -> Ty {
if a == b {
return a.clone();
}
if a.is_error() || b.is_error() {
return Ty::Error;
}
if a.is_unknown() || b.is_unknown() {
return Ty::Unknown;
}
if a.is_variant() || b.is_variant() {
return Ty::Variant;
}
if ty::is_assignable(self.api, a, b) == Assign::Ok {
return b.clone();
}
if ty::is_assignable(self.api, b, a) == Assign::Ok {
return a.clone();
}
Ty::Variant
}
fn in_branch<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
let saved = self.narrowing.clone();
let r = f(self);
self.narrowing = saved;
r
}
}
fn global_ty(g: &GlobalDef) -> Ty {
match g {
GlobalDef::Const(t) => t.clone(),
GlobalDef::Singleton(c) | GlobalDef::ClassType(c) => Ty::Object(*c),
GlobalDef::BuiltinType(b) => Ty::Builtin(*b),
GlobalDef::Builtin | GlobalDef::Utility => Ty::Callable,
GlobalDef::GlobalEnum => Ty::Variant,
}
}
fn inference_on_variant_msg(kind: &str) -> String {
format!(
"The {kind} type is being inferred from a Variant value, so it will be typed as Variant."
)
}
fn op_symbol(op: BinOp) -> Option<&'static str> {
Some(match op {
BinOp::Add => "+",
BinOp::Sub => "-",
BinOp::Mul => "*",
BinOp::Div => "/",
BinOp::Mod => "%",
BinOp::Pow => "**",
BinOp::BitAnd => "&",
BinOp::BitOr => "|",
BinOp::BitXor => "^",
BinOp::Shl => "<<",
BinOp::Shr => ">>",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item_tree::item_tree;
use gdscript_syntax::{SyntaxKind, parse};
struct Harness {
result: InferenceResult,
body: Body,
}
fn infer_first_func(src: &str) -> Harness {
let api = gdscript_api::bundled();
let db = gdscript_db::RootDatabase::default();
let root = parse(src).syntax_node();
let tree = item_tree(&root);
let class = ClassScope::new(&db, api, &tree, None);
let func = gdscript_syntax::ast::descendants(&root)
.into_iter()
.find(|n| n.kind() == SyntaxKind::FuncDecl)
.expect("a function");
let body = body::body_of_func(&func);
let return_ty = cst::first_child(&func, |k| k == SyntaxKind::TypeRef)
.map_or(Ty::Variant, |t| resolve::resolve_type_ref(&db, api, &t));
let result = infer(&db, api, &root, &class, &body, return_ty);
Harness { result, body }
}
fn codes(h: &Harness) -> Vec<&str> {
h.result
.diagnostics
.iter()
.map(|d| d.code.as_str())
.collect()
}
fn file_codes(src: &str) -> Vec<String> {
let api = gdscript_api::bundled();
let db = gdscript_db::RootDatabase::default();
let root = parse(src).syntax_node();
let fi = analyze_file(&db, api, &root, FileId(0));
fi.diagnostics.iter().map(|d| d.code.clone()).collect()
}
#[test]
fn integer_division_warns() {
let h = infer_first_func("func f():\n\tvar x = 5 / 2\n");
assert!(codes(&h).contains(&INTEGER_DIVISION));
}
#[test]
fn float_div_does_not_warn() {
let h = infer_first_func("func f():\n\tvar x = 5.0 / 2\n");
assert!(!codes(&h).contains(&INTEGER_DIVISION));
}
#[test]
fn type_mismatch_on_hard_annotation() {
let h = infer_first_func("func f():\n\tvar s: String = 5\n");
assert!(codes(&h).contains(&TYPE_MISMATCH));
}
#[test]
fn narrowing_conversion_float_to_int() {
let h = infer_first_func("func f():\n\tvar n: int = 1.5\n");
assert!(codes(&h).contains(&NARROWING_CONVERSION));
}
#[test]
fn int_to_float_is_silent() {
let h = infer_first_func("func f():\n\tvar x: float = 3\n");
assert!(
h.result.diagnostics.is_empty(),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn member_access_resolves_engine_property() {
let h = infer_first_func(
"extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.get_parent()\n",
);
assert!(
codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn unsafe_method_on_known_type() {
let h = infer_first_func(
"extends Node\nfunc f():\n\tvar n := get_node(\"x\")\n\tn.totally_bogus_method()\n",
);
assert!(
codes(&h).contains(&UNSAFE_METHOD_ACCESS),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn is_narrowing_suppresses_unsafe() {
let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.queue_free()\n");
assert!(
codes(&h).iter().all(|c| !c.starts_with("UNSAFE")),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn is_narrowing_flags_real_missing_member() {
let h = infer_first_func("func f(x):\n\tif x is Node:\n\t\tx.bogus_method()\n");
assert!(codes(&h).contains(&UNSAFE_METHOD_ACCESS));
}
#[test]
fn variant_receiver_never_unsafe() {
let h = infer_first_func("func f(x):\n\tx.anything_at_all()\n");
assert!(
h.result.diagnostics.is_empty(),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn unsafe_call_argument_on_variant_into_typed_param() {
let h = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n: Node2D):\n\tpass\n");
assert!(
codes(&h).contains(&UNSAFE_CALL_ARGUMENT),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn unsafe_call_argument_silent_on_safe_and_untyped() {
let upcast =
infer_first_func("func f(n: Node2D):\n\ttake(n)\nfunc take(n: Node):\n\tpass\n");
assert!(
!codes(&upcast).contains(&UNSAFE_CALL_ARGUMENT),
"upcast is safe: {:?}",
upcast.result.diagnostics
);
let untyped = infer_first_func("func f(p):\n\ttake(p)\nfunc take(n):\n\tpass\n");
assert!(
!codes(&untyped).contains(&UNSAFE_CALL_ARGUMENT),
"untyped param accepts anything: {:?}",
untyped.result.diagnostics
);
}
#[test]
fn inference_on_variant() {
let h = infer_first_func("func f(x):\n\tvar y := x\n");
assert!(codes(&h).contains(&INFERENCE_ON_VARIANT));
}
#[test]
fn field_inferred_from_earlier_field_is_typed() {
let codes = file_codes("var a := 1\nvar b := a + 1\n");
assert!(
!codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
"field `b` from earlier field `a` should type as int, not Variant: {codes:?}"
);
}
#[test]
fn field_forward_reference_is_seamed_not_warned() {
let codes = file_codes("var b := a\nvar a := 1\n");
assert!(
!codes.iter().any(|c| c == INFERENCE_ON_VARIANT),
"forward field reference must not false-warn: {codes:?}"
);
}
#[test]
fn standalone_inferred_field_unchanged() {
let codes = file_codes("var n := 0\n");
assert!(
codes.is_empty(),
"a literal-initialised field should produce no diagnostics: {codes:?}"
);
}
#[test]
fn lambda_var_is_callable_not_variant() {
let h = infer_first_func("func f():\n\tvar cb := func():\n\t\tpass\n");
assert!(
!codes(&h).contains(&INFERENCE_ON_VARIANT),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn multiline_lambda_then_paren_line_no_false_warning() {
let src = "func f(state, i, loop):\n\tvar cb := func():\n\t\tif i >= state.size():\n\t\t\treturn\n\t(loop as SceneTree).process_frame.connect(cb, CONNECT_ONE_SHOT)\n";
let h = infer_first_func(src);
assert!(
!codes(&h).contains(&INFERENCE_ON_VARIANT),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn calling_a_callable_value_is_seam_not_variant() {
let src = "func f(cb: Callable):\n\tvar x := (cb)()\n\treturn x\n";
let h = infer_first_func(src);
assert!(
!codes(&h).contains(&INFERENCE_ON_VARIANT),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn ternary_with_seam_branch_does_not_collapse_to_variant() {
let src =
"func f(c: bool):\n\tvar x := 5 if c else await get_tree().process_frame\n\treturn x\n";
let h = infer_first_func(src);
assert!(
!codes(&h).contains(&INFERENCE_ON_VARIANT),
"seam branch should keep the ternary on the seam: {:?}",
h.result.diagnostics
);
}
#[test]
fn await_a_coroutine_call_recovers_its_return_type() {
let src = "func g() -> int:\n\tvar x := await make()\n\treturn x\nfunc make() -> int:\n\treturn 5\n";
let h = infer_first_func(src);
assert!(
!codes(&h).contains(&INFERENCE_ON_VARIANT),
"no false variant warning: {:?}",
h.result.diagnostics
);
let api = gdscript_api::bundled();
let x = &h.result.bindings[0];
assert!(
matches!(&x.ty, Ty::Builtin(b) if api.builtin(*b).name == "int"),
"await make() should recover int, got {:?}",
x.ty
);
}
#[test]
fn await_a_signal_stays_the_seam() {
let src = "func f():\n\tvar x := await get_tree().process_frame\n\treturn x\n";
let h = infer_first_func(src);
assert!(
!codes(&h).contains(&INFERENCE_ON_VARIANT),
"awaiting a signal must not warn: {:?}",
h.result.diagnostics
);
assert!(
matches!(&h.result.bindings[0].ty, Ty::Unknown),
"awaiting a signal stays the seam, got {:?}",
h.result.bindings[0].ty
);
}
#[test]
fn for_var_over_packed_string_array_is_string() {
let h = infer_first_func("func f():\n\tfor s in \"a,b\".split(\",\"):\n\t\tvar x := s\n");
assert!(
!codes(&h).contains(&INFERENCE_ON_VARIANT),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn class_new_is_object_not_variant() {
let h = infer_first_func("func f():\n\tvar s := GDScript.new()\n");
assert!(
!codes(&h).contains(&INFERENCE_ON_VARIANT),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn unknown_seam_never_warns() {
let h = infer_first_func("func f():\n\tvar s := preload(\"res://x.gd\")\n\ts.whatever()\n");
assert!(
h.result.diagnostics.is_empty(),
"{:?}",
h.result.diagnostics
);
}
#[test]
fn expr_types_are_memoized_for_hover() {
let h = infer_first_func("func f():\n\tvar n := 42\n");
let has_int = h
.result
.expr_ty
.values()
.any(|t| matches!(t, Ty::Builtin(_)));
assert!(has_int);
assert!(!h.body.exprs.is_empty());
}
}