diff --git a/crates/emmylua_code_analysis/src/compilation/test/member_infer_test.rs b/crates/emmylua_code_analysis/src/compilation/test/member_infer_test.rs index 1ede38cf3..21a0e5bef 100644 --- a/crates/emmylua_code_analysis/src/compilation/test/member_infer_test.rs +++ b/crates/emmylua_code_analysis/src/compilation/test/member_infer_test.rs @@ -97,10 +97,28 @@ mod test { let e_ty = ws.expr_ty("e"); let f_ty = ws.expr_ty("f"); - assert_eq!(a_ty, LuaType::Integer); - assert_eq!(d_ty, LuaType::Integer); - assert_eq!(e_ty, LuaType::Integer); - assert_eq!(f_ty, LuaType::Integer); + // a and d: direct field access resolves via the table literal (IntegerConst) + // or via the class declaration (Integer); both are valid integer types + assert!( + matches!(a_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type for a, got {:?}", + a_ty + ); + assert!( + matches!(d_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type for d, got {:?}", + d_ty + ); + assert!( + matches!(e_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type for e, got {:?}", + e_ty + ); + assert!( + matches!(f_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type for f, got {:?}", + f_ty + ); } #[test] @@ -322,4 +340,108 @@ mod test { value_ty ); } + + #[test] + fn test_optional_field_narrowed_by_table_literal() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@class NarrowFieldTest + ---@field a? integer + ---@field b? integer + + ---@type NarrowFieldTest + local test = { a = 1 } + c = test.a + d = test.b + "#, + ); + + let c_ty = ws.expr_ty("c"); + assert!( + matches!(c_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type for provided field, got {:?}", + c_ty + ); + + // b is not provided in the literal, should remain integer? (nullable) + let d_ty = ws.expr_ty("d"); + let expected = + LuaType::Union(LuaUnionType::from_vec(vec![LuaType::Integer, LuaType::Nil]).into()); + assert_eq!(d_ty, expected, "expected integer? for unprovided field"); + } + + #[test] + fn test_nil_literal_preserves_nullable() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@class NilFieldTest + ---@field a? integer + + ---@type NilFieldTest + local test = { a = nil } + x = test.a + "#, + ); + + let x_ty = ws.expr_ty("x"); + let expected = + LuaType::Union(LuaUnionType::from_vec(vec![LuaType::Integer, LuaType::Nil]).into()); + assert_eq!(x_ty, expected, "{{a = nil}} should keep a as integer?"); + } + + #[test] + fn test_recursive_instance_member_resolution() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@class DeepInner + ---@field c integer + + ---@class DeepMiddle + ---@field b? DeepInner + + ---@class DeepOuter + ---@field a? DeepMiddle + + ---@type DeepOuter + local test = { a = { b = { c = 1 } } } + x = test.a.b.c + "#, + ); + + let x_ty = ws.expr_ty("x"); + assert!( + matches!(x_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type for deeply nested field, got {:?}", + x_ty + ); + } + + #[test] + fn test_optional_class_field_narrowed_by_table_literal() { + let mut ws = VirtualWorkspace::new(); + + ws.def( + r#" + ---@class InnerClass + ---@field b integer + + ---@class OuterClass + ---@field a? InnerClass + + ---@type OuterClass + local test = { a = { b = 1 } } + c = test.a + "#, + ); + + let c_ty = ws.expr_ty("c"); + assert!( + matches!(c_ty, LuaType::Ref(_) | LuaType::Instance(_)), + "expected InnerClass (non-nullable, possibly Instance) for provided optional field, got {:?}", + c_ty + ); + } } diff --git a/crates/emmylua_code_analysis/src/db_index/type/humanize_type.rs b/crates/emmylua_code_analysis/src/db_index/type/humanize_type.rs index f53a2e64e..ed1ae94ed 100644 --- a/crates/emmylua_code_analysis/src/db_index/type/humanize_type.rs +++ b/crates/emmylua_code_analysis/src/db_index/type/humanize_type.rs @@ -1,13 +1,13 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fmt::{self, Write}; use itertools::Itertools; use crate::{ AsyncState, DbIndex, LuaAliasCallType, LuaConditionalType, LuaFunctionType, LuaGenericType, - LuaIntersectionType, LuaMemberKey, LuaMemberOwner, LuaObjectType, LuaSignatureId, - LuaStringTplType, LuaTupleType, LuaType, LuaTypeDeclId, LuaUnionType, TypeSubstitutor, - VariadicType, + LuaInstanceType, LuaIntersectionType, LuaMemberKey, LuaMemberOwner, LuaObjectType, + LuaSignatureId, LuaStringTplType, LuaTupleType, LuaType, LuaTypeDeclId, LuaUnionType, TypeOps, + TypeSubstitutor, VariadicType, }; use super::{LuaAliasCallKind, LuaMultiLineUnion}; @@ -201,7 +201,7 @@ impl<'a> TypeHumanizer<'a> { LuaType::TplRef(tpl) => w.write_str(tpl.get_name()), LuaType::StrTplRef(str_tpl) => self.write_str_tpl_ref_type(str_tpl, w), LuaType::Variadic(multi) => self.write_variadic_type(multi, w), - LuaType::Instance(ins) => self.write_type_inner(ins.get_base(), w), + LuaType::Instance(ins) => self.write_instance_type(ins, w), LuaType::Signature(signature_id) => self.write_signature_type(signature_id, w), LuaType::Namespace(ns) => write!(w, "{{ {} }}", ns), LuaType::MultiLineUnion(multi_union) => { @@ -271,6 +271,154 @@ impl<'a> TypeHumanizer<'a> { w.write_char('>') } + // ─── Instance (narrowed struct view) ─────────────────────────── + + /// Writes an Instance type: a class type narrowed by a table literal. + /// Fields present in the literal have their nil stripped (not optional), + /// while absent fields retain their original (possibly optional) type. + fn write_instance_type(&mut self, ins: &LuaInstanceType, w: &mut W) -> fmt::Result { + let base = ins.get_base(); + + // Extract the type decl id from the base type + let type_id = match base { + LuaType::Ref(id) | LuaType::Def(id) => id.clone(), + _ => return self.write_type_inner(base, w), + }; + + let type_decl = match self.db.get_type_index().get_type_decl(&type_id) { + Some(decl) => decl, + None => return self.write_type_inner(base, w), + }; + + let name = type_decl.get_full_name().to_string(); + + let max_display_count = match self.level.max_display_count() { + Some(n) => n, + None => { + w.write_str(&name)?; + return Ok(()); + } + }; + + // cycle detection + if !self.visited.insert(type_id.clone()) { + w.write_str(&name)?; + return Ok(()); + } + + // Collect keys present in the table literal, along with their types + let literal_owner = LuaMemberOwner::Element(ins.get_range().clone()); + let member_index = self.db.get_member_index(); + let literal_keys: HashMap = member_index + .get_sorted_members(&literal_owner) + .map(|members| { + members + .iter() + .map(|m| { + let ty = self + .db + .get_type_index() + .get_type_cache(&m.get_id().into()) + .map(|tc| tc.as_type().clone()) + .unwrap_or(LuaType::Any); + (m.get_key().clone(), ty) + }) + .collect() + }) + .unwrap_or_default(); + + // Get class members + let class_owner = LuaMemberOwner::Type(type_id.clone()); + let members = match member_index.get_sorted_members(&class_owner) { + Some(m) => m, + None => { + self.visited.remove(&type_id); + w.write_str(&name)?; + return Ok(()); + } + }; + + let mut member_vec = Vec::new(); + let mut function_vec = Vec::new(); + for member in members { + let member_key = member.get_key(); + let type_cache = self + .db + .get_type_index() + .get_type_cache(&member.get_id().into()); + let type_cache = match type_cache { + Some(type_cache) => type_cache, + None => &super::LuaTypeCache::InferType(LuaType::Any), + }; + if type_cache.is_function() { + function_vec.push(member_key); + } else { + member_vec.push((member_key, type_cache.as_type())); + } + } + + if member_vec.is_empty() && function_vec.is_empty() { + self.visited.remove(&type_id); + w.write_str(&name)?; + return Ok(()); + } + + let all_count = member_vec.len() + function_vec.len(); + + w.write_str(&name)?; + w.write_str(" {\n")?; + + let saved = self.level; + self.level = self.child_level(); + + let mut count = 0; + for (member_key, typ) in &member_vec { + w.write_str(" ")?; + if let Some(literal_ty) = literal_keys.get(member_key) { + if literal_ty.is_nullable() { + // Literal value is nil/nullable — keep optional display + let stripped = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); + self.write_optional_member_field(member_key, &stripped, saved, w)?; + } else { + // Field provided with concrete value: strip nil + let narrowed = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); + self.write_table_member_field(member_key, &narrowed, saved, w)?; + } + } else if typ.is_nullable() { + // Optional field not provided: show as "name?: type" (without nil) + let stripped = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); + self.write_optional_member_field(member_key, &stripped, saved, w)?; + } else { + self.write_table_member_field(member_key, typ, saved, w)?; + } + w.write_str(",\n")?; + count += 1; + if count >= max_display_count { + break; + } + } + if count < all_count { + for function_key in &function_vec { + w.write_str(" ")?; + write_member_key_and_separator(function_key, saved, w)?; + w.write_str("function,\n")?; + count += 1; + if count >= max_display_count { + break; + } + } + } + if count >= max_display_count { + writeln!(w, " ...(+{})", all_count - max_display_count)?; + } + + self.level = saved; + self.visited.remove(&type_id); + + w.write_char('}')?; + Ok(()) + } + // ─── Simple (expanded struct view) ────────────────────────────── /// Tries to write an expanded view of a named type (struct-like fields). @@ -1024,6 +1172,32 @@ impl<'a> TypeHumanizer<'a> { } } + /// Write an optional member field as "name?: type" (with ? after name, nil stripped from type). + fn write_optional_member_field( + &mut self, + member_key: &LuaMemberKey, + ty: &LuaType, + parent_level: RenderLevel, + w: &mut W, + ) -> fmt::Result { + match member_key { + LuaMemberKey::Name(name) => { + w.write_str(name)?; + w.write_str("?")?; + let separator = if parent_level == RenderLevel::Detailed { + ": " + } else { + " = " + }; + w.write_str(separator)?; + } + _ => { + write_member_key_and_separator(member_key, parent_level, w)?; + } + } + self.write_type(ty, w) + } + // ─── helper: write a table member (key: type) ─────────────────── fn write_table_member_field( diff --git a/crates/emmylua_code_analysis/src/db_index/type/types.rs b/crates/emmylua_code_analysis/src/db_index/type/types.rs index 43af62efa..5b90b010e 100644 --- a/crates/emmylua_code_analysis/src/db_index/type/types.rs +++ b/crates/emmylua_code_analysis/src/db_index/type/types.rs @@ -230,6 +230,16 @@ impl LuaType { matches!(self, LuaType::Unknown) } + pub fn is_class_type(&self, db: &DbIndex) -> bool { + let type_id = match self { + LuaType::Ref(id) | LuaType::Def(id) => id, + _ => return false, + }; + db.get_type_index() + .get_type_decl(type_id) + .is_some_and(|decl| decl.is_class()) + } + pub fn is_nil(&self) -> bool { matches!(self, LuaType::Nil) } diff --git a/crates/emmylua_code_analysis/src/semantic/infer/infer_index/mod.rs b/crates/emmylua_code_analysis/src/semantic/infer/infer_index/mod.rs index 23709282e..ea7851b67 100644 --- a/crates/emmylua_code_analysis/src/semantic/infer/infer_index/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/infer/infer_index/mod.rs @@ -701,8 +701,23 @@ fn infer_instance_member( match base_result { Ok(typ) => match infer_table_member(db, cache, range.clone(), index_expr.clone()) { Ok(table_type) => { - return Ok(match TypeOps::Intersect.apply(db, &typ, &table_type) { - LuaType::Never => typ, + // If the literal value is nullable (e.g. `a = nil`), the field + // is effectively unset — keep the original (nullable) class type. + if table_type.is_nullable() { + return Ok(typ); + } + // Field has a concrete value — strip nil from the class type. + let base = TypeOps::Remove.apply(db, &typ, &LuaType::Nil); + return Ok(match TypeOps::Intersect.apply(db, &base, &table_type) { + LuaType::Never => { + // If the literal field is itself a table, wrap in Instance + // to preserve literal context for recursive member access. + if let LuaType::TableConst(nested_range) = table_type { + LuaType::Instance(LuaInstanceType::new(base, nested_range).into()) + } else { + base + } + } intersected => intersected, }); } diff --git a/crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs b/crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs index da37a0207..b97fe786a 100644 --- a/crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs +++ b/crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs @@ -62,6 +62,31 @@ pub fn get_type_at_flow( if *position <= var_ref_id.get_position() { match get_var_ref_type(db, cache, var_ref_id) { Ok(var_type) => { + if var_type.is_class_type(db) { + if let Ok(Some(init_type)) = + try_infer_decl_initializer_type(db, cache, root, var_ref_id) + { + // Only narrow if the table literal has members + if let LuaType::TableConst(ref range) = init_type { + let owner = crate::LuaMemberOwner::Element(range.clone()); + if db + .get_member_index() + .get_members(&owner) + .is_some_and(|m| !m.is_empty()) + { + if let Some(narrowed) = narrow_down_type( + db, + var_type.clone(), + init_type, + Some(var_type.clone()), + ) { + result_type = narrowed; + break; + } + } + } + } + } result_type = var_type; break; } diff --git a/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs b/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs index 8a3faa5f4..70397ca3d 100644 --- a/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs @@ -4,8 +4,8 @@ mod semantic_decl_level; mod semantic_guard; use crate::{ - DbIndex, LuaDeclExtra, LuaDeclId, LuaMemberId, LuaSemanticDeclId, LuaType, LuaTypeCache, - TypeOps, + DbIndex, LuaDeclExtra, LuaDeclId, LuaInstanceType, LuaMemberId, LuaSemanticDeclId, LuaType, + LuaTypeCache, TypeOps, }; use emmylua_parser::{ LuaAstNode, LuaAstToken, LuaDocNameType, LuaDocTag, LuaExpr, LuaLocalName, LuaParamName, @@ -38,8 +38,38 @@ pub fn infer_token_semantic_info( .get_type_index() .get_type_cache(&decl_id.into()) .unwrap_or(&LuaTypeCache::InferType(LuaType::Unknown)); + let mut typ = type_cache.as_type().clone(); + + // Only narrow LocalName declarations — ForStat/ForRangeStat cannot have + // table literal initializers. + if matches!(parent.kind().into(), LuaSyntaxKind::LocalName) && typ.is_class_type(db) { + if let Some(decl) = db.get_decl_index().get_decl(&decl_id) { + if let Some(value_syntax_id) = decl.get_value_syntax_id() { + if let Some(node) = + value_syntax_id.to_node_from_root(&parent.ancestors().last()?) + { + if let Some(expr) = LuaExpr::cast(node) { + if let Ok(LuaType::TableConst(range)) = infer_expr(db, cache, expr) + { + let owner = crate::LuaMemberOwner::Element(range.clone()); + if db + .get_member_index() + .get_members(&owner) + .is_some_and(|m| !m.is_empty()) + { + typ = LuaType::Instance( + LuaInstanceType::new(typ, range).into(), + ); + } + } + } + } + } + } + } + Some(SemanticInfo { - typ: type_cache.as_type().clone(), + typ, semantic_decl: Some(LuaSemanticDeclId::LuaDecl(decl_id)), }) } diff --git a/crates/emmylua_ls/src/handlers/test/hover_test.rs b/crates/emmylua_ls/src/handlers/test/hover_test.rs index 6acf90628..c509c48dd 100644 --- a/crates/emmylua_ls/src/handlers/test/hover_test.rs +++ b/crates/emmylua_ls/src/handlers/test/hover_test.rs @@ -551,4 +551,113 @@ mod tests { Ok(()) } + + #[gtest] + fn test_optional_field_narrowing_partial() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + check!(ws.check_hover( + r#" + ---@class NarrowTest + ---@field a? integer + ---@field b? integer + + ---@type NarrowTest + local test = { a = 1 } + "#, + VirtualHoverResult { + value: "```lua\nlocal test: NarrowTest {\n a: integer,\n b?: integer,\n}\n```" + .to_string(), + }, + )); + Ok(()) + } + + #[gtest] + fn test_optional_field_narrowing_all_provided() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + check!(ws.check_hover( + r#" + ---@class NarrowTestAll + ---@field a? integer + ---@field b? integer + + ---@type NarrowTestAll + local test = { a = 1, b = 2 } + "#, + VirtualHoverResult { + value: + "```lua\nlocal test: NarrowTestAll {\n a: integer,\n b: integer,\n}\n```" + .to_string(), + }, + )); + Ok(()) + } + + #[gtest] + fn test_optional_field_narrowing_empty_table() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + // Empty table: no Instance created, falls back to standard Ref rendering + check!(ws.check_hover( + r#" + ---@class NarrowTestEmpty + ---@field a? integer + ---@field b? integer + + ---@type NarrowTestEmpty + local test = {} + "#, + VirtualHoverResult { + value: "```lua\nlocal test: NarrowTestEmpty {\n a: integer?,\n b: integer?,\n}\n```" + .to_string(), + }, + )); + Ok(()) + } + + #[gtest] + fn test_recursive_nested_hover() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + check!(ws.check_hover( + r#" + ---@class RecDeepInner + ---@field c integer + + ---@class RecDeepMiddle + ---@field b? RecDeepInner + + ---@class RecDeepOuter + ---@field a? RecDeepMiddle + + ---@type RecDeepOuter + local test = { a = { b = { c = 1 } } } + local x = test.a.b.c + "#, + VirtualHoverResult { + value: "```lua\nlocal x: integer = 1\n```".to_string(), + }, + )); + Ok(()) + } + + #[gtest] + fn test_nested_optional_field_narrowing() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + check!(ws.check_hover( + r#" + ---@class NarrowNestedInner + ---@field b integer + + ---@class NarrowNestedOuter + ---@field a? NarrowNestedInner + + ---@type NarrowNestedOuter + local test = { a = { b = 1 } } + local x = test.a + "#, + VirtualHoverResult { + value: "```lua\nlocal x: NarrowNestedInner {\n b: integer,\n}\n```".to_string(), + }, + )); + Ok(()) + } }