From 959957b3bc3120d8e636e38ae6d7c7327bbad969 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 31 May 2026 22:58:23 +0100 Subject: [PATCH 1/5] refactor: merged table field inference Collect all fields of a global table, then merge them after. The language server is not yet able to tell load order, so this is a compromise to try make it work well without knowing when things are overwritten. --- .../src/db_index/member/lua_member_item.rs | 9 +- .../src/db_index/type/humanize_type.rs | 74 ++++++- .../src/db_index/type/mod.rs | 81 +++++++- .../src/db_index/type/type_ops/remove_type.rs | 2 + .../src/db_index/type/types.rs | 79 +++++-- .../semantic/generic/instantiate_type/mod.rs | 19 +- .../src/semantic/generic/test.rs | 34 ++- .../src/semantic/infer/infer_index/mod.rs | 194 +++++++++++++++++- .../semantic/infer/narrow/narrow_type/mod.rs | 6 + .../src/semantic/member/find_index.rs | 28 ++- .../src/semantic/member/find_members.rs | 116 ++++++++++- .../src/semantic/member/infer_raw_member.rs | 35 +++- .../src/semantic/member/mod.rs | 86 ++++++++ crates/glua_code_analysis/src/semantic/mod.rs | 4 +- .../semantic_info/infer_expr_semantic_decl.rs | 36 +++- .../semantic/type_check/complex_type/mod.rs | 32 ++- .../src/semantic/type_check/mod.rs | 1 + .../src/semantic/type_check/simple_type.rs | 1 + 18 files changed, 791 insertions(+), 46 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/member/lua_member_item.rs b/crates/glua_code_analysis/src/db_index/member/lua_member_item.rs index dfc260855..47803bc8c 100644 --- a/crates/glua_code_analysis/src/db_index/member/lua_member_item.rs +++ b/crates/glua_code_analysis/src/db_index/member/lua_member_item.rs @@ -1038,7 +1038,14 @@ fn widen_file_define_member_type(typ: &LuaType, widen_table_literals: bool) -> L } fn is_table_assignment_merge_type(typ: &LuaType) -> bool { - matches!(typ, LuaType::Table | LuaType::TableConst(_)) + matches!( + typ, + LuaType::Table + | LuaType::TableConst(_) + | LuaType::Object(_) + | LuaType::MergedTable(_) + | LuaType::TableOf(_) + ) } fn member_item_from_ids(member_ids: Vec) -> LuaMemberIndexItem { diff --git a/crates/glua_code_analysis/src/db_index/type/humanize_type.rs b/crates/glua_code_analysis/src/db_index/type/humanize_type.rs index 93bd24c23..04d93620a 100644 --- a/crates/glua_code_analysis/src/db_index/type/humanize_type.rs +++ b/crates/glua_code_analysis/src/db_index/type/humanize_type.rs @@ -5,8 +5,9 @@ use itertools::Itertools; use crate::{ AsyncState, DbIndex, GenericTpl, LuaAliasCallType, LuaConditionalType, LuaFunctionType, LuaGenericType, LuaInstanceType, LuaIntersectionType, LuaMemberKey, LuaMemberOwner, - LuaObjectType, LuaSignatureId, LuaStringTplType, LuaTupleType, LuaType, LuaTypeDeclId, - LuaUnionType, TypeSubstitutor, VariadicType, + LuaMergedTableType, LuaObjectType, LuaSignatureId, LuaStringTplType, LuaTupleType, LuaType, + LuaTypeDeclId, LuaUnionType, TypeSubstitutor, VariadicType, + semantic::{LuaMemberInfo, find_members}, }; use super::{LuaAliasCallKind, LuaMultiLineUnion}; @@ -113,6 +114,7 @@ pub fn humanize_type(db: &DbIndex, ty: &LuaType, level: RenderLevel) -> String { LuaType::DocFunction(lua_func) => humanize_doc_function_type(db, lua_func, level), LuaType::Object(object) => humanize_object_type(db, object, level), LuaType::Intersection(inter) => humanize_intersect_type(db, inter, level), + LuaType::MergedTable(merged) => humanize_merged_table_type(db, merged, level), LuaType::Generic(generic) => humanize_generic_type(db, generic, level), LuaType::TableGeneric(table_generic_params) => { humanize_table_generic_type(db, table_generic_params, level) @@ -647,6 +649,74 @@ fn humanize_table_const_type( } } +fn humanize_merged_table_type( + db: &DbIndex, + merged: &LuaMergedTableType, + level: RenderLevel, +) -> String { + match level { + RenderLevel::Detailed | RenderLevel::Simple => { + let typ = LuaType::MergedTable(merged.clone().into()); + let Some(members) = find_members(db, &typ) else { + return "table".to_string(); + }; + humanize_member_list_as_table(db, members, level).unwrap_or("table".to_string()) + } + _ => "table".to_string(), + } +} + +fn humanize_member_list_as_table( + db: &DbIndex, + members: Vec, + level: RenderLevel, +) -> Option { + let mut total_length = 0; + let mut total_line = 0; + let mut members_string = String::new(); + for member in members { + let member_string = build_table_member_string( + db, + &member.key, + &member.typ, + humanize_type(db, &member.typ, level.next_level()), + level, + ); + + match level { + RenderLevel::Detailed => { + total_line += 1; + members_string.push_str(&format!(" {},\n", member_string)); + if total_line >= 12 { + members_string.push_str(" ...\n"); + break; + } + } + RenderLevel::Simple => { + let member_string_len = member_string.chars().count(); + if total_length != 0 { + members_string.push_str(", "); + total_length += 2; + } + + total_length += member_string_len; + members_string.push_str(&member_string); + if total_length > 54 { + members_string.push_str(", ..."); + break; + } + } + _ => return None, + } + } + + match level { + RenderLevel::Detailed => Some(format!("{{\n{}}}", members_string)), + RenderLevel::Simple => Some(format!("{{ {} }}", members_string)), + _ => None, + } +} + fn humanize_table_generic_type( db: &DbIndex, table_generic_params: &[LuaType], diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index 01bc55c8d..f8ee31c7f 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -56,6 +56,19 @@ fn replace_table_const_in_type( .collect(); changed.then(|| LuaType::from_vec(new_types)) } + LuaType::MergedTable(merged) => { + let mut changed = false; + let new_types: Vec = merged + .get_types() + .iter() + .map(|sub_type| { + replace_table_const_in_type(sub_type, table_range, replacement) + .inspect(|_| changed = true) + .unwrap_or_else(|| sub_type.clone()) + }) + .collect(); + changed.then(|| LuaMergedTableType::new(new_types).into()) + } _ => None, } } @@ -97,7 +110,7 @@ pub(crate) fn prune_redundant_guarded_table_bootstrap_type(db: &DbIndex, typ: Lu return collapse_guarded_table_bootstrap_branches(db, types); } - LuaType::from_vec( + merge_guarded_table_bootstrap_result( types .into_iter() .filter(|typ| !is_guarded_table_bootstrap_branch(db, typ)) @@ -121,7 +134,63 @@ fn collapse_guarded_table_bootstrap_branches(db: &DbIndex, types: Vec) retained.push(LuaType::Table); } - LuaType::from_vec(retained) + merge_guarded_table_bootstrap_result(retained) +} + +fn merge_guarded_table_bootstrap_result(types: Vec) -> LuaType { + let mut table_components = Vec::new(); + let mut other_components = Vec::new(); + + for typ in types { + collect_guarded_table_merge_components(typ, &mut table_components, &mut other_components); + } + + if table_components + .iter() + .any(|component| !matches!(component, LuaType::Table)) + { + table_components.retain(|component| !matches!(component, LuaType::Table)); + } + + let merged_table = match table_components.len() { + 0 => None, + 1 => Some(table_components.remove(0)), + _ => Some(LuaMergedTableType::new(table_components).into()), + }; + + if other_components.is_empty() { + return merged_table.unwrap_or(LuaType::Nil); + } + + if let Some(merged_table) = merged_table { + other_components.push(merged_table); + } + + LuaType::from_vec(other_components) +} + +fn collect_guarded_table_merge_components( + typ: LuaType, + table_components: &mut Vec, + other_components: &mut Vec, +) { + match typ { + LuaType::MergedTable(merged) => { + for component in merged.get_types() { + collect_guarded_table_merge_components( + component.clone(), + table_components, + other_components, + ); + } + } + LuaType::Table + | LuaType::TableConst(_) + | LuaType::Object(_) + | LuaType::TableGeneric(_) + | LuaType::TableOf(_) => table_components.push(typ), + _ => other_components.push(typ), + } } fn is_informative_guarded_table_branch(db: &DbIndex, typ: &LuaType) -> bool { @@ -134,6 +203,10 @@ fn is_informative_guarded_table_branch(db: &DbIndex, typ: &LuaType) -> bool { LuaType::Object(object) => { !object.get_fields().is_empty() || !object.get_index_access().is_empty() } + LuaType::MergedTable(merged) => merged + .get_types() + .iter() + .any(|typ| is_informative_guarded_table_branch(db, typ)), _ => false, } } @@ -146,6 +219,10 @@ fn is_guarded_table_bootstrap_branch(db: &DbIndex, typ: &LuaType) -> bool { .get_member_len(&LuaMemberOwner::Element(table_id.clone())) == 0 } + LuaType::MergedTable(merged) => merged + .get_types() + .iter() + .all(|typ| is_guarded_table_bootstrap_branch(db, typ)), _ => false, } } diff --git a/crates/glua_code_analysis/src/db_index/type/type_ops/remove_type.rs b/crates/glua_code_analysis/src/db_index/type/type_ops/remove_type.rs index a9c4d881b..9e1058e9e 100644 --- a/crates/glua_code_analysis/src/db_index/type/type_ops/remove_type.rs +++ b/crates/glua_code_analysis/src/db_index/type/type_ops/remove_type.rs @@ -66,7 +66,9 @@ pub fn remove_type(db: &DbIndex, source: LuaType, removed_type: LuaType) -> Opti | LuaType::Array(_) | LuaType::Tuple(_) | LuaType::Generic(_) + | LuaType::MergedTable(_) | LuaType::Object(_) + | LuaType::TableOf(_) | LuaType::TableGeneric(_) => return None, LuaType::Ref(type_decl_id) | LuaType::Def(type_decl_id) => { let type_decl = db.get_type_index().get_type_decl(type_decl_id)?; diff --git a/crates/glua_code_analysis/src/db_index/type/types.rs b/crates/glua_code_analysis/src/db_index/type/types.rs index b4dd04d4c..6ba8e5b1f 100644 --- a/crates/glua_code_analysis/src/db_index/type/types.rs +++ b/crates/glua_code_analysis/src/db_index/type/types.rs @@ -46,6 +46,7 @@ pub enum LuaType { Object(Arc), Union(Arc), Intersection(Arc), + MergedTable(Arc), Generic(Arc), TableGeneric(Arc>), TplRef(Arc), @@ -104,6 +105,7 @@ impl PartialEq for LuaType { (LuaType::Object(a), LuaType::Object(b)) => a == b, (LuaType::Union(a), LuaType::Union(b)) => a == b, (LuaType::Intersection(a), LuaType::Intersection(b)) => a == b, + (LuaType::MergedTable(a), LuaType::MergedTable(b)) => a == b, (LuaType::Generic(a), LuaType::Generic(b)) => a == b, (LuaType::TableGeneric(a), LuaType::TableGeneric(b)) => a == b, (LuaType::TplRef(a), LuaType::TplRef(b)) => a == b, @@ -173,6 +175,7 @@ impl Hash for LuaType { let ptr = Arc::as_ptr(a); (29, ptr).hash(state) } + LuaType::MergedTable(a) => (54, a).hash(state), LuaType::Generic(a) => { let ptr = Arc::as_ptr(a); (30, ptr).hash(state) @@ -249,6 +252,7 @@ impl LuaType { LuaType::Table | LuaType::TableGeneric(_) | LuaType::TableConst(_) + | LuaType::MergedTable(_) | LuaType::Global | LuaType::Tuple(_) | LuaType::Array(_) @@ -440,6 +444,7 @@ impl LuaType { LuaType::Object(base) => base.contain_tpl(), LuaType::Union(base) => base.contain_tpl(), LuaType::Intersection(base) => base.contain_tpl(), + LuaType::MergedTable(base) => base.contain_tpl(), LuaType::Generic(base) => base.contain_tpl(), LuaType::Variadic(multi) => multi.contain_tpl(), LuaType::TableGeneric(params) => params.iter().any(|p| p.contain_tpl()), @@ -532,6 +537,7 @@ impl TypeVisitTrait for LuaType { LuaType::Object(base) => base.visit_type(f), LuaType::Union(base) => base.visit_type(f), LuaType::Intersection(base) => base.visit_type(f), + LuaType::MergedTable(base) => base.visit_type(f), LuaType::Generic(base) => base.visit_type(f), LuaType::Variadic(multi) => multi.visit_type(f), LuaType::TableGeneric(params) => { @@ -1099,6 +1105,42 @@ impl From for LuaType { } } +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct LuaMergedTableType { + types: Vec, +} + +impl TypeVisitTrait for LuaMergedTableType { + fn visit_type(&self, f: &mut F) + where + F: FnMut(&LuaType), + { + for ty in &self.types { + ty.visit_type(f); + } + } +} + +impl LuaMergedTableType { + pub fn new(types: Vec) -> Self { + Self { types } + } + + pub fn get_types(&self) -> &[LuaType] { + &self.types + } + + pub fn contain_tpl(&self) -> bool { + self.types.iter().any(|t| t.contain_tpl()) + } +} + +impl From for LuaType { + fn from(t: LuaMergedTableType) -> Self { + LuaType::MergedTable(t.into()) + } +} + #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub enum LuaAliasCallKind { KeyOf, @@ -1705,24 +1747,25 @@ fn lua_type_sort_key(ty: &LuaType) -> (u8, u64) { LuaType::Object(_) => 29, LuaType::Union(_) => 30, LuaType::Intersection(_) => 31, - LuaType::Generic(_) => 32, - LuaType::TableGeneric(_) => 33, - LuaType::TplRef(_) => 34, - LuaType::StrTplRef(_) => 35, - LuaType::Variadic(_) => 36, - LuaType::Signature(_) => 37, - LuaType::Instance(_) => 38, - LuaType::Namespace(_) => 39, - LuaType::Call(_) => 40, - LuaType::MultiLineUnion(_) => 41, - LuaType::TypeGuard(_) => 42, - LuaType::ConstTplRef(_) => 43, - LuaType::Language(_) => 44, - LuaType::ModuleRef(_) => 45, - LuaType::DocAttribute(_) => 46, - LuaType::Conditional(_) => 47, - LuaType::ConditionalInfer(_) => 48, - LuaType::Mapped(_) => 49, + LuaType::MergedTable(_) => 32, + LuaType::Generic(_) => 33, + LuaType::TableGeneric(_) => 34, + LuaType::TplRef(_) => 35, + LuaType::StrTplRef(_) => 36, + LuaType::Variadic(_) => 37, + LuaType::Signature(_) => 38, + LuaType::Instance(_) => 39, + LuaType::Namespace(_) => 40, + LuaType::Call(_) => 41, + LuaType::MultiLineUnion(_) => 42, + LuaType::TypeGuard(_) => 43, + LuaType::ConstTplRef(_) => 44, + LuaType::Language(_) => 45, + LuaType::ModuleRef(_) => 46, + LuaType::DocAttribute(_) => 47, + LuaType::Conditional(_) => 48, + LuaType::ConditionalInfer(_) => 49, + LuaType::Mapped(_) => 50, }; // For same-variant tiebreaking, use a deterministic identity value. diff --git a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/mod.rs b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/mod.rs index f4fd73f58..3bba0d71e 100644 --- a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/mod.rs +++ b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/mod.rs @@ -11,8 +11,8 @@ use crate::{ LuaInstanceType, LuaMappedType, LuaMemberKey, LuaOperatorMetaMethod, LuaSignatureId, LuaTupleStatus, LuaTypeDeclId, TypeOps, check_type_compact, db_index::{ - LuaFunctionType, LuaGenericType, LuaIntersectionType, LuaObjectType, LuaTupleType, LuaType, - LuaUnionType, VariadicType, + LuaFunctionType, LuaGenericType, LuaIntersectionType, LuaMergedTableType, LuaObjectType, + LuaTupleType, LuaType, LuaUnionType, VariadicType, }, semantic::type_check::{TypeCheckCheckLevel, check_type_compact_with_level}, }; @@ -38,6 +38,7 @@ pub fn instantiate_type_generic( LuaType::Intersection(intersection) => { instantiate_intersection(db, intersection, substitutor) } + LuaType::MergedTable(merged) => instantiate_merged_table(db, merged, substitutor), LuaType::Generic(generic) => instantiate_generic(db, generic, substitutor), LuaType::TableGeneric(table_params) => { instantiate_table_generic(db, table_params, substitutor) @@ -71,6 +72,20 @@ pub fn instantiate_type_generic( } } +fn instantiate_merged_table( + db: &DbIndex, + merged: &LuaMergedTableType, + substitutor: &TypeSubstitutor, +) -> LuaType { + let types = merged + .get_types() + .iter() + .map(|typ| instantiate_type_generic(db, typ, substitutor)) + .collect(); + + LuaMergedTableType::new(types).into() +} + fn instantiate_array(db: &DbIndex, base: &LuaType, substitutor: &TypeSubstitutor) -> LuaType { let base = instantiate_type_generic(db, base, substitutor); LuaType::Array(LuaArrayType::from_base_type(base).into()) diff --git a/crates/glua_code_analysis/src/semantic/generic/test.rs b/crates/glua_code_analysis/src/semantic/generic/test.rs index 45f9eefb3..30c78dd30 100644 --- a/crates/glua_code_analysis/src/semantic/generic/test.rs +++ b/crates/glua_code_analysis/src/semantic/generic/test.rs @@ -1,6 +1,12 @@ #[cfg(test)] mod test { - use crate::{DiagnosticCode, LuaType, VirtualWorkspace}; + use internment::ArcIntern; + use smol_str::SmolStr; + + use crate::{ + DiagnosticCode, GenericTpl, GenericTplId, LuaMergedTableType, LuaType, TypeSubstitutor, + VirtualWorkspace, instantiate_type_generic, + }; #[test] fn test_variadic_func() { @@ -30,6 +36,32 @@ mod test { assert_eq!(ty, expected); } + #[test] + fn test_merged_table_instantiates_generic_components() { + let ws = crate::VirtualWorkspace::new(); + let mut substitutor = TypeSubstitutor::new(); + substitutor.insert_type(GenericTplId::Type(0), LuaType::String, true); + + let generic_type = LuaType::TplRef( + GenericTpl::new( + GenericTplId::Type(0), + ArcIntern::new(SmolStr::new("T")), + None, + ) + .into(), + ); + let merged_type = LuaMergedTableType::new(vec![generic_type, LuaType::Table]).into(); + + let instantiated = + instantiate_type_generic(ws.analysis.compilation.get_db(), &merged_type, &substitutor); + + let LuaType::MergedTable(merged) = instantiated else { + panic!("expected merged table after generic instantiation"); + }; + assert_eq!(merged.get_types()[0], LuaType::String); + assert_eq!(merged.get_types()[1], LuaType::Table); + } + #[test] fn test_select_type() { let mut ws = crate::VirtualWorkspace::new_with_init_std_lib(); diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs b/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs index f9ce7242e..b57fb933d 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs @@ -16,8 +16,8 @@ use crate::{ LuaDeclOrMemberId, LuaInferCache, LuaInstanceType, LuaMemberOwner, LuaOperatorOwner, TypeOps, compilation::{get_scripted_class_info_for_file, get_scripted_class_type_decl_id}, db_index::{ - DbIndex, LuaGenericType, LuaIntersectionType, LuaMemberKey, LuaObjectType, - LuaOperatorMetaMethod, LuaTupleType, LuaType, LuaTypeDeclId, LuaUnionType, + DbIndex, LuaGenericType, LuaIntersectionType, LuaMemberKey, LuaMergedTableType, + LuaObjectType, LuaOperatorMetaMethod, LuaTupleType, LuaType, LuaTypeDeclId, LuaUnionType, }, enum_variable_is_param, get_keyof_members, get_tpl_ref_extend_type, semantic::{ @@ -32,6 +32,7 @@ use crate::{ member::infer_owner_raw_member_type_with_realm, member::intersect_member_types, member::member_key_matches_type, + member::merge_open_table_types, member::resolve_dynamic_field_member, type_check::{self, check_type_compact}, }, @@ -275,6 +276,9 @@ pub fn infer_member_by_member_key( LuaType::Union(union_type) => { infer_union_member(db, cache, union_type, index_expr, infer_guard) } + LuaType::MergedTable(merged_table) => { + infer_merged_table_member(db, cache, merged_table, index_expr, infer_guard) + } LuaType::MultiLineUnion(multi_union) => { let union_type = multi_union.to_union(); if let LuaType::Union(union_type) = union_type { @@ -400,7 +404,11 @@ fn infer_table_member( if is_dynamic_expr_key_without_table_data(db, &owner, &inst, &key) { return Ok(nullable_any_type()); } - if is_table_const_from_doc_tag(db, &inst) { + if let Ok(global_path_type) = + infer_global_path_member(db, cache, index_expr.clone(), Some(key.clone())) + { + Ok(global_path_type) + } else if is_table_const_from_doc_tag(db, &inst) { Ok(nullable_any_type()) } else { Err(InferFailReason::FieldNotFound) @@ -646,15 +654,17 @@ fn infer_plain_table_member( return Ok(member_type); } - let nullable_any = nullable_any_type(); + if let Ok(global_path_type) = infer_global_path_member(db, cache, index_expr.clone(), None) { + return Ok(global_path_type); + } let index_prefix_expr = match index_expr.clone() { - LuaIndexMemberExpr::TableField(_) => return Ok(nullable_any), + LuaIndexMemberExpr::TableField(_) => return Ok(nullable_any_type()), _ => index_expr.get_prefix_expr().ok_or(InferFailReason::None)?, }; let Some(index_key) = index_expr.get_index_key() else { - return Ok(nullable_any); + return Ok(nullable_any_type()); }; if let LuaIndexKey::Expr(expr) = index_key @@ -663,7 +673,7 @@ fn infer_plain_table_member( return Ok(LuaType::Any); } - Ok(nullable_any) + Ok(nullable_any_type()) } fn infer_gmod_plain_table_dynamic_field( @@ -1312,6 +1322,43 @@ fn infer_union_member( Ok(LuaType::from_vec(member_types)) } +fn infer_merged_table_member( + db: &DbIndex, + cache: &mut LuaInferCache, + merged_table: &LuaMergedTableType, + index_expr: LuaIndexMemberExpr, + infer_guard: &InferGuardRef, +) -> InferResult { + let mut member_types = Vec::new(); + let mut last_resolve_reason = InferFailReason::FieldNotFound; + + for component in merged_table.get_types() { + match infer_member_by_member_key( + db, + cache, + component, + index_expr.clone(), + &infer_guard.fork(), + ) { + Ok(typ) if !typ.is_never() => member_types.push(typ), + Ok(_) => {} + Err(InferFailReason::FieldNotFound | InferFailReason::None) => {} + Err(reason) if reason.is_need_resolve() => last_resolve_reason = reason, + Err(reason) => return Err(reason), + } + } + + if member_types.is_empty() { + if let Ok(global_path_type) = infer_global_path_member(db, cache, index_expr.clone(), None) + { + return Ok(global_path_type); + } + return Err(last_resolve_reason); + } + + Ok(merge_open_table_types(db, member_types)) +} + fn infer_intersection_member( db: &DbIndex, cache: &mut LuaInferCache, @@ -1472,6 +1519,9 @@ pub fn infer_member_by_operator( infer_member_by_index_array(db, cache, array_type.get_base(), index_expr) } LuaType::Object(object) => infer_member_by_index_object(db, cache, object, index_expr), + LuaType::MergedTable(merged_table) => { + infer_member_by_index_merged_table(db, cache, merged_table, index_expr, infer_guard) + } LuaType::Union(union) => { infer_member_by_index_union(db, cache, union, index_expr, infer_guard) } @@ -1510,6 +1560,136 @@ pub fn infer_member_by_operator( } } +fn infer_member_by_index_merged_table( + db: &DbIndex, + cache: &mut LuaInferCache, + merged_table: &LuaMergedTableType, + index_expr: LuaIndexMemberExpr, + infer_guard: &InferGuardRef, +) -> InferResult { + let mut member_types = Vec::new(); + let mut last_resolve_reason = InferFailReason::FieldNotFound; + + for component in merged_table.get_types() { + match infer_member_by_operator( + db, + cache, + component, + index_expr.clone(), + &infer_guard.fork(), + ) { + Ok(typ) if !typ.is_never() => member_types.push(typ), + Ok(_) => {} + Err(InferFailReason::FieldNotFound | InferFailReason::None) => {} + Err(reason) if reason.is_need_resolve() => last_resolve_reason = reason, + Err(reason) => return Err(reason), + } + } + + if member_types.is_empty() { + if let Ok(global_path_type) = infer_global_path_member(db, cache, index_expr.clone(), None) + { + return Ok(global_path_type); + } + return Err(last_resolve_reason); + } + + Ok(merge_open_table_types(db, member_types)) +} + +fn infer_global_path_member( + db: &DbIndex, + cache: &mut LuaInferCache, + index_expr: LuaIndexMemberExpr, + resolved_key: Option, +) -> InferResult { + let Some(prefix_expr) = index_expr.get_prefix_expr() else { + return Err(InferFailReason::FieldNotFound); + }; + let Some(prefix_path) = global_expr_access_path(db, cache.get_file_id(), &prefix_expr) else { + return Err(InferFailReason::FieldNotFound); + }; + + let member_key = if let Some(resolved_key) = resolved_key { + resolved_key + } else { + let index_key = index_expr.get_index_key().ok_or(InferFailReason::None)?; + LuaMemberKey::from_index_key(db, cache, &index_key)? + }; + + let owner = LuaMemberOwner::GlobalPath(GlobalId::new(&prefix_path)); + let Some(member_item) = db.get_member_index().get_member_item(&owner, &member_key) else { + return Err(InferFailReason::FieldNotFound); + }; + + let access_position = index_expr.get_position(); + let resolved = + member_item.resolve_type_with_realm_at_offset(db, &cache.get_file_id(), access_position); + let decl_backed_type = resolve_decl_backed_global_path_member_type( + db, + member_item, + &cache.get_file_id(), + member_key.clone(), + Some(access_position), + ); + + if let Some(module_decl_type) = decl_backed_type.clone() + && resolved + .as_ref() + .is_ok_and(|resolved_type| resolved_type.is_table()) + { + return Ok(module_decl_type); + } + if resolved.is_ok() { + return resolved; + } + if let Some(module_decl_type) = decl_backed_type { + return Ok(module_decl_type); + } + + resolved +} + +fn global_expr_access_path(db: &DbIndex, file_id: FileId, expr: &LuaExpr) -> Option { + if !expr_root_is_global(db, file_id, expr) { + return None; + } + + match expr { + LuaExpr::NameExpr(name_expr) => name_expr.get_access_path(), + LuaExpr::IndexExpr(index_expr) => index_expr.get_access_path(), + _ => None, + } +} + +fn expr_root_is_global(db: &DbIndex, file_id: FileId, expr: &LuaExpr) -> bool { + let Some(root_name) = expr_root_name(expr) else { + return false; + }; + + let Some(decl_id) = db + .get_reference_index() + .get_var_reference_decl(&file_id, root_name.get_range()) + else { + return true; + }; + + db.get_decl_index() + .get_decl(&decl_id) + .is_some_and(|decl| decl.is_global() || decl.is_module_scoped()) +} + +fn expr_root_name(expr: &LuaExpr) -> Option { + match expr { + LuaExpr::NameExpr(name_expr) => Some(name_expr.clone()), + LuaExpr::IndexExpr(index_expr) => { + let prefix_expr = index_expr.get_prefix_expr()?; + expr_root_name(&prefix_expr) + } + _ => None, + } +} + fn infer_member_by_index_table( db: &DbIndex, cache: &mut LuaInferCache, diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/narrow_type/mod.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/narrow_type/mod.rs index d6d480a60..d18537252 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/narrow_type/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/narrow_type/mod.rs @@ -83,6 +83,9 @@ pub fn narrow_down_type( LuaType::Object(_) => { return Some(source); } + LuaType::MergedTable(_) => { + return Some(source); + } LuaType::Table | LuaType::Userdata | LuaType::Any | LuaType::Unknown => { return Some(LuaType::Table); } @@ -179,6 +182,9 @@ pub fn narrow_down_type( LuaType::TableConst(s) => { return Some(LuaType::TableConst(s.clone())); } + LuaType::MergedTable(_) => { + return Some(source); + } LuaType::Table | LuaType::Userdata | LuaType::Any | LuaType::Unknown => { return Some(LuaType::TableConst(t.clone())); } diff --git a/crates/glua_code_analysis/src/semantic/member/find_index.rs b/crates/glua_code_analysis/src/semantic/member/find_index.rs index f5917573f..6fa856ece 100644 --- a/crates/glua_code_analysis/src/semantic/member/find_index.rs +++ b/crates/glua_code_analysis/src/semantic/member/find_index.rs @@ -2,8 +2,8 @@ use std::collections::{HashMap, HashSet}; use crate::{ DbIndex, InFiled, InferGuardRef, LuaGenericType, LuaIntersectionType, LuaMemberKey, - LuaMemberOwner, LuaObjectType, LuaOperatorMetaMethod, LuaOperatorOwner, LuaSemanticDeclId, - LuaType, LuaTypeDeclId, LuaUnionType, TypeOps, + LuaMemberOwner, LuaMergedTableType, LuaObjectType, LuaOperatorMetaMethod, LuaOperatorOwner, + LuaSemanticDeclId, LuaType, LuaTypeDeclId, LuaUnionType, TypeOps, semantic::{ InferGuard, generic::{TypeSubstitutor, instantiate_type_generic}, @@ -29,6 +29,9 @@ pub fn find_index_operations_guard( LuaType::Array(array_type) => find_index_array(db, array_type.get_base()), LuaType::Object(object) => find_index_object(db, object), LuaType::Union(union) => find_index_union(db, union, infer_guard), + LuaType::MergedTable(merged_table) => { + find_index_merged_table(db, merged_table, infer_guard) + } LuaType::Intersection(intersection) => { find_index_intersection(db, intersection, infer_guard) } @@ -38,6 +41,7 @@ pub fn find_index_operations_guard( let base = inst.get_base(); find_index_operations_guard(db, base, infer_guard) } + LuaType::TableOf(inner) => find_index_operations_guard(db, inner, infer_guard), LuaType::ModuleRef(file_id) => { let module_info = db.get_module_index().get_module(*file_id); if let Some(module_info) = module_info @@ -240,6 +244,26 @@ fn find_index_union( } } +fn find_index_merged_table( + db: &DbIndex, + merged_table: &LuaMergedTableType, + infer_guard: &InferGuardRef, +) -> FindMembersResult { + let mut members = Vec::new(); + + for component in merged_table.get_types() { + if let Some(component_members) = find_index_operations_guard(db, component, infer_guard) { + members.extend(component_members); + } + } + + if members.is_empty() { + None + } else { + Some(members) + } +} + fn find_index_intersection( db: &DbIndex, intersection: &LuaIntersectionType, diff --git a/crates/glua_code_analysis/src/semantic/member/find_members.rs b/crates/glua_code_analysis/src/semantic/member/find_members.rs index c3507e0cb..26be1d1eb 100644 --- a/crates/glua_code_analysis/src/semantic/member/find_members.rs +++ b/crates/glua_code_analysis/src/semantic/member/find_members.rs @@ -5,8 +5,8 @@ use smol_str::SmolStr; use crate::{ DbIndex, FileId, GlobalId, InferGuardRef, LuaGenericType, LuaInstanceType, LuaIntersectionType, - LuaMemberKey, LuaMemberOwner, LuaObjectType, LuaSemanticDeclId, LuaTupleType, LuaType, - LuaTypeDeclId, LuaUnionType, WorkspaceId, + LuaMemberKey, LuaMemberOwner, LuaMergedTableType, LuaObjectType, LuaSemanticDeclId, + LuaTupleType, LuaType, LuaTypeDeclId, LuaUnionType, WorkspaceId, semantic::{ InferGuard, generic::{TypeSubstitutor, instantiate_type_generic}, @@ -15,7 +15,7 @@ use crate::{ use super::{ FindMembersResult, LuaMemberInfo, get_buildin_type_map_type_id, intersect_member_types, - resolve_dynamic_field_member_for_file, + merge_open_table_types, resolve_dynamic_field_member_for_file, }; #[derive(Debug, Clone)] @@ -263,6 +263,9 @@ fn find_members_guard( LuaType::Intersection(intersection_type) => { find_intersection_members(db, intersection_type, ctx, filter) } + LuaType::MergedTable(merged_table) => { + find_merged_table_members(db, merged_table, ctx, filter) + } LuaType::Generic(generic_type) => find_generic_members(db, generic_type, ctx, filter), LuaType::Global => find_global_members(db, ctx, filter), LuaType::Instance(inst) => find_instance_members(db, inst, ctx, filter), @@ -686,6 +689,62 @@ fn find_intersection_members( } } +fn find_merged_table_members( + db: &DbIndex, + merged_table: &LuaMergedTableType, + ctx: &FindMembersContext, + filter: &FindMemberFilter, +) -> FindMembersResult { + let mut order: Vec = Vec::new(); + let mut members: HashMap = HashMap::new(); + + for typ in merged_table.get_types().iter() { + let instantiated_type = ctx.instantiate_type(db, typ); + let fork_ctx = ctx.fork_infer(); + let Some(sub_members) = find_members_guard(db, &instantiated_type, &fork_ctx, filter) + else { + continue; + }; + + let mut component_seen: HashSet = HashSet::new(); + for member in sub_members { + if !component_seen.insert(member.key.clone()) { + continue; + } + + match members.entry(member.key.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + order.push(member.key.clone()); + entry.insert(member); + } + std::collections::hash_map::Entry::Occupied(mut entry) => { + let merged_type = + merge_open_table_types(db, vec![entry.get().typ.clone(), member.typ]); + entry.get_mut().typ = merged_type; + } + } + } + } + + if members.is_empty() { + None + } else { + let mut result = Vec::new(); + for key in order { + let Some(member) = members.get(&key) else { + continue; + }; + result.push(member.clone()); + + if should_stop_collecting(result.len(), filter) { + break; + } + } + + Some(result) + } +} + fn find_generic_members( db: &DbIndex, generic_type: &LuaGenericType, @@ -830,6 +889,25 @@ fn find_namespace_members( } } + if let Some(global_path_members) = find_owner_members( + db, + ctx, + &LuaMemberOwner::GlobalPath(GlobalId::new(ns)), + filter, + ) { + for member in global_path_members { + if members.iter().any(|existing| existing.key == member.key) { + continue; + } + + members.push(member); + + if should_stop_collecting(members.len(), filter) { + break; + } + } + } + Some(members) } @@ -1225,6 +1303,38 @@ mod tests { assert_eq!(members[0].property_owner_id, Some(shared_member.into())); } + #[test] + fn find_namespace_members_include_direct_global_path_members() { + let mut db = make_db(); + let member_id = make_member_id(FileId::new(20), 1); + let key = LuaMemberKey::Name("Print".into()); + let owner = LuaMemberOwner::GlobalPath(GlobalId::new("marauth.util")); + + db.get_member_index_mut().add_member( + owner, + LuaMember::new( + member_id, + key.clone(), + LuaMemberFeature::FileFieldDecl, + None, + ), + ); + bind_member_type(&mut db, member_id, LuaType::Function); + + let members = find_members_with_key( + &db, + &LuaType::Namespace(smol_str::SmolStr::new("marauth.util").into()), + key.clone(), + false, + ) + .expect("namespace global-path member should resolve"); + + assert_eq!(members.len(), 1); + assert_eq!(members[0].key, key); + assert_eq!(members[0].typ, LuaType::Function); + assert_eq!(members[0].property_owner_id, Some(member_id.into())); + } + #[test] fn find_members_with_key_is_stable_for_reversed_same_key_insertions() { let mut db_forward = make_db(); diff --git a/crates/glua_code_analysis/src/semantic/member/infer_raw_member.rs b/crates/glua_code_analysis/src/semantic/member/infer_raw_member.rs index d603e27f3..e78f85c65 100644 --- a/crates/glua_code_analysis/src/semantic/member/infer_raw_member.rs +++ b/crates/glua_code_analysis/src/semantic/member/infer_raw_member.rs @@ -5,14 +5,14 @@ use smol_str::SmolStr; use crate::{ DbIndex, FileId, GlobalId, InferFailReason, InferGuard, InferGuardRef, LuaGenericType, - LuaMemberIndexItem, LuaMemberKey, LuaMemberOwner, LuaObjectType, LuaTupleType, LuaType, - LuaTypeDeclId, LuaUnionType, TypeOps, check_type_compact, + LuaMemberIndexItem, LuaMemberKey, LuaMemberOwner, LuaMergedTableType, LuaObjectType, + LuaTupleType, LuaType, LuaTypeDeclId, LuaUnionType, TypeOps, check_type_compact, semantic::generic::{TypeSubstitutor, instantiate_type_generic}, }; use super::{ RawGetMemberTypeResult, get_buildin_type_map_type_id, member_key_as_type, - member_key_matches_type, + member_key_matches_type, merge_open_table_types, }; pub fn infer_raw_member_type( @@ -77,12 +77,41 @@ fn infer_raw_member_type_guard( LuaType::Generic(generic_type) => { infer_generic_raw_member_type(db, generic_type, member_key, infer_guard) } + LuaType::MergedTable(merged_table) => { + infer_merged_table_raw_member_type(db, merged_table, member_key, infer_guard) + } LuaType::TableOf(inner) => infer_raw_member_type_guard(db, inner, member_key, infer_guard), // other do not support now _ => Err(InferFailReason::None), } } +fn infer_merged_table_raw_member_type( + db: &DbIndex, + merged_table: &LuaMergedTableType, + member_key: &LuaMemberKey, + infer_guard: &InferGuardRef, +) -> RawGetMemberTypeResult { + let mut member_types = Vec::new(); + let mut last_resolve_reason = InferFailReason::FieldNotFound; + + for component in merged_table.get_types() { + match infer_raw_member_type_guard(db, component, member_key, &infer_guard.fork()) { + Ok(typ) if !typ.is_never() => member_types.push(typ), + Ok(_) => {} + Err(InferFailReason::FieldNotFound | InferFailReason::None) => {} + Err(reason) if reason.is_need_resolve() => last_resolve_reason = reason, + Err(reason) => return Err(reason), + } + } + + if member_types.is_empty() { + return Err(last_resolve_reason); + } + + Ok(merge_open_table_types(db, member_types)) +} + fn infer_owner_raw_member_type( db: &DbIndex, member_owner: LuaMemberOwner, diff --git a/crates/glua_code_analysis/src/semantic/member/mod.rs b/crates/glua_code_analysis/src/semantic/member/mod.rs index 48f007582..142bdfde0 100644 --- a/crates/glua_code_analysis/src/semantic/member/mod.rs +++ b/crates/glua_code_analysis/src/semantic/member/mod.rs @@ -63,6 +63,92 @@ pub(crate) fn intersect_member_types(db: &DbIndex, left: LuaType, right: LuaType } } +pub(crate) fn merge_open_table_types(db: &DbIndex, types: Vec) -> LuaType { + let mut table_components = Vec::new(); + let mut other_types = Vec::new(); + let mut seen = HashSet::new(); + + for typ in types { + if typ.is_never() { + continue; + } + + if let LuaType::Union(union) = &typ { + let mut nested_components = Vec::new(); + let mut all_components_are_tables = true; + for component in union.into_vec() { + if is_open_table_merge_component(&component) { + nested_components.push(component); + } else { + all_components_are_tables = false; + break; + } + } + + if all_components_are_tables { + for component in nested_components { + if seen.insert(component.clone()) { + table_components.push(component); + } + } + } else if seen.insert(typ.clone()) { + other_types.push(typ); + } + continue; + } + + if let LuaType::MergedTable(merged) = &typ { + for component in merged.get_types() { + if seen.insert(component.clone()) { + table_components.push(component.clone()); + } + } + continue; + } + + if is_open_table_merge_component(&typ) { + if seen.insert(typ.clone()) { + table_components.push(typ); + } + } else if seen.insert(typ.clone()) { + other_types.push(typ); + } + } + + let table_type = merge_open_table_components(table_components); + let mut result = table_type; + for typ in other_types { + result = match result { + Some(existing) => Some(TypeOps::Union.apply(db, &existing, &typ)), + None => Some(typ), + }; + } + + result.unwrap_or(LuaType::Never) +} + +fn merge_open_table_components(mut components: Vec) -> Option { + if components + .iter() + .any(|component| !matches!(component, LuaType::Table)) + { + components.retain(|component| !matches!(component, LuaType::Table)); + } + + match components.as_slice() { + [] => None, + [only] => Some(only.clone()), + _ => Some(crate::LuaMergedTableType::new(components).into()), + } +} + +fn is_open_table_merge_component(typ: &LuaType) -> bool { + matches!( + typ, + LuaType::Table | LuaType::TableConst(_) | LuaType::Object(_) | LuaType::MergedTable(_) + ) +} + #[derive(Debug, Clone)] pub(crate) struct DynamicFieldResolution { pub typ: LuaType, diff --git a/crates/glua_code_analysis/src/semantic/mod.rs b/crates/glua_code_analysis/src/semantic/mod.rs index 1c5f95f5e..d186594a0 100644 --- a/crates/glua_code_analysis/src/semantic/mod.rs +++ b/crates/glua_code_analysis/src/semantic/mod.rs @@ -28,10 +28,12 @@ pub use infer::{infer_table_field_value_should_be, infer_table_should_be}; use lsp_types::Uri; pub use member::LuaMemberInfo; pub use member::find_index_operations; +use member::find_member_origin_owner; +pub(crate) use member::find_members; pub use member::get_member_map; pub(crate) use member::infer_owner_raw_member_type_with_realm; pub(crate) use member::member_key_matches_type; -use member::{find_member_origin_owner, find_members}; +pub(crate) use member::merge_open_table_types; use reference::is_reference_to; use rowan::{NodeOrToken, TextRange}; pub use semantic_info::SemanticInfo; diff --git a/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs b/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs index 264a6e1e9..a9873d5b7 100644 --- a/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs +++ b/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs @@ -5,8 +5,8 @@ use glua_parser::{ use crate::{ DbIndex, GlobalId, LuaDeclId, LuaDeclOrMemberId, LuaInferCache, LuaInstanceType, - LuaIntersectionType, LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaSemanticDeclId, LuaType, - LuaTypeDeclId, LuaUnionType, + LuaIntersectionType, LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaMergedTableType, + LuaSemanticDeclId, LuaType, LuaTypeDeclId, LuaUnionType, semantic::{ infer::{find_self_decl_or_member_id, resolve_scoped_scripted_global_type_decl_id}, member::{get_buildin_type_map_type_id, resolve_dynamic_field_member}, @@ -341,6 +341,14 @@ fn infer_member_semantic_decl_by_member_key( member_access_position, semantic_guard.next_level()?, ), + LuaType::MergedTable(merged_table) => infer_merged_table_member_semantic_info( + db, + cache, + merged_table, + member_key, + member_access_position, + semantic_guard.next_level()?, + ), LuaType::TableOf(inner) => infer_member_semantic_decl_by_member_key( db, cache, @@ -544,6 +552,30 @@ fn infer_intersection_member_semantic_info( None } +fn infer_merged_table_member_semantic_info( + db: &DbIndex, + cache: &mut LuaInferCache, + merged_table: &LuaMergedTableType, + member_key: &LuaMemberKey, + member_access_position: Option, + semantic_guard: SemanticDeclGuard, +) -> Option { + for typ in merged_table.get_types() { + if let Some(property_owner_id) = infer_member_semantic_decl_by_member_key( + db, + cache, + typ, + member_key, + member_access_position, + semantic_guard.next_level()?, + ) { + return Some(property_owner_id); + } + } + + None +} + fn infer_namespace_member_semantic_decl( db: &DbIndex, cache: &mut LuaInferCache, diff --git a/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs b/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs index e9827448b..838ab3ccf 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs @@ -13,8 +13,8 @@ use table_generic_check::check_table_generic_type_compact; use tuple_type_check::check_tuple_type_compact; use crate::{ - LuaType, LuaUnionType, TypeSubstitutor, - semantic::type_check::type_check_context::TypeCheckContext, + LuaObjectType, LuaType, LuaUnionType, TypeSubstitutor, + semantic::{member::find_members, type_check::type_check_context::TypeCheckContext}, }; use super::{ @@ -75,6 +75,12 @@ pub fn check_complex_type_compact( result => return result, } } + LuaType::MergedTable(_) => { + match check_merged_table_type_compact(context, source, compact_type, check_guard) { + Err(TypeCheckFailReason::DonotCheck) => {} + result => return result, + } + } LuaType::TableGeneric(source_generic_param) => { match check_table_generic_type_compact( context, @@ -158,6 +164,28 @@ pub fn check_complex_type_compact( Err(TypeCheckFailReason::TypeNotMatch) } +fn check_merged_table_type_compact( + context: &mut TypeCheckContext, + source: &LuaType, + compact_type: &LuaType, + check_guard: TypeCheckGuard, +) -> TypeCheckResult { + if matches!(compact_type, LuaType::Any | LuaType::Table) { + return Ok(()); + } + + let Some(members) = find_members(context.db, source) else { + return Err(TypeCheckFailReason::DonotCheck); + }; + + let fields = members + .into_iter() + .map(|member| (member.key, member.typ)) + .collect(); + let object = LuaType::Object(LuaObjectType::new_with_fields(fields, Vec::new()).into()); + check_general_type_compact(context, &object, compact_type, check_guard.next_level()?) +} + // too complex fn check_union_type_compact_union( context: &mut TypeCheckContext, diff --git a/crates/glua_code_analysis/src/semantic/type_check/mod.rs b/crates/glua_code_analysis/src/semantic/type_check/mod.rs index 5f9b61f2d..4622a25b4 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/mod.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/mod.rs @@ -233,6 +233,7 @@ fn check_general_type_compact( LuaType::Array(_) | LuaType::Tuple(_) | LuaType::Object(_) + | LuaType::MergedTable(_) | LuaType::Union(_) | LuaType::Intersection(_) | LuaType::TableGeneric(_) diff --git a/crates/glua_code_analysis/src/semantic/type_check/simple_type.rs b/crates/glua_code_analysis/src/semantic/type_check/simple_type.rs index a2ad793a7..889e4de2b 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/simple_type.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/simple_type.rs @@ -36,6 +36,7 @@ pub fn check_simple_type_compact( | LuaType::Object(_) | LuaType::Ref(_) | LuaType::Def(_) + | LuaType::MergedTable(_) | LuaType::TableGeneric(_) | LuaType::Generic(_) | LuaType::Global From a5af332c40e3132af04f300b20e362daa65b1af7 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 31 May 2026 22:58:45 +0100 Subject: [PATCH 2/5] fix: preserve guarded global assignments --- .../src/compilation/analyzer/decl/stats.rs | 40 +- .../src/compilation/analyzer/lua/stats.rs | 103 +++- .../compilation/test/legacy_module_test.rs | 41 +- .../src/compilation/test/member_infer_test.rs | 566 +++++++++++++++++- .../src/semantic/infer/infer_name.rs | 155 +++-- .../semantic/infer/narrow/get_type_at_flow.rs | 49 +- 6 files changed, 872 insertions(+), 82 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/decl/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/decl/stats.rs index 140ecff32..f004f9d50 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/decl/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/decl/stats.rs @@ -97,24 +97,30 @@ pub fn analyze_assign_stat(analyzer: &mut DeclAnalyzer, stat: LuaAssignStat) -> } if let Some(decl) = analyzer.find_decl(name, position) { - let decl_id = decl.get_id(); - analyzer - .db - .get_reference_index_mut() - .add_decl_reference(decl_id, file_id, range, true); - } else { - let decl = LuaDecl::new( - name, - file_id, - range, - LuaDeclExtra::Global { - kind: LuaSyntaxKind::NameExpr.into(), - }, - value_expr_id, - ); - - analyzer.add_decl(decl); + let is_local_decl = !decl.is_global() && !decl.is_module_scoped(); + let is_same_file_global_like = (decl.is_global() || decl.is_module_scoped()) + && decl.get_file_id() == file_id; + if is_local_decl || is_same_file_global_like { + let decl_id = decl.get_id(); + analyzer + .db + .get_reference_index_mut() + .add_decl_reference(decl_id, file_id, range, true); + continue; + } } + + let decl = LuaDecl::new( + name, + file_id, + range, + LuaDeclExtra::Global { + kind: LuaSyntaxKind::NameExpr.into(), + }, + value_expr_id, + ); + + analyzer.add_decl(decl); } LuaVarExpr::IndexExpr(index_expr) => { let index_key = index_expr.get_index_key()?; diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 44306fb1a..9ac06392a 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1,6 +1,6 @@ use crate::{ - CacheEntry, GmodRealm, InFiled, InferFailReason, LuaArrayType, LuaMemberKey, LuaSemanticDeclId, - LuaSignatureId, LuaTypeCache, LuaTypeOwner, TypeOps, + CacheEntry, FileId, GmodRealm, InFiled, InferFailReason, LuaArrayType, LuaMemberKey, + LuaSemanticDeclId, LuaSignatureId, LuaTypeCache, LuaTypeOwner, TypeOps, compilation::{ analyzer::{ common::{add_member, bind_type}, @@ -9,7 +9,7 @@ use crate::{ get_scripted_class_type_decl_id, }, db_index::{LuaDeclId, LuaMember, LuaMemberFeature, LuaMemberId, LuaMemberOwner, LuaType}, - semantic::{member_key_matches_type, remove_false_or_nil}, + semantic::{member_key_matches_type, merge_open_table_types, remove_false_or_nil}, }; use glua_parser::{ BinaryOperator, LuaAssignStat, LuaAstNode, LuaExpr, LuaFuncStat, LuaIndexExpr, LuaIndexKey, @@ -392,6 +392,16 @@ fn get_var_owner(analyzer: &mut LuaAnalyzer, var: LuaVarExpr) -> LuaTypeOwner { let file_id = analyzer.file_id; match var { LuaVarExpr::NameExpr(var_name) => { + let maybe_decl_id = LuaDeclId::new(file_id, var_name.get_position()); + if analyzer + .db + .get_decl_index() + .get_decl(&maybe_decl_id) + .is_some() + { + return LuaTypeOwner::Decl(maybe_decl_id); + } + let decl_id = analyzer .db .get_reference_index() @@ -430,7 +440,8 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt match analyzer.infer_expr(&prefix_expr.clone()) { Ok(prefix_type) => { - let (member_owner, set_owner_only) = resolve_index_expr_member_owner(&prefix_type)?; + let (member_owner, set_owner_only) = + resolve_index_expr_member_owner_for_file(&prefix_type, Some(analyzer.file_id))?; apply_index_expr_member_owner(analyzer, index_expr, member_owner, set_owner_only); } Err(InferFailReason::None) => {} @@ -1107,6 +1118,10 @@ fn assign_merge_type_owner_and_expr_type( expr_type = widened_type; } + if is_global_decl_owner(analyzer, &type_owner) { + expr_type = merge_open_table_types(analyzer.db, vec![expr_type]); + } + bind_type( analyzer.db, type_owner.clone(), @@ -1141,6 +1156,18 @@ fn assign_merge_type_owner_and_expr_type( Some(()) } +fn is_global_decl_owner(analyzer: &LuaAnalyzer, type_owner: &LuaTypeOwner) -> bool { + let LuaTypeOwner::Decl(decl_id) = type_owner else { + return false; + }; + + analyzer + .db + .get_decl_index() + .get_decl(decl_id) + .is_some_and(|decl| decl.is_global()) +} + fn preserve_guarded_table_assignment_members(analyzer: &mut LuaAnalyzer, member_id: LuaMemberId) { let Some(member_ids) = guarded_table_assignment_member_ids_for_owner_key(analyzer, member_id) else { @@ -1425,7 +1452,14 @@ fn widen_related_assignment_type(typ: &LuaType, widen_table_literals: bool) -> L } fn is_table_assignment_merge_type(typ: &LuaType) -> bool { - matches!(typ, LuaType::Table | LuaType::TableConst(_)) + matches!( + typ, + LuaType::Table + | LuaType::TableConst(_) + | LuaType::Object(_) + | LuaType::MergedTable(_) + | LuaType::TableOf(_) + ) } fn prefer_class_assignment_type(typ: &LuaType) -> Option { @@ -1607,10 +1641,17 @@ fn member_key_as_expr_type(member_key: &LuaMemberKey) -> Option<&LuaType> { } fn get_member_owner_for_prefix_type(prefix_type: LuaType) -> Option { - resolve_index_expr_member_owner(&prefix_type).map(|(owner, _)| owner) + resolve_index_expr_member_owner_for_file(&prefix_type, None).map(|(owner, _)| owner) } fn resolve_index_expr_member_owner(prefix_type: &LuaType) -> Option<(LuaMemberOwner, bool)> { + resolve_index_expr_member_owner_for_file(prefix_type, None) +} + +fn resolve_index_expr_member_owner_for_file( + prefix_type: &LuaType, + preferred_file_id: Option, +) -> Option<(LuaMemberOwner, bool)> { match prefix_type { LuaType::TableConst(in_file_range) => { Some((LuaMemberOwner::Element(in_file_range.clone()), false)) @@ -1620,38 +1661,68 @@ fn resolve_index_expr_member_owner(prefix_type: &LuaType) -> Option<(LuaMemberOw LuaType::Instance(instance) => { Some((LuaMemberOwner::Element(instance.get_range().clone()), false)) } - LuaType::TableOf(inner) => resolve_index_expr_member_owner(inner), - LuaType::TypeGuard(inner) => resolve_index_expr_member_owner(inner), - LuaType::Union(union) => pick_preferred_index_expr_member_owner(union.into_vec().iter()), - LuaType::Intersection(intersection) => { - pick_preferred_index_expr_member_owner(intersection.get_types().iter()) + LuaType::TableOf(inner) => { + resolve_index_expr_member_owner_for_file(inner, preferred_file_id) } - LuaType::MultiLineUnion(union) => { - pick_preferred_index_expr_member_owner(union.get_unions().iter().map(|(typ, _)| typ)) + LuaType::TypeGuard(inner) => { + resolve_index_expr_member_owner_for_file(inner, preferred_file_id) + } + LuaType::Union(union) => { + pick_preferred_index_expr_member_owner(union.into_vec().iter(), preferred_file_id) } + LuaType::Intersection(intersection) => pick_preferred_index_expr_member_owner( + intersection.get_types().iter(), + preferred_file_id, + ), + LuaType::MergedTable(merged_table) => pick_preferred_index_expr_member_owner( + merged_table.get_types().iter(), + preferred_file_id, + ), + LuaType::MultiLineUnion(union) => pick_preferred_index_expr_member_owner( + union.get_unions().iter().map(|(typ, _)| typ), + preferred_file_id, + ), _ => None, } } fn pick_preferred_index_expr_member_owner<'a>( types: impl Iterator, + preferred_file_id: Option, ) -> Option<(LuaMemberOwner, bool)> { + let mut exact_type_owner = None; let mut fallback_owner = None; for typ in types { - let Some(owner_info) = resolve_index_expr_member_owner(typ) else { + let Some(owner_info) = resolve_index_expr_member_owner_for_file(typ, preferred_file_id) + else { continue; }; - if matches!(&owner_info.0, LuaMemberOwner::Type(_)) && !owner_info.1 { + if owner_matches_preferred_file(&owner_info.0, preferred_file_id) { return Some(owner_info); } + if matches!(&owner_info.0, LuaMemberOwner::Type(_)) && !owner_info.1 { + if exact_type_owner.is_none() { + exact_type_owner = Some(owner_info); + } + continue; + } + if fallback_owner.is_none() { fallback_owner = Some(owner_info); } } - fallback_owner + exact_type_owner.or(fallback_owner) +} + +fn owner_matches_preferred_file(owner: &LuaMemberOwner, preferred_file_id: Option) -> bool { + let Some(preferred_file_id) = preferred_file_id else { + return false; + }; + + matches!(owner, LuaMemberOwner::Element(range) if range.file_id == preferred_file_id) } fn is_collection_append_write(index_expr: &LuaIndexExpr) -> Option { diff --git a/crates/glua_code_analysis/src/compilation/test/legacy_module_test.rs b/crates/glua_code_analysis/src/compilation/test/legacy_module_test.rs index ee5828d77..5e792d555 100644 --- a/crates/glua_code_analysis/src/compilation/test/legacy_module_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/legacy_module_test.rs @@ -4,7 +4,8 @@ mod test { use crate::humanize_type; use crate::{ - Emmyrc, EmmyrcLuaVersion, LuaSemanticDeclId, LuaType, RenderLevel, VirtualWorkspace, + Emmyrc, EmmyrcLuaVersion, GlobalId, LuaMemberKey, LuaMemberOwner, LuaSemanticDeclId, + LuaType, RenderLevel, VirtualWorkspace, }; fn local_type_by_name( @@ -108,6 +109,44 @@ mod test { assert!(local_type_by_name(&mut ws, class_file, "c").is_function()); } + #[test] + fn legacy_module_same_file_reassignment_reuses_module_decl() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.runtime.version = EmmyrcLuaVersion::Lua51; + ws.update_emmyrc(emmyrc); + let class_file = ws.def_file( + "class.lua", + r#" + module("class", package.seeall) + + Value = "hello" + Value = 123 + local value = Value + "#, + ); + + let value_type = local_type_by_name(&mut ws, class_file, "value"); + assert!( + matches!(value_type, LuaType::Integer | LuaType::IntegerConst(_)), + "same-file module reassignment should update the original module-scoped decl, got {value_type:?}" + ); + + let owner = LuaMemberOwner::GlobalPath(GlobalId::new("class")); + let key = LuaMemberKey::Name("Value".into()); + let member_count = ws + .analysis + .compilation + .get_db() + .get_member_index() + .get_members_for_owner_key(&owner, &key) + .len(); + assert_eq!( + member_count, 1, + "same-file module reassignment should not create duplicate module members" + ); + } + #[test] fn legacy_module_external_member_access_resolves() { let mut ws = VirtualWorkspace::new_with_init_std_lib(); diff --git a/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs b/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs index bacf926b0..981828b6b 100644 --- a/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs @@ -2,11 +2,14 @@ mod test { use glua_parser::{LuaAst, LuaAstNode, LuaAstToken, LuaIndexKey, LuaLocalName, LuaVarExpr}; use googletest::prelude::*; - use lsp_types::NumberOrString; + use lsp_types::{NumberOrString, Uri}; use smol_str::SmolStr; use tokio_util::sync::CancellationToken; - use crate::{DiagnosticCode, Emmyrc, LuaType, LuaUnionType, VirtualWorkspace}; + use crate::{ + DiagnosticCode, Emmyrc, LuaMemberId, LuaMemberOwner, LuaType, LuaUnionType, + VirtualWorkspace, + }; fn file_has_diagnostic( ws: &mut VirtualWorkspace, @@ -75,6 +78,36 @@ mod test { .expect("expected semantic info for index expr") } + fn first_index_expr_member_owner( + ws: &VirtualWorkspace, + file_id: crate::FileId, + expr_text: &str, + ) -> LuaMemberOwner { + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("expected semantic model"); + let index_expr = semantic_model + .get_root() + .descendants::() + .find_map(|node| match node { + LuaAst::LuaIndexExpr(index_expr) if index_expr.syntax().text() == expr_text => { + Some(index_expr) + } + _ => None, + }) + .expect("expected index expr"); + let member_id = LuaMemberId::new(index_expr.get_syntax_id(), file_id); + ws.analysis + .compilation + .get_db() + .get_member_index() + .get_member_owner(&member_id) + .cloned() + .expect("expected member owner") + } + #[test] fn test_issue_318() { let mut ws = VirtualWorkspace::new_with_init_std_lib(); @@ -1340,4 +1373,533 @@ Editor:MissingMethod() "Editor in file_b should have the same concrete type as Editor in file_a" ); } + + #[gtest] + fn test_global_self_assignment_preserves_existing_table_shape() { + let mut ws = VirtualWorkspace::new(); + ws.def_file( + "marauth/gamemode/src/boot/boot.lua", + r#" +marauth = {} +marauth.util = {} +"#, + ); + let file_id = ws.def_file( + "marauth-hl2rp/gamemode/sh_init.lua", + r#" +marauth = marauth + +local observed = marauth +local util = marauth.util +"#, + ); + + let observed_type = local_name_type(&mut ws, file_id, "observed"); + assert_that!( + observed_type.is_unknown(), + eq(false), + "self-assignment should not make marauth unknown" + ); + + let util_type = local_name_type(&mut ws, file_id, "util"); + assert_that!( + util_type.is_unknown() || util_type.is_nil(), + eq(false), + "self-assignment should not erase the known marauth.util table" + ); + } + + #[gtest] + fn test_global_self_assignment_does_not_shadow_lower_priority_table() { + let mut ws = VirtualWorkspace::new(); + + let library_root = ws.virtual_url_generator.new_path("__test_library_marauth"); + ws.analysis.add_library_workspace(library_root.clone()); + let library_uri = Uri::parse_from_file_path(&library_root.join("marauth.lua")) + .expect("valid library uri"); + ws.analysis.update_file_by_uri( + &library_uri, + Some( + r#" +marauth = {} +marauth.util = {} +"# + .to_string(), + ), + ); + + let file_id = ws.def( + r#" +marauth = marauth + +local observed = marauth +local util = marauth.util +"#, + ); + + let observed_type = local_name_type(&mut ws, file_id, "observed"); + assert_that!( + observed_type.is_table(), + eq(true), + "self-assignment should not shadow a visible lower-priority table with unknown" + ); + + let util_type = local_name_type(&mut ws, file_id, "util"); + assert_that!( + util_type.is_table(), + eq(true), + "self-assignment should preserve fields from the visible lower-priority table" + ); + } + + #[gtest] + fn test_plain_global_table_prefers_global_path_member_over_nullable_any() { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def( + r#" +---@type table +marauth = {} + +marauth.util = {} + +function marauth.util:TestFunction() +end + +local util = marauth.util +local testFunction = marauth.util.TestFunction +"#, + ); + + let util_type = local_name_type(&mut ws, file_id, "util"); + assert_that!( + matches!(util_type, LuaType::TableConst(_) | LuaType::MergedTable(_)), + eq(true), + "plain global table should resolve the concrete global-path util member, got {util_type:?}" + ); + + let test_function_type = local_name_type(&mut ws, file_id, "testFunction"); + assert_that!( + test_function_type.is_function(), + eq(true), + "plain global table should not turn nested global-path methods into nullable any, got {test_function_type:?}" + ); + } + + #[gtest] + fn test_bare_table_fragment_does_not_pollute_merged_global_member() { + let mut ws = VirtualWorkspace::new(); + + let library_root = ws + .virtual_url_generator + .new_path("__test_library_marauth_bare_table"); + ws.analysis.add_library_workspace(library_root.clone()); + let library_uri = + Uri::parse_from_file_path(&library_root.join("marauth.lua")).expect("valid uri"); + ws.analysis.update_file_by_uri( + &library_uri, + Some( + r#" +marauth = {} +marauth.util = {} + +function marauth.util:TestFunction() +end +"# + .to_string(), + ), + ); + + let file_id = ws.def( + r#" +---@type table +marauth = marauth or {} + +local testFunction = marauth.util.TestFunction +"#, + ); + + let test_function_type = local_name_type(&mut ws, file_id, "testFunction"); + assert_that!( + test_function_type.is_function(), + eq(true), + "bare table fragments in a merged global should not pollute known members with nullable any, got {test_function_type:?}" + ); + } + + #[gtest] + fn test_resolved_current_workspace_globals_do_not_fall_back_to_lower_priority_table() { + let mut ws = VirtualWorkspace::new(); + + let library_root = ws + .virtual_url_generator + .new_path("__test_library_shadowed_global"); + ws.analysis.add_library_workspace(library_root.clone()); + let library_uri = Uri::parse_from_file_path(&library_root.join("shadowed.lua")) + .expect("valid library uri"); + ws.analysis.update_file_by_uri( + &library_uri, + Some( + r#" +shadowed = {} +shadowed.util = {} +"# + .to_string(), + ), + ); + + let file_id = ws.def( + r#" +shadowed = 1 +shadowed = 2 + +local value = shadowed +"#, + ); + + let value_type = local_name_type(&mut ws, file_id, "value"); + assert_that!( + matches!(value_type, LuaType::Integer | LuaType::IntegerConst(_)), + eq(true), + "a resolved current-workspace primitive global must remain a primitive, got {:?}", + value_type + ); + } + + #[gtest] + fn test_guarded_global_assignment_keeps_lower_priority_fields_without_field_guard() { + let mut ws = VirtualWorkspace::new(); + + let library_root = ws + .virtual_url_generator + .new_path("__test_library_marauth_guard"); + ws.analysis.add_library_workspace(library_root.clone()); + let library_uri = Uri::parse_from_file_path(&library_root.join("marauth.lua")) + .expect("valid library uri"); + ws.analysis.update_file_by_uri( + &library_uri, + Some( + r#" +marauth = {} +marauth.util = {} + +function marauth.util:TestFunction() +end +"# + .to_string(), + ), + ); + + let file_id = ws.def( + r#" +marauth = marauth or {} + +local util = marauth.util +local testFunction = marauth.util.TestFunction +"#, + ); + + let util_type = local_name_type(&mut ws, file_id, "util"); + assert_that!( + util_type.is_table(), + eq(true), + "root guard should not require repeating `marauth.util = marauth.util or {{}}` in the child file" + ); + + let function_type = local_name_type(&mut ws, file_id, "testFunction"); + assert_that!( + function_type.is_function(), + eq(true), + "fields from the lower-priority table should remain visible through the guarded root" + ); + } + + #[gtest] + fn test_same_priority_guarded_global_tables_merge_fields_independent_of_index_order() { + let mut ws = VirtualWorkspace::new(); + + let child_file = ws.def_file( + "gamemodes/test/gamemode/sh_test1.lua", + r#" +marauth = marauth or {} + +marauth.util:TestFunction() +local util = marauth.util +local testFunction = marauth.util.TestFunction +"#, + ); + ws.def_file( + "gamemodes/test_base/gamemode/sh_test1.lua", + r#" +marauth = marauth or {} +marauth.util = marauth.util or {} + +function marauth.util:TestFunction() +end +"#, + ); + + let util_type = local_name_type(&mut ws, child_file, "util"); + assert_that!( + util_type.is_table(), + eq(true), + "same-priority guarded table pieces should merge instead of depending on file order" + ); + + let function_type = local_name_type(&mut ws, child_file, "testFunction"); + assert_that!( + function_type.is_function(), + eq(true), + "same-priority guarded table merge should preserve method fields" + ); + + assert_that!( + file_has_diagnostic(&mut ws, child_file, DiagnosticCode::NeedCheckNil), + eq(false), + "same-priority guarded table merge should not make known fields nullable" + ); + } + + #[gtest] + fn test_cross_workspace_guarded_global_tables_merge_fields_with_isolation_disabled() { + let mut ws = VirtualWorkspace::new(); + + let base_root = ws + .virtual_url_generator + .new_path("__test_base_gamemode_root"); + ws.analysis.add_main_workspace(base_root.clone()); + let base_uri = Uri::parse_from_file_path(&base_root.join("gamemode/sh_test1.lua")) + .expect("valid base uri"); + ws.analysis.update_file_by_uri( + &base_uri, + Some( + r#" +marauth = marauth or {} +marauth.util = marauth.util or {} + +function marauth.util:TestFunction() +end +"# + .to_string(), + ), + ); + + let child_file = ws.def_file( + "gamemode/sh_test1.lua", + r#" +marauth = marauth or {} + +marauth.util:TestFunction() +local util = marauth.util +local testFunction = marauth.util.TestFunction +"#, + ); + + let util_type = local_name_type(&mut ws, child_file, "util"); + assert_that!( + util_type.is_table(), + eq(true), + "guarded table fields from another visible main workspace should stay non-nil, got {:?}", + util_type + ); + + let function_type = local_name_type(&mut ws, child_file, "testFunction"); + assert_that!( + function_type.is_function(), + eq(true), + "guarded table methods from another visible main workspace should stay visible, got {:?}", + function_type + ); + + assert_that!( + file_has_diagnostic(&mut ws, child_file, DiagnosticCode::NeedCheckNil), + eq(false), + "visible guarded table fields should not require nil checks" + ); + } + + #[gtest] + fn test_cross_workspace_guarded_global_tables_merge_disjoint_and_nested_fields() { + let mut ws = VirtualWorkspace::new(); + + let base_root = ws + .virtual_url_generator + .new_path("__test_base_gamemode_merge_root"); + ws.analysis.add_main_workspace(base_root.clone()); + let base_uri = Uri::parse_from_file_path(&base_root.join("gamemode/sh_test1.lua")) + .expect("valid base uri"); + ws.analysis.update_file_by_uri( + &base_uri, + Some( + r#" +marauth = marauth or {} +marauth.util = marauth.util or {} + +function marauth.util:BaseFunction() +end +"# + .to_string(), + ), + ); + + let child_file = ws.def_file( + "gamemode/sh_test1.lua", + r#" +marauth = marauth or {} +marauth.character = marauth.character or {} +marauth.util = marauth.util or {} + +function marauth.character:Create() +end + +function marauth.util:ChildFunction() +end + +local util = marauth.util +local baseFunction = marauth.util.BaseFunction +local childFunction = marauth.util.ChildFunction +local character = marauth.character +local create = marauth.character.Create +"#, + ); + + let util_type = local_name_type(&mut ws, child_file, "util"); + assert_that!( + util_type.is_table(), + eq(true), + "same global table field contributions should merge into a definite table" + ); + + let base_function_type = local_name_type(&mut ws, child_file, "baseFunction"); + assert_that!( + base_function_type.is_function(), + eq(true), + "nested table merge should preserve base workspace methods" + ); + + let child_function_type = local_name_type(&mut ws, child_file, "childFunction"); + assert_that!( + child_function_type.is_function(), + eq(true), + "nested table merge should preserve child workspace methods" + ); + + let character_type = local_name_type(&mut ws, child_file, "character"); + assert_that!( + character_type.is_table(), + eq(true), + "disjoint root fields should remain visible through the merged global table" + ); + + let create_type = local_name_type(&mut ws, child_file, "create"); + assert_that!( + create_type.is_function(), + eq(true), + "disjoint nested methods should remain visible through the merged global table" + ); + } + + #[gtest] + fn test_three_file_nested_guarded_global_table_chain_keeps_all_methods() { + let mut ws = VirtualWorkspace::new(); + + ws.def_file( + "gamemodes/test_base/gamemode/sh_test1.lua", + r#" +marauth = marauth or {} +marauth.util = marauth.util or {} + +function marauth.util:BaseFunction() +end +"#, + ); + ws.def_file( + "gamemodes/test/gamemode/sh_test1.lua", + r#" +marauth = marauth or {} +marauth.util = marauth.util or {} + +function marauth.util:FirstChildFunction() +end +"#, + ); + let consumer_file = ws.def_file( + "gamemodes/test/gamemode/sh_test2.lua", + r#" +marauth = marauth or {} +marauth.util = marauth.util or {} + +function marauth.util:SecondChildFunction() +end + +local util = marauth.util +local baseFunction = marauth.util.BaseFunction +local firstChildFunction = marauth.util.FirstChildFunction +local secondChildFunction = marauth.util.SecondChildFunction +"#, + ); + + let util_type = local_name_type(&mut ws, consumer_file, "util"); + assert_that!( + util_type.is_table(), + eq(true), + "nested guarded table chain should remain a definite table, got {:?}", + util_type + ); + + for name in ["baseFunction", "firstChildFunction", "secondChildFunction"] { + let function_type = local_name_type(&mut ws, consumer_file, name); + assert_that!( + function_type.is_function(), + eq(true), + "nested guarded table chain should preserve {name}, got {:?}", + function_type + ); + } + + assert_that!( + file_has_diagnostic(&mut ws, consumer_file, DiagnosticCode::NeedCheckNil), + eq(false), + "nested guarded table chain should not make known methods nullable" + ); + } + + #[gtest] + fn test_guarded_global_child_field_owner_prefers_current_file_table() { + let mut ws = VirtualWorkspace::new(); + + let base_root = ws + .virtual_url_generator + .new_path("__test_base_gamemode_owner_root"); + ws.analysis.add_main_workspace(base_root.clone()); + let base_uri = Uri::parse_from_file_path(&base_root.join("gamemode/sh_test1.lua")) + .expect("valid base uri"); + ws.analysis.update_file_by_uri( + &base_uri, + Some( + r#" +marauth = marauth or {} +marauth.util = marauth.util or {} +"# + .to_string(), + ), + ); + + let child_file = ws.def_file( + "gamemode/sh_test1.lua", + r#" +marauth = marauth or {} +marauth.character = marauth.character or {} +"#, + ); + + let owner = first_index_expr_member_owner(&ws, child_file, "marauth.character"); + let LuaMemberOwner::Element(owner_range) = owner else { + panic!("expected marauth.character to be owned by a table element, got {owner:?}"); + }; + assert_that!( + owner_range.file_id, + eq(child_file), + "child guarded field assignment should attach to the current file's table owner" + ); + } } diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs index 67b950b75..4a111dec0 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs @@ -14,6 +14,7 @@ use crate::{ infer_node_semantic_decl, semantic::{ infer::narrow::{VarRefId, infer_expr_narrow_type}, + member::merge_open_table_types, semantic_info::resolve_global_decl_id, }, }; @@ -1013,8 +1014,70 @@ pub fn infer_global_type( return Err(InferFailReason::None); } - let decl_ids = - select_decl_ids_for_global_infer(db, current_file_id, call_offset, &priority_tiers); + // A top-priority global can exist before its type cache is resolved while + // analyzing assignments such as `x = x`. It must not hide lower-priority + // declarations that describe the value being read. + let call_realm = if db.get_emmyrc().gmod.enabled { + current_file_id + .zip(call_offset) + .map(|(file_id, call_offset)| { + db.get_gmod_infer_index() + .get_realm_at_offset(&file_id, call_offset) + }) + } else { + None + }; + + let mut last_resolve_reason = InferFailReason::None; + let mut saw_compatible_tier = false; + let mut fallback_best_tier = None; + for (_, decl_ids) in priority_tiers { + let decl_ids = if let Some(call_realm) = call_realm { + let selected_decl_ids = + select_realm_compatible_decl_ids_for_global_infer_tier(db, call_realm, &decl_ids); + if selected_decl_ids.is_empty() { + if fallback_best_tier.is_none() { + fallback_best_tier = Some(decl_ids); + } + continue; + } + + selected_decl_ids + } else { + decl_ids + }; + if decl_ids.is_empty() { + continue; + } + saw_compatible_tier = true; + + match infer_global_type_from_decl_ids(db, decl_ids) { + Ok(typ) => return Ok(typ), + Err(reason) if can_fall_through_global_tier(&reason) => last_resolve_reason = reason, + Err(reason) => return Err(reason), + } + } + + if !saw_compatible_tier && let Some(decl_ids) = fallback_best_tier { + match infer_global_type_from_decl_ids(db, decl_ids) { + Ok(typ) => return Ok(typ), + Err(reason) => last_resolve_reason = reason, + } + } + + Err(last_resolve_reason) +} + +fn can_fall_through_global_tier(reason: &InferFailReason) -> bool { + matches!( + reason, + InferFailReason::UnResolveDeclType(_) + | InferFailReason::UnResolveTypeDecl(_) + | InferFailReason::UnResolveMemberType(_) + ) +} + +fn infer_global_type_from_decl_ids(db: &DbIndex, decl_ids: Vec) -> InferResult { if decl_ids.is_empty() { return Err(InferFailReason::None); } @@ -1042,16 +1105,16 @@ pub fn infer_global_type( b_is_std.cmp(&a_is_std) }); - // Prefer callable declarations when a name has both callable and table-like - // global decls (e.g. constructor functions alongside class bootstrap tables). let mut callable_type: Option = None; let mut def_or_ref_type: Option = None; - let mut table_type: Option = None; + let mut table_types = Vec::new(); let mut last_resolve_reason = InferFailReason::None; + let mut saw_resolved_decl_type = false; for decl_id in sorted_decl_ids { let decl_type_cache = db.get_type_index().get_type_cache(&decl_id.into()); match decl_type_cache { Some(type_cache) => { + saw_resolved_decl_type = true; let typ = type_cache.as_type(); if typ.contain_tpl() { @@ -1076,8 +1139,8 @@ pub fn infer_global_type( continue; } - if type_cache.is_table() && table_type.is_none() { - table_type = Some(typ.clone()); + if collect_global_table_merge_candidates(typ, &mut table_types) { + continue; } } None => { @@ -1094,11 +1157,42 @@ pub fn infer_global_type( return Ok(def_or_ref_type); } - if let Some(table_type) = table_type { - return Ok(table_type); + if !table_types.is_empty() { + return Ok(merge_open_table_types(db, table_types)); } - Err(last_resolve_reason) + if saw_resolved_decl_type { + Err(InferFailReason::None) + } else { + Err(last_resolve_reason) + } +} + +fn collect_global_table_merge_candidates(typ: &LuaType, table_types: &mut Vec) -> bool { + match typ { + LuaType::Object(_) => { + table_types.push(typ.clone()); + true + } + LuaType::Union(union) => { + let mut nested = Vec::new(); + if union + .into_vec() + .iter() + .all(|typ| collect_global_table_merge_candidates(typ, &mut nested)) + { + table_types.extend(nested); + true + } else { + false + } + } + _ if typ.is_table() => { + table_types.push(typ.clone()); + true + } + _ => false, + } } fn infer_legacy_module_local_type( @@ -1191,41 +1285,20 @@ fn has_legacy_module_namespace_for_file(db: &DbIndex, file_id: Option, n }) || file_id.is_none() && has_legacy_module_namespace(db, name) } -fn select_decl_ids_for_global_infer( +fn select_realm_compatible_decl_ids_for_global_infer_tier( db: &DbIndex, - current_file_id: Option, - call_offset: Option, - priority_tiers: &[(u8, Vec)], + call_realm: GmodRealm, + decl_ids: &[LuaDeclId], ) -> Vec { - let Some((_, best_tier_decl_ids)) = priority_tiers.first() else { - return Vec::new(); - }; - - if !db.get_emmyrc().gmod.enabled { - return best_tier_decl_ids.clone(); - } - - let (Some(file_id), Some(call_offset)) = (current_file_id, call_offset) else { - return best_tier_decl_ids.clone(); - }; - let infer_index = db.get_gmod_infer_index(); - let call_realm = infer_index.get_realm_at_offset(&file_id, call_offset); - for (_, decl_ids) in priority_tiers { - let mut compatible_decl_ids = Vec::new(); - for decl_id in decl_ids { + decl_ids + .iter() + .copied() + .filter(|decl_id| { let decl_realm = infer_index.get_realm_at_offset(&decl_id.file_id, decl_id.position); - if is_realm_compatible(call_realm, decl_realm) { - compatible_decl_ids.push(*decl_id); - } - } - - if !compatible_decl_ids.is_empty() { - return compatible_decl_ids; - } - } - - best_tier_decl_ids.clone() + is_realm_compatible(call_realm, decl_realm) + }) + .collect() } fn is_realm_compatible(call_realm: GmodRealm, decl_realm: GmodRealm) -> bool { diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs index 90b06f49a..eedf7dff8 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs @@ -9,7 +9,8 @@ use rowan::TextSize; use crate::{ AssignVarHint, CacheEntry, DbIndex, FlowAntecedent, FlowId, FlowNode, FlowNodeKind, FlowTree, GmodRealm, InferFailReason, LuaArrayType, LuaDeclId, LuaInferCache, LuaMemberId, LuaMemberKey, - LuaMemberOwner, LuaSemanticDeclId, LuaSignatureId, LuaType, LuaUnionType, TypeOps, infer_expr, + LuaMemberOwner, LuaSemanticDeclId, LuaSignatureId, LuaType, LuaTypeOwner, LuaUnionType, + TypeOps, infer_expr, semantic::infer::{ InferResult, VarRefId, infer_expr_list_value_type_at, infer_name::infer_param, @@ -979,7 +980,7 @@ fn get_type_at_assign_stat( } // Check if there's an explicit type annotation (not just inferred type) - let var_id = match &var { + let type_owner = match &var { LuaVarExpr::NameExpr(name_expr) => { Some(LuaDeclId::new(cache.get_file_id(), name_expr.get_position()).into()) } @@ -988,12 +989,20 @@ fn get_type_at_assign_stat( } }; - let explicit_var_type = var_id - .and_then(|id| db.get_type_index().get_type_cache(&id)) + let explicit_var_type = type_owner + .as_ref() + .and_then(|id| db.get_type_index().get_type_cache(id)) .filter(|tc| tc.is_doc()) .map(|tc| tc.as_type().clone()); - let expr_type = infer_expr_list_value_type_at(db, cache, &exprs, i)?; + let guarded_global_type = exprs.get(i).and_then(|expr| { + guarded_global_self_assignment_type(db, cache, type_owner.as_ref(), &maybe_ref_id, expr) + }); + + let expr_type = match guarded_global_type { + Some(typ) => Some(typ), + None => infer_expr_list_value_type_at(db, cache, &exprs, i)?, + }; let Some(expr_type) = expr_type else { return Ok(ResultTypeOrContinue::Continue); }; @@ -1044,6 +1053,36 @@ fn get_type_at_assign_stat( Ok(ResultTypeOrContinue::Continue) } +fn guarded_global_self_assignment_type( + db: &DbIndex, + cache: &mut LuaInferCache, + type_owner: Option<&LuaTypeOwner>, + var_ref_id: &VarRefId, + expr: &LuaExpr, +) -> Option { + if !is_self_coalescing_or_expr(db, cache, var_ref_id, expr) { + return None; + } + + let Some(LuaTypeOwner::Decl(decl_id)) = type_owner else { + return None; + }; + + let decl = db.get_decl_index().get_decl(decl_id)?; + if !decl.is_global() { + return None; + } + + let type_cache = db + .get_type_index() + .get_type_cache(&LuaTypeOwner::Decl(*decl_id))?; + if !type_cache.is_infer() || !type_cache.as_type().is_table() { + return None; + } + + Some(type_cache.as_type().clone()) +} + fn assignment_flow_info_cannot_match( tree: &FlowTree, flow_id: FlowId, From 7559ffd4ae7859b8877a9a0d110a3d3519155014 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 31 May 2026 22:59:07 +0100 Subject: [PATCH 3/5] fix: always complete merged global tables and add tests --- Cargo.lock | 1 + crates/glua_ls/Cargo.toml | 1 + .../completion/providers/member_provider.rs | 81 ++++++++++++++++++- .../src/handlers/test/completion_test.rs | 60 ++++++++++++++ .../glua_ls/src/handlers/test/hover_test.rs | 46 +++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 35abcc7e3..c0c88b2b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,6 +942,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", + "smol_str", "tokio", "tokio-util", "walkdir", diff --git a/crates/glua_ls/Cargo.toml b/crates/glua_ls/Cargo.toml index 4a45ba098..b9334a003 100644 --- a/crates/glua_ls/Cargo.toml +++ b/crates/glua_ls/Cargo.toml @@ -40,6 +40,7 @@ itertools.workspace = true dirs.workspace = true wax.workspace = true internment.workspace = true +smol_str.workspace = true include_dir.workspace = true [dependencies.clap] diff --git a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs index 3e995953f..648938c50 100644 --- a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs @@ -4,9 +4,10 @@ use glua_code_analysis::{ }; use glua_parser::{ LuaAstNode, LuaAstToken, LuaComment, LuaCommentOwner, LuaDocTag, LuaDocTagRealm, LuaExpr, - LuaFuncStat, LuaIndexExpr, LuaLocalFuncStat, LuaStringToken, + LuaFuncStat, LuaIndexExpr, LuaLocalFuncStat, LuaNameExpr, LuaStringToken, PathTrait, }; use rowan::TextSize; +use smol_str::SmolStr; use std::collections::{HashMap, HashSet}; use crate::handlers::completion::{ @@ -62,11 +63,89 @@ pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { .semantic_model .get_member_info_map_at_offset(&prefix_type, builder.position_offset) .unwrap_or_default(); + extend_global_path_members(builder, &prefix_expr, &mut member_info_map); extend_gmod_hook_fallback_members(builder, &prefix_expr, &prefix_type, &mut member_info_map); add_completions_for_members(builder, &member_info_map, completion_status) } +fn extend_global_path_members( + builder: &CompletionBuilder, + prefix_expr: &LuaExpr, + members: &mut HashMap>, +) { + let Some(prefix_path) = global_expr_access_path(&builder.semantic_model, prefix_expr) else { + return; + }; + + let namespace_type = LuaType::Namespace(SmolStr::new(prefix_path).into()); + let Some(global_path_members) = builder + .semantic_model + .get_member_info_map_at_offset(&namespace_type, builder.position_offset) + else { + return; + }; + + let mut existing: HashMap>> = HashMap::new(); + for (key, infos) in members.iter() { + let entry = existing.entry(key.clone()).or_default(); + for info in infos { + entry.insert(info.property_owner_id.clone()); + } + } + + for (key, infos) in global_path_members { + let owners = existing.entry(key.clone()).or_default(); + let target = members.entry(key).or_default(); + for info in infos { + if owners.insert(info.property_owner_id.clone()) { + target.push(info); + } + } + } +} + +fn global_expr_access_path(semantic_model: &SemanticModel, expr: &LuaExpr) -> Option { + if !expr_root_is_global(semantic_model, expr) { + return None; + } + + match expr { + LuaExpr::NameExpr(name_expr) => name_expr.get_access_path(), + LuaExpr::IndexExpr(index_expr) => index_expr.get_access_path(), + _ => None, + } +} + +fn expr_root_is_global(semantic_model: &SemanticModel, expr: &LuaExpr) -> bool { + let Some(root_name) = expr_root_name(expr) else { + return false; + }; + + let db = semantic_model.get_db(); + let Some(decl_id) = db + .get_reference_index() + .get_var_reference_decl(&semantic_model.get_file_id(), root_name.get_range()) + else { + return true; + }; + + db.get_decl_index() + .get_decl(&decl_id) + .is_some_and(|decl| decl.is_global() || decl.is_module_scoped()) +} + +fn expr_root_name(expr: &LuaExpr) -> Option { + match expr { + LuaExpr::NameExpr(name_expr) => Some(name_expr.clone()), + LuaExpr::IndexExpr(index_expr) => { + let prefix_expr = index_expr.get_prefix_expr()?; + expr_root_name(&prefix_expr) + } + _ => None, + } +} + fn extend_gmod_hook_fallback_members( builder: &CompletionBuilder, prefix_expr: &LuaExpr, diff --git a/crates/glua_ls/src/handlers/test/completion_test.rs b/crates/glua_ls/src/handlers/test/completion_test.rs index e2958dc2f..6ae4b2dd7 100644 --- a/crates/glua_ls/src/handlers/test/completion_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_test.rs @@ -251,6 +251,66 @@ mod tests { Ok(()) } + #[gtest] + fn test_guarded_global_table_members_complete_when_child_indexes_first() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let (child_content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + marauth = marauth or {} + marauth.character = marauth.character or {} + + function marauth.character:Create() + end + + marauth.util: + "#, + )?; + + let file_ids = ws.def_files(vec![ + ( + "gamemodes/test/gamemode/sh_test1.lua", + child_content.as_str(), + ), + ( + "gamemodes/test_base/gamemode/sh_test1.lua", + r#" + marauth = marauth or {} + marauth.util = marauth.util or {} + + function marauth.util:BaseFunction() + end + "#, + ), + ]); + let child_file = file_ids[0]; + + let result = completion( + &ws.analysis, + child_file, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .ok_or("failed to get completion") + .or_fail()?; + + let items = match result { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let labels = items + .iter() + .map(|item| item.label.clone()) + .collect::>(); + + assert!( + labels.iter().any(|label| label == "BaseFunction"), + "expected guarded global table completion to include BaseFunction, got: {labels:?}" + ); + + Ok(()) + } + #[gtest] fn test_5() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new_with_init_std_lib(); diff --git a/crates/glua_ls/src/handlers/test/hover_test.rs b/crates/glua_ls/src/handlers/test/hover_test.rs index 27081a713..d2e2dec68 100644 --- a/crates/glua_ls/src/handlers/test/hover_test.rs +++ b/crates/glua_ls/src/handlers/test/hover_test.rs @@ -2842,6 +2842,52 @@ mod tests { markup.value } + #[gtest] + fn test_hover_guarded_global_table_field_when_child_indexes_first() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let (child_content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + marauth = marauth or {} + marauth.character = marauth.character or {} + + function marauth.character:Create() + end + + local util = marauth.util + "#, + )?; + + let file_ids = ws.def_files(vec![ + ( + "gamemodes/test/gamemode/sh_test1.lua", + child_content.as_str(), + ), + ( + "gamemodes/test_base/gamemode/sh_test1.lua", + r#" + marauth = marauth or {} + marauth.util = marauth.util or {} + + function marauth.util:BaseFunction() + end + "#, + ), + ]); + let child_file = file_ids[0]; + let value = extract_hover_markdown(&ws, child_file, position); + + assert!( + !value.contains("(field) util: nil"), + "guarded global table field must not hover as nil, got: {value}" + ); + assert!( + value.contains("BaseFunction"), + "expected hover to include the merged util table method, got: {value}" + ); + + Ok(()) + } + #[gtest] fn test_hover_net_message_on_net_start_shows_send_and_receive_patterns() -> Result<()> { let mut ws = enable_gmod_workspace(); From 2acceda765b66d091b901b2dc544def7beac6e94 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:15:29 +0100 Subject: [PATCH 4/5] fix: table merge failure with two merged tables colliding --- .../semantic/type_check/complex_type/mod.rs | 48 +++++- .../complex_type/object_type_check.rs | 104 ++++++++++++- .../src/semantic/type_check/test.rs | 146 +++++++++++++++++- 3 files changed, 292 insertions(+), 6 deletions(-) diff --git a/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs b/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs index 838ab3ccf..1161f3b70 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs @@ -5,6 +5,8 @@ mod object_type_check; mod table_generic_check; mod tuple_type_check; +use std::collections::HashMap; + use array_type_check::check_array_type_compact; use call_type_check::check_call_type_compact; use intersection_type_check::check_intersection_type_compact; @@ -174,16 +176,54 @@ fn check_merged_table_type_compact( return Ok(()); } - let Some(members) = find_members(context.db, source) else { + let Some(object) = structural_object_from_members(context, source) else { return Err(TypeCheckFailReason::DonotCheck); }; - let fields = members + if matches!(compact_type, LuaType::MergedTable(_)) + && let Some(compact_object) = structural_object_from_members(context, compact_type) + { + return check_general_type_compact( + context, + &object, + &compact_object, + check_guard.next_level()?, + ); + } + + check_general_type_compact(context, &object, compact_type, check_guard.next_level()?) +} + +fn structural_object_from_members(context: &TypeCheckContext, typ: &LuaType) -> Option { + let members = find_members(context.db, typ).unwrap_or_default(); + let fields: HashMap<_, _> = members .into_iter() .map(|member| (member.key, member.typ)) .collect(); - let object = LuaType::Object(LuaObjectType::new_with_fields(fields, Vec::new()).into()); - check_general_type_compact(context, &object, compact_type, check_guard.next_level()?) + let mut index_access = Vec::new(); + collect_index_access_from_type(typ, &mut index_access); + if fields.is_empty() && index_access.is_empty() { + return None; + } + + Some(LuaType::Object( + LuaObjectType::new_with_fields(fields, index_access).into(), + )) +} + +fn collect_index_access_from_type(typ: &LuaType, index_access: &mut Vec<(LuaType, LuaType)>) { + match typ { + LuaType::Object(object) => { + index_access.extend(object.get_index_access().iter().cloned()); + } + LuaType::MergedTable(merged_table) => { + for component in merged_table.get_types() { + collect_index_access_from_type(component, index_access); + } + } + LuaType::TableOf(inner) => collect_index_access_from_type(inner, index_access), + _ => {} + } } // too complex diff --git a/crates/glua_code_analysis/src/semantic/type_check/complex_type/object_type_check.rs b/crates/glua_code_analysis/src/semantic/type_check/complex_type/object_type_check.rs index 371c017b5..33a2001ea 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/complex_type/object_type_check.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/complex_type/object_type_check.rs @@ -4,7 +4,7 @@ use crate::{ LuaMemberKey, LuaMemberOwner, LuaObjectType, LuaTupleType, LuaType, RenderLevel, TypeCheckFailReason, TypeCheckResult, humanize_type, semantic::{ - member::find_members, + member::{find_members, member_key_matches_type}, type_check::{ check_general_type_compact, type_check_context::TypeCheckContext, type_check_guard::TypeCheckGuard, @@ -94,9 +94,111 @@ fn check_object_type_compact_object_type( )?; } + check_object_index_access_compact_object(context, source_object, compact_object, check_guard)?; + Ok(()) } +fn check_object_index_access_compact_object( + context: &mut TypeCheckContext, + source_object: &LuaObjectType, + compact_object: &LuaObjectType, + check_guard: TypeCheckGuard, +) -> TypeCheckResult { + for (key_type, source_type) in source_object.get_index_access() { + for (compact_key, compact_type) in compact_object.get_fields() { + if source_object.get_fields().contains_key(compact_key) { + continue; + } + + if member_key_matches_type(context.db, key_type, compact_key) { + let Some(compact_key_type) = member_key_type_for_index(compact_key) else { + continue; + }; + check_member_value( + context, + compact_key, + Some(&compact_key_type), + source_type, + compact_type, + check_guard, + )?; + } + } + + for (compact_key_type, compact_type) in compact_object.get_index_access() { + if !index_key_types_may_overlap( + context, + key_type, + compact_key_type, + check_guard.next_level()?, + )? { + continue; + } + + check_general_type_compact( + context, + source_type, + compact_type, + check_guard.next_level()?, + )?; + } + } + + Ok(()) +} + +fn index_key_types_may_overlap( + context: &mut TypeCheckContext, + source_key_type: &LuaType, + compact_key_type: &LuaType, + check_guard: TypeCheckGuard, +) -> Result { + if let Some(is_match) = exact_literal_key_type_match(source_key_type, compact_key_type) { + return Ok(is_match); + } + + match check_general_type_compact( + context, + source_key_type, + compact_key_type, + check_guard.next_level()?, + ) { + Ok(_) => Ok(true), + Err(err) if err.is_type_not_match() => { + match check_general_type_compact( + context, + compact_key_type, + source_key_type, + check_guard.next_level()?, + ) { + Ok(_) => Ok(true), + Err(err) if err.is_type_not_match() => Ok(false), + Err(err) => Err(err), + } + } + Err(err) => Err(err), + } +} + +fn exact_literal_key_type_match(left: &LuaType, right: &LuaType) -> Option { + match (left, right) { + (LuaType::StringConst(left), LuaType::StringConst(right)) + | (LuaType::StringConst(left), LuaType::DocStringConst(right)) + | (LuaType::DocStringConst(left), LuaType::StringConst(right)) + | (LuaType::DocStringConst(left), LuaType::DocStringConst(right)) => Some(left == right), + (LuaType::IntegerConst(left), LuaType::IntegerConst(right)) + | (LuaType::IntegerConst(left), LuaType::DocIntegerConst(right)) + | (LuaType::DocIntegerConst(left), LuaType::IntegerConst(right)) + | (LuaType::DocIntegerConst(left), LuaType::DocIntegerConst(right)) => Some(left == right), + (LuaType::BooleanConst(left), LuaType::BooleanConst(right)) + | (LuaType::BooleanConst(left), LuaType::DocBooleanConst(right)) + | (LuaType::DocBooleanConst(left), LuaType::BooleanConst(right)) + | (LuaType::DocBooleanConst(left), LuaType::DocBooleanConst(right)) => Some(left == right), + _ => None, + } +} + struct TypeMembers { map: HashMap, index_keys: Vec, diff --git a/crates/glua_code_analysis/src/semantic/type_check/test.rs b/crates/glua_code_analysis/src/semantic/type_check/test.rs index f6d2bee92..dc8c2e513 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/test.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/test.rs @@ -1,6 +1,12 @@ #[cfg(test)] mod test { - use crate::{DiagnosticCode, VirtualWorkspace}; + use std::collections::HashMap; + + use crate::{ + DiagnosticCode, LuaMemberKey, LuaMergedTableType, LuaObjectType, LuaType, VirtualWorkspace, + }; + + use super::super::check_type_compact; #[test] fn test_string() { @@ -110,6 +116,144 @@ mod test { } } + #[test] + fn test_merged_table_types_check_structurally() { + let db = crate::DbIndex::new(); + let source = merged_table_with_field("name", LuaType::String); + let compact = merged_table_with_field("name", LuaType::String); + + assert!(check_type_compact(&db, &source, &compact).is_ok()); + } + + #[test] + fn test_merged_table_type_check_preserves_index_access() { + let db = crate::DbIndex::new(); + let source = merged_table_with_field_and_index_access( + "name", + LuaType::String, + LuaType::String, + LuaType::Number, + ); + let compact = merged_table_with_field_and_index_access( + "name", + LuaType::String, + LuaType::String, + LuaType::String, + ); + + assert!(check_type_compact(&db, &source, &compact).is_err()); + } + + #[test] + fn test_object_type_index_access_accepts_matching_explicit_fields() { + let db = crate::DbIndex::new(); + let source = object_with_index_access(LuaType::String, LuaType::String); + let compact = object_with_field("name", LuaType::String); + + assert!(check_type_compact(&db, &source, &compact).is_ok()); + } + + #[test] + fn test_object_type_index_access_rejects_mismatched_explicit_fields() { + let db = crate::DbIndex::new(); + let source = object_with_index_access(LuaType::String, LuaType::String); + let compact = object_with_field_and_index_access( + "count", + LuaType::Number, + LuaType::String, + LuaType::String, + ); + + assert!(check_type_compact(&db, &source, &compact).is_err()); + } + + #[test] + fn test_object_type_literal_index_access_ignores_non_matching_explicit_fields() { + let db = crate::DbIndex::new(); + let source = object_with_index_access( + LuaType::StringConst(smol_str::SmolStr::new("name").into()), + LuaType::String, + ); + let compact = object_with_field("count", LuaType::Number); + + assert!(check_type_compact(&db, &source, &compact).is_ok()); + } + + #[test] + fn test_object_type_index_access_checks_all_matching_index_signatures() { + let db = crate::DbIndex::new(); + let source = object_with_index_access(LuaType::String, LuaType::String); + let compact = object_with_index_accesses(vec![ + (LuaType::String, LuaType::String), + (LuaType::String, LuaType::Number), + ]); + + assert!(check_type_compact(&db, &source, &compact).is_err()); + } + + #[test] + fn test_pure_index_access_merged_tables_check_structurally() { + let db = crate::DbIndex::new(); + let source = merged_table_with_index_access(LuaType::String, LuaType::String); + let compact = merged_table_with_index_access(LuaType::String, LuaType::String); + + assert!(check_type_compact(&db, &source, &compact).is_ok()); + } + + fn merged_table_with_field(name: &str, typ: LuaType) -> LuaType { + merged_table_from_object(object_with_field(name, typ)) + } + + fn merged_table_with_index_access( + index_key_type: LuaType, + index_value_type: LuaType, + ) -> LuaType { + merged_table_from_object(object_with_index_access(index_key_type, index_value_type)) + } + + fn merged_table_from_object(object: LuaType) -> LuaType { + LuaMergedTableType::new(vec![object]).into() + } + + fn object_with_field(name: &str, typ: LuaType) -> LuaType { + let mut fields = HashMap::new(); + fields.insert(LuaMemberKey::Name(name.into()), typ); + LuaObjectType::new_with_fields(fields, Vec::new()).into() + } + + fn object_with_index_access(index_key_type: LuaType, index_value_type: LuaType) -> LuaType { + object_with_index_accesses(vec![(index_key_type, index_value_type)]) + } + + fn object_with_index_accesses(index_access: Vec<(LuaType, LuaType)>) -> LuaType { + LuaObjectType::new_with_fields(HashMap::new(), index_access).into() + } + + fn merged_table_with_field_and_index_access( + name: &str, + field_type: LuaType, + index_key_type: LuaType, + index_value_type: LuaType, + ) -> LuaType { + merged_table_from_object(object_with_field_and_index_access( + name, + field_type, + index_key_type, + index_value_type, + )) + } + + fn object_with_field_and_index_access( + name: &str, + field_type: LuaType, + index_key_type: LuaType, + index_value_type: LuaType, + ) -> LuaType { + let mut fields = HashMap::new(); + fields.insert(LuaMemberKey::Name(name.into()), field_type); + LuaObjectType::new_with_fields(fields, vec![(index_key_type, index_value_type)]).into() + } + #[test] fn test_array_types() { let mut ws = VirtualWorkspace::new(); From 722cd66e8e116541ad946fd82005b911cafc3dd1 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:25:17 +0100 Subject: [PATCH 5/5] fix: tableof tables could become union rather than merged after guarded assignment --- .../src/semantic/member/mod.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/glua_code_analysis/src/semantic/member/mod.rs b/crates/glua_code_analysis/src/semantic/member/mod.rs index 142bdfde0..049ab355f 100644 --- a/crates/glua_code_analysis/src/semantic/member/mod.rs +++ b/crates/glua_code_analysis/src/semantic/member/mod.rs @@ -145,10 +145,40 @@ fn merge_open_table_components(mut components: Vec) -> Option fn is_open_table_merge_component(typ: &LuaType) -> bool { matches!( typ, - LuaType::Table | LuaType::TableConst(_) | LuaType::Object(_) | LuaType::MergedTable(_) + LuaType::Table + | LuaType::TableConst(_) + | LuaType::Object(_) + | LuaType::MergedTable(_) + | LuaType::TableOf(_) ) } +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{DbIndex, LuaMemberKey, LuaObjectType, LuaType, LuaTypeDeclId}; + + use super::merge_open_table_types; + + #[test] + fn merge_open_table_types_keeps_tableof_as_table_component() { + let db = DbIndex::new(); + let table_of = LuaType::TableOf(Box::new(LuaType::Ref(LuaTypeDeclId::global("Entity")))); + + let mut fields = HashMap::new(); + fields.insert(LuaMemberKey::Name("flags".into()), LuaType::Table); + let object = LuaType::Object(LuaObjectType::new_with_fields(fields, Vec::new()).into()); + + let merged = merge_open_table_types(&db, vec![table_of.clone(), object.clone()]); + let LuaType::MergedTable(merged_table) = merged else { + panic!("expected tableof and object fragments to merge as an open table"); + }; + + assert_eq!(merged_table.get_types(), &[table_of, object]); + } +} + #[derive(Debug, Clone)] pub(crate) struct DynamicFieldResolution { pub typ: LuaType,