From 51ad3501f24ebce1e5296d0ee5b76abc10618d58 Mon Sep 17 00:00:00 2001 From: Ozay Date: Thu, 19 Mar 2026 18:21:22 +0100 Subject: [PATCH 1/9] feat(infer): resolve nested table literals as Instance types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a typed class field is provided with a table literal that is itself a sub-table (TableConst), wrap the result in an Instance type instead of falling back to the bare class Ref. This preserves the literal context for recursive member access, enabling correct nil-stripping on optional fields at any depth. - infer_index: wrap TableConst fallback in Instance(base, range) - humanize_type: render Instance with per-field nil-strip display - semantic_info: create Instance for LocalName hover - get_type_at_flow: narrow class types via table literal at flow - tests: add recursive depth-3 member resolution test - tests: add recursive hover test (test.a.b.c → integer = 1) Co-Authored-By: Claude Opus 4.6 --- .../src/compilation/test/member_infer_test.rs | 112 +++++++++++- .../src/db_index/type/humanize_type.rs | 163 +++++++++++++++++- .../src/semantic/infer/infer_index/mod.rs | 16 +- .../semantic/infer/narrow/get_type_at_flow.rs | 36 ++++ .../src/semantic/semantic_info/mod.rs | 46 ++++- .../src/handlers/test/hover_test.rs | 109 ++++++++++++ 6 files changed, 469 insertions(+), 13 deletions(-) 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..9801cc4e5 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,90 @@ 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"); + assert!( + matches!(d_ty, LuaType::Union(_) | LuaType::Nil), + "expected nullable type for unprovided field, got {:?}", + d_ty + ); + } + + #[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..6917622f2 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 @@ -5,9 +5,9 @@ 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,135 @@ 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 + let literal_owner = LuaMemberOwner::Element(ins.get_range().clone()); + let member_index = self.db.get_member_index(); + let literal_keys: HashSet = member_index + .get_sorted_members(&literal_owner) + .map(|members| members.iter().map(|m| m.get_key().clone()).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 literal_keys.contains(member_key) { + // Field provided in the literal: strip nil to remove optionality + 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 +1153,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/semantic/infer/infer_index/mod.rs b/crates/emmylua_code_analysis/src/semantic/infer/infer_index/mod.rs index 23709282e..8643ed972 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,20 @@ 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, + // Field exists in the literal, so it cannot be nil — strip nil. + let stripped = TypeOps::Remove.apply(db, &typ, &LuaType::Nil); + return Ok(match TypeOps::Intersect.apply(db, &stripped, &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(stripped, nested_range).into(), + ) + } else { + stripped + } + } 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..22291ef25 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 is_class_type(db, &var_type) { + 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; } @@ -295,3 +320,14 @@ fn try_infer_decl_initializer_type( Ok(init_type) } + +/// Check if a type is a Ref or Def that resolves to a class (not an alias). +fn is_class_type(db: &DbIndex, ty: &LuaType) -> bool { + let type_id = match ty { + 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()) +} 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..e11ac3b88 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, @@ -18,6 +18,16 @@ pub use semantic_guard::SemanticDeclGuard; use super::{LuaInferCache, infer_expr}; +fn is_class_type(db: &DbIndex, ty: &LuaType) -> bool { + let type_id = match ty { + 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()) +} + #[derive(Debug, Clone, PartialEq)] pub struct SemanticInfo { pub typ: LuaType, @@ -38,8 +48,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(); + + // For LocalName with a class type and a non-empty table literal initializer, + // narrow to Instance to track which optional fields are provided. + if is_class_type(db, &typ) { + 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().unwrap()) + { + 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(()) + } } From e98efd5a4bbe6e70d1902d1cd90bacf192b483ec Mon Sep 17 00:00:00 2001 From: Ozay Date: Thu, 19 Mar 2026 22:54:51 +0100 Subject: [PATCH 2/9] fix(infer): address PR review feedback for Instance type narrowing Deduplicate is_class_type into LuaType method, conditionally strip nil only when literal value is non-nullable (fixes `{ a = nil }` case), restrict Instance narrowing to LocalName only, strengthen test assertion, and fix hover display for nil-valued literal fields. Co-Authored-By: Claude Opus 4.6 --- .../src/compilation/test/member_infer_test.rs | 28 ++++++++++++--- .../src/db_index/type/humanize_type.rs | 35 ++++++++++++++----- .../src/db_index/type/types.rs | 10 ++++++ .../src/semantic/infer/infer_index/mod.rs | 15 +++++--- .../semantic/infer/narrow/get_type_at_flow.rs | 12 +------ .../src/semantic/semantic_info/mod.rs | 16 ++------- 6 files changed, 75 insertions(+), 41 deletions(-) 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 9801cc4e5..135a773fe 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 @@ -366,11 +366,31 @@ mod test { // b is not provided in the literal, should remain integer? (nullable) let d_ty = ws.expr_ty("d"); - assert!( - matches!(d_ty, LuaType::Union(_) | LuaType::Nil), - "expected nullable type for unprovided field, got {:?}", - d_ty + 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] 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 6917622f2..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,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fmt::{self, Write}; use itertools::Itertools; @@ -306,12 +306,25 @@ impl<'a> TypeHumanizer<'a> { return Ok(()); } - // Collect keys present in the table literal + // 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: HashSet = member_index + let literal_keys: HashMap = member_index .get_sorted_members(&literal_owner) - .map(|members| members.iter().map(|m| m.get_key().clone()).collect()) + .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 @@ -361,10 +374,16 @@ impl<'a> TypeHumanizer<'a> { let mut count = 0; for (member_key, typ) in &member_vec { w.write_str(" ")?; - if literal_keys.contains(member_key) { - // Field provided in the literal: strip nil to remove optionality - let narrowed = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); - self.write_table_member_field(member_key, &narrowed, saved, w)?; + 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); 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 8643ed972..e78090cf7 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,18 +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) => { - // Field exists in the literal, so it cannot be nil — strip nil. - let stripped = TypeOps::Remove.apply(db, &typ, &LuaType::Nil); - return Ok(match TypeOps::Intersect.apply(db, &stripped, &table_type) { + // 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(stripped, nested_range).into(), + LuaInstanceType::new(base, nested_range).into(), ) } else { - stripped + 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 22291ef25..2330e01aa 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,7 +62,7 @@ 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 is_class_type(db, &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) { @@ -321,13 +321,3 @@ fn try_infer_decl_initializer_type( Ok(init_type) } -/// Check if a type is a Ref or Def that resolves to a class (not an alias). -fn is_class_type(db: &DbIndex, ty: &LuaType) -> bool { - let type_id = match ty { - 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()) -} 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 e11ac3b88..26af369a8 100644 --- a/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs @@ -18,16 +18,6 @@ pub use semantic_guard::SemanticDeclGuard; use super::{LuaInferCache, infer_expr}; -fn is_class_type(db: &DbIndex, ty: &LuaType) -> bool { - let type_id = match ty { - 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()) -} - #[derive(Debug, Clone, PartialEq)] pub struct SemanticInfo { pub typ: LuaType, @@ -50,9 +40,9 @@ pub fn infer_token_semantic_info( .unwrap_or(&LuaTypeCache::InferType(LuaType::Unknown)); let mut typ = type_cache.as_type().clone(); - // For LocalName with a class type and a non-empty table literal initializer, - // narrow to Instance to track which optional fields are provided. - if is_class_type(db, &typ) { + // 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) = From 6d738998ee2d49afbdb29909282033a9b6e77059 Mon Sep 17 00:00:00 2001 From: Ozay Date: Fri, 20 Mar 2026 14:47:17 +0100 Subject: [PATCH 3/9] fix(ci): fix clippy unwrap and reformat code Replace `.unwrap()` with `?` in `infer_token_semantic_info` to satisfy clippy's restriction on unwrap in Option-returning functions. Apply `cargo fmt` to fix code style check failures. Co-Authored-By: Claude Opus 4.6 --- .../src/compilation/test/member_infer_test.rs | 10 ++++------ .../src/semantic/infer/infer_index/mod.rs | 4 +--- .../src/semantic/infer/narrow/get_type_at_flow.rs | 1 - .../src/semantic/semantic_info/mod.rs | 2 +- 4 files changed, 6 insertions(+), 11 deletions(-) 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 135a773fe..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 @@ -366,9 +366,8 @@ mod test { // 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(), - ); + let expected = + LuaType::Union(LuaUnionType::from_vec(vec![LuaType::Integer, LuaType::Nil]).into()); assert_eq!(d_ty, expected, "expected integer? for unprovided field"); } @@ -387,9 +386,8 @@ mod test { ); let x_ty = ws.expr_ty("x"); - let expected = LuaType::Union( - LuaUnionType::from_vec(vec![LuaType::Integer, LuaType::Nil]).into(), - ); + 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?"); } 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 e78090cf7..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 @@ -713,9 +713,7 @@ fn infer_instance_member( // 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(), - ) + LuaType::Instance(LuaInstanceType::new(base, nested_range).into()) } else { base } 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 2330e01aa..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 @@ -320,4 +320,3 @@ fn try_infer_decl_initializer_type( Ok(init_type) } - 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 26af369a8..70397ca3d 100644 --- a/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs @@ -46,7 +46,7 @@ pub fn infer_token_semantic_info( 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().unwrap()) + 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) From c3cc7c6b38d4444e400c1a48359fa7922a511258 Mon Sep 17 00:00:00 2001 From: Ozay Date: Thu, 2 Apr 2026 18:10:58 +0200 Subject: [PATCH 4/9] refactor(infer): fix Instance narrowing regression and reduce nesting - Remove Intersect in infer_instance_member to prevent narrowing declared types (e.g. integer) down to literal constants (e.g. IntegerConst(1)); the literal value is now used only to decide whether to strip nil - Restore exact assert_eq! assertions in test_issue_397 (no longer need to accept both Integer and IntegerConst) - Extract try_narrow_local_to_instance helper in semantic_info/mod.rs to reduce 6 levels of if-let nesting to 1 - Flatten DeclPosition narrowing in get_type_at_flow.rs with a labeled block to reduce 5 levels of nesting to 2 - Rename stripped/narrowed to without_nil and fix misleading comment in write_instance_type (both branches already stripped nil from the type) Co-Authored-By: Claude Sonnet 4.6 --- .../src/compilation/test/member_infer_test.rs | 26 ++------ .../src/db_index/type/humanize_type.rs | 17 ++--- .../src/semantic/infer/infer_index/mod.rs | 19 +++--- .../semantic/infer/narrow/get_type_at_flow.rs | 50 +++++++------- .../src/semantic/semantic_info/mod.rs | 65 ++++++++++++------- 5 files changed, 89 insertions(+), 88 deletions(-) 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 21a0e5bef..6b2a9d39b 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,28 +97,10 @@ mod test { let e_ty = ws.expr_ty("e"); let f_ty = ws.expr_ty("f"); - // 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 - ); + assert_eq!(a_ty, LuaType::Integer); + assert_eq!(d_ty, LuaType::Integer); + assert_eq!(e_ty, LuaType::Integer); + assert_eq!(f_ty, LuaType::Integer); } #[test] 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 ed1ae94ed..a876f2317 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 @@ -376,18 +376,19 @@ impl<'a> TypeHumanizer<'a> { 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)?; + // Literal value is nil/nullable — show as optional (strip nil from + // type, but add `?` to the key to signal it was not concretely set). + let without_nil = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); + self.write_optional_member_field(member_key, &without_nil, 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)?; + // Concrete value provided — strip nil and display as required. + let without_nil = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); + self.write_table_member_field(member_key, &without_nil, 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)?; + let without_nil = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); + self.write_optional_member_field(member_key, &without_nil, saved, w)?; } else { self.write_table_member_field(member_key, typ, saved, w)?; } 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 ea7851b67..7fcef2d52 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 @@ -707,18 +707,15 @@ fn infer_instance_member( return Ok(typ); } // Field has a concrete value — strip nil from the class type. + // Do not intersect with the literal value to avoid narrowing declared + // types (e.g. `integer`) down to their constant (e.g. `IntegerConst(1)`). 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, + return Ok(if let LuaType::TableConst(nested_range) = table_type { + // Nested table: wrap in Instance to preserve literal context + // for recursive member access. + LuaType::Instance(LuaInstanceType::new(base, nested_range).into()) + } else { + base }); } Err(InferFailReason::FieldNotFound) => return Ok(typ), 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 b97fe786a..8487d12c4 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,29 +62,35 @@ 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) + 'narrow: { + if !var_type.is_class_type(db) { + break 'narrow; + } + let Ok(Some(init_type)) = try_infer_decl_initializer_type( + db, cache, root, var_ref_id, + ) else { + break 'narrow; + }; + // Only narrow if the table literal has members + let LuaType::TableConst(ref range) = init_type else { + break 'narrow; + }; + let owner = crate::LuaMemberOwner::Element(range.clone()); + if !db + .get_member_index() + .get_members(&owner) + .is_some_and(|m| !m.is_empty()) { - // 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; - } - } - } + break 'narrow; + } + 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; 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 70397ca3d..52054c772 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, LuaInstanceType, LuaMemberId, LuaSemanticDeclId, LuaType, - LuaTypeCache, TypeOps, + DbIndex, LuaDeclExtra, LuaDeclId, LuaInstanceType, LuaMemberId, LuaMemberOwner, + LuaSemanticDeclId, LuaType, LuaTypeCache, TypeOps, }; use emmylua_parser::{ LuaAstNode, LuaAstToken, LuaDocNameType, LuaDocTag, LuaExpr, LuaLocalName, LuaParamName, @@ -42,29 +42,12 @@ pub fn infer_token_semantic_info( // 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(), - ); - } - } - } - } - } + if matches!(parent.kind().into(), LuaSyntaxKind::LocalName) { + let root = parent.ancestors().last()?; + if let Some(narrowed) = + try_narrow_local_to_instance(db, cache, &typ, &decl_id, &root) + { + typ = narrowed; } } @@ -174,6 +157,38 @@ pub fn infer_node_semantic_info( } } +/// If `typ` is a class type and `decl_id` has a non-empty `TableConst` initializer, +/// returns `Instance(typ, range)`. Otherwise returns `None`. +fn try_narrow_local_to_instance( + db: &DbIndex, + cache: &mut LuaInferCache, + typ: &LuaType, + decl_id: &LuaDeclId, + root: &LuaSyntaxNode, +) -> Option { + if !typ.is_class_type(db) { + return None; + } + let decl = db.get_decl_index().get_decl(decl_id)?; + let value_syntax_id = decl.get_value_syntax_id()?; + let node = value_syntax_id.to_node_from_root(root)?; + let expr = LuaExpr::cast(node)?; + let LuaType::TableConst(range) = infer_expr(db, cache, expr).ok()? else { + return None; + }; + let owner = LuaMemberOwner::Element(range.clone()); + if !db + .get_member_index() + .get_members(&owner) + .is_some_and(|m| !m.is_empty()) + { + return None; + } + Some(LuaType::Instance( + LuaInstanceType::new(typ.clone(), range).into(), + )) +} + fn type_def_tag_info(name: &str, db: &DbIndex, cache: &mut LuaInferCache) -> Option { let type_decl = db .get_type_index() From 0339b6a1964069c20982a74a5bfa87a1746224a8 Mon Sep 17 00:00:00 2001 From: Ozay Date: Thu, 2 Apr 2026 18:19:58 +0200 Subject: [PATCH 5/9] fix(infer): label outer loop to resolve ambiguous break in narrow block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unlabeled `break` inside `'narrow:` was ambiguous — Rust requires an explicit label when breaking out of an enclosing loop from within a labeled block. Label the outer loop as `'flow:` and use `break 'flow`. Co-Authored-By: Claude Sonnet 4.6 --- .../src/semantic/infer/narrow/get_type_at_flow.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 8487d12c4..069ffa241 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 @@ -33,7 +33,7 @@ pub fn get_type_at_flow( let result_type; let mut antecedent_flow_id = flow_id; - loop { + 'flow: loop { let flow_node = tree .get_flow_node(antecedent_flow_id) .ok_or(InferFailReason::None)?; @@ -90,7 +90,7 @@ pub fn get_type_at_flow( Some(var_type.clone()), ) { result_type = narrowed; - break; + break 'flow; } } result_type = var_type; From 646bd23c5b37ea8050c3d9b48cc389112af3a529 Mon Sep 17 00:00:00 2001 From: Ozay Date: Thu, 2 Apr 2026 18:57:16 +0200 Subject: [PATCH 6/9] fix(infer): guard Instance creation to optional fields only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the Intersect in infer_instance_member (needed for assignment narrowing: `a = { a = "hello" }` → `a.a` should be `StringConst`). Add `literal_provides_optional_class_field` guard in both `try_narrow_local_to_instance` and `try_narrow_decl_to_instance`: only create Instance when the literal provides at least one field that is declared optional in the class. Without this guard, initial declarations like `local b: B = { field = 1 }` (B.field: integer, non-optional) would have their fields narrowed to `IntegerConst(1)` instead of `Integer`. Co-Authored-By: Claude Sonnet 4.6 --- .../src/semantic/infer/infer_index/mod.rs | 23 +++++---- .../semantic/infer/narrow/get_type_at_flow.rs | 49 ++++++++++++++++--- .../src/semantic/semantic_info/mod.rs | 49 ++++++++++++++++--- 3 files changed, 96 insertions(+), 25 deletions(-) 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 9379d1ec3..f6db62abe 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 @@ -714,16 +714,21 @@ fn infer_instance_member( if table_type.is_nullable() { return Ok(typ); } - // Field has a concrete value — strip nil from the class type. - // Do not intersect with the literal value to avoid narrowing declared - // types (e.g. `integer`) down to their constant (e.g. `IntegerConst(1)`). + // Field has a concrete value — strip nil from the class type, then + // intersect with the literal type to capture the specific assigned type + // (e.g. `string` → `StringConst("hello")` after `a = { a = "hello" }`). let base = TypeOps::Remove.apply(db, &typ, &LuaType::Nil); - return Ok(if let LuaType::TableConst(nested_range) = table_type { - // Nested table: wrap in Instance to preserve literal context - // for recursive member access. - LuaType::Instance(LuaInstanceType::new(base, nested_range).into()) - } else { - base + return Ok(match TypeOps::Intersect.apply(db, &base, &table_type) { + LuaType::Never => { + // Incompatible types: if literal field is a nested table, wrap in + // Instance to preserve 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, }); } Err(InferFailReason::FieldNotFound) => return Ok(typ), 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 1101ccc5e..4cb69e7f1 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 @@ -496,8 +496,13 @@ fn try_infer_decl_initializer_type( Ok(init_type) } -/// If `var_type` is a class type and the declaration's initializer is a non-empty -/// `TableConst`, returns the Instance-narrowed type. Otherwise returns `None`. +/// If `var_type` is a class type, the declaration's initializer is a `TableConst`, and +/// at least one provided field is optional in the class, returns the Instance-narrowed +/// type. Otherwise returns `None`. +/// +/// The optional-field guard prevents wrapping non-optional class declarations in +/// Instance (which would intersect field types with literal constants, narrowing +/// `integer` to `IntegerConst(1)` undesirably for initial declarations). fn try_narrow_decl_to_instance( db: &DbIndex, cache: &mut LuaInferCache, @@ -512,13 +517,41 @@ fn try_narrow_decl_to_instance( let LuaType::TableConst(ref range) = init_type else { return None; }; - let owner = LuaMemberOwner::Element(range.clone()); - if !db - .get_member_index() - .get_members(&owner) - .is_some_and(|m| !m.is_empty()) - { + let literal_owner = LuaMemberOwner::Element(range.clone()); + // Only create Instance when at least one provided literal field corresponds + // to an optional class field — otherwise narrowing brings no benefit. + if !literal_provides_optional_class_field(db, var_type, &literal_owner) { return None; } narrow_down_type(db, var_type.clone(), init_type, Some(var_type.clone())) } + +/// Returns `true` if the table literal (identified by `literal_owner`) provides at least +/// one field that is declared optional (`field?`) in `class_type`. +fn literal_provides_optional_class_field( + db: &DbIndex, + class_type: &LuaType, + literal_owner: &LuaMemberOwner, +) -> bool { + let type_id = match class_type { + LuaType::Ref(id) | LuaType::Def(id) => id, + _ => return false, + }; + let class_owner = LuaMemberOwner::Type(type_id.clone()); + let Some(class_members) = db.get_member_index().get_members(&class_owner) else { + return false; + }; + let Some(literal_members) = db.get_member_index().get_members(literal_owner) else { + return false; + }; + literal_members.iter().any(|lit_member| { + let lit_key = lit_member.get_key(); + class_members.iter().any(|cls_member| { + cls_member.get_key() == lit_key + && db + .get_type_index() + .get_type_cache(&cls_member.get_id().into()) + .is_some_and(|tc| tc.as_type().is_nullable()) + }) + }) +} 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 52054c772..796b1a587 100644 --- a/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs @@ -157,8 +157,13 @@ pub fn infer_node_semantic_info( } } -/// If `typ` is a class type and `decl_id` has a non-empty `TableConst` initializer, -/// returns `Instance(typ, range)`. Otherwise returns `None`. +/// If `typ` is a class type, `decl_id` has a `TableConst` initializer, and at least one +/// provided field is optional in the class, returns `Instance(typ, range)`. +/// Otherwise returns `None`. +/// +/// The optional-field guard prevents wrapping non-optional class declarations in +/// Instance (which would intersect field types with literal constants, narrowing +/// `integer` to `IntegerConst(1)` undesirably for initial declarations). fn try_narrow_local_to_instance( db: &DbIndex, cache: &mut LuaInferCache, @@ -176,12 +181,10 @@ fn try_narrow_local_to_instance( let LuaType::TableConst(range) = infer_expr(db, cache, expr).ok()? else { return None; }; - let owner = LuaMemberOwner::Element(range.clone()); - if !db - .get_member_index() - .get_members(&owner) - .is_some_and(|m| !m.is_empty()) - { + let literal_owner = LuaMemberOwner::Element(range.clone()); + // Only create Instance when at least one provided literal field corresponds + // to an optional class field — otherwise narrowing brings no benefit. + if !literal_provides_optional_class_field(db, typ, &literal_owner) { return None; } Some(LuaType::Instance( @@ -189,6 +192,36 @@ fn try_narrow_local_to_instance( )) } +/// Returns `true` if the table literal (identified by `literal_owner`) provides at least +/// one field that is declared optional (`field?`) in `class_type`. +fn literal_provides_optional_class_field( + db: &DbIndex, + class_type: &LuaType, + literal_owner: &LuaMemberOwner, +) -> bool { + let type_id = match class_type { + LuaType::Ref(id) | LuaType::Def(id) => id, + _ => return false, + }; + let class_owner = LuaMemberOwner::Type(type_id.clone()); + let Some(class_members) = db.get_member_index().get_members(&class_owner) else { + return false; + }; + let Some(literal_members) = db.get_member_index().get_members(literal_owner) else { + return false; + }; + literal_members.iter().any(|lit_member| { + let lit_key = lit_member.get_key(); + class_members.iter().any(|cls_member| { + cls_member.get_key() == lit_key + && db + .get_type_index() + .get_type_cache(&cls_member.get_id().into()) + .is_some_and(|tc| tc.as_type().is_nullable()) + }) + }) +} + fn type_def_tag_info(name: &str, db: &DbIndex, cache: &mut LuaInferCache) -> Option { let type_decl = db .get_type_index() From 0bf026525ae875e2c22f3f480e04e694839b3a6c Mon Sep 17 00:00:00 2001 From: Ozay Date: Thu, 2 Apr 2026 21:56:13 +0200 Subject: [PATCH 7/9] refactor(infer): deduplicate literal_provides_optional_class_field Move the function to narrow/mod.rs (pub(in crate::semantic)), re-export it via infer/mod.rs, and remove the identical copy from both get_type_at_flow.rs and semantic_info/mod.rs. Also fix two issues in write_instance_type (humanize_type.rs): - compute without_nil once instead of three times in the same loop - guard function_vec loop with count < max_display_count instead of count < all_count Co-Authored-By: Claude Sonnet 4.6 --- .../src/db_index/type/humanize_type.rs | 20 +++++------- .../src/semantic/infer/mod.rs | 1 + .../semantic/infer/narrow/get_type_at_flow.rs | 31 +----------------- .../src/semantic/infer/narrow/mod.rs | 32 ++++++++++++++++++- .../src/semantic/semantic_info/mod.rs | 32 +------------------ 5 files changed, 42 insertions(+), 74 deletions(-) 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 a876f2317..b16968805 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 @@ -374,21 +374,17 @@ impl<'a> TypeHumanizer<'a> { 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 — show as optional (strip nil from - // type, but add `?` to the key to signal it was not concretely set). - let without_nil = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); + let is_optional = literal_keys + .get(member_key) + .map_or(typ.is_nullable(), |lit| lit.is_nullable()); + if is_optional || literal_keys.contains_key(member_key) { + // Strip nil: either the field has a concrete literal value or is optional. + let without_nil = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); + if is_optional { self.write_optional_member_field(member_key, &without_nil, saved, w)?; } else { - // Concrete value provided — strip nil and display as required. - let without_nil = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); self.write_table_member_field(member_key, &without_nil, saved, w)?; } - } else if typ.is_nullable() { - // Optional field not provided: show as "name?: type" (without nil) - let without_nil = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil); - self.write_optional_member_field(member_key, &without_nil, saved, w)?; } else { self.write_table_member_field(member_key, typ, saved, w)?; } @@ -398,7 +394,7 @@ impl<'a> TypeHumanizer<'a> { break; } } - if count < all_count { + if count < max_display_count { for function_key in &function_vec { w.write_str(" ")?; write_member_key_and_separator(function_key, saved, w)?; diff --git a/crates/emmylua_code_analysis/src/semantic/infer/mod.rs b/crates/emmylua_code_analysis/src/semantic/infer/mod.rs index 2036d2c9c..ba4e75aee 100644 --- a/crates/emmylua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/infer/mod.rs @@ -27,6 +27,7 @@ use infer_table::infer_table_expr; pub use infer_table::{infer_table_field_value_should_be, infer_table_should_be}; use infer_unary::infer_unary_expr; pub(in crate::semantic) use narrow::ConditionFlowAction; +pub(in crate::semantic) use narrow::literal_provides_optional_class_field; pub use narrow::VarRefId; use rowan::TextRange; 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 4cb69e7f1..a09a7e3cc 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 @@ -520,38 +520,9 @@ fn try_narrow_decl_to_instance( let literal_owner = LuaMemberOwner::Element(range.clone()); // Only create Instance when at least one provided literal field corresponds // to an optional class field — otherwise narrowing brings no benefit. - if !literal_provides_optional_class_field(db, var_type, &literal_owner) { + if !super::literal_provides_optional_class_field(db, var_type, &literal_owner) { return None; } narrow_down_type(db, var_type.clone(), init_type, Some(var_type.clone())) } -/// Returns `true` if the table literal (identified by `literal_owner`) provides at least -/// one field that is declared optional (`field?`) in `class_type`. -fn literal_provides_optional_class_field( - db: &DbIndex, - class_type: &LuaType, - literal_owner: &LuaMemberOwner, -) -> bool { - let type_id = match class_type { - LuaType::Ref(id) | LuaType::Def(id) => id, - _ => return false, - }; - let class_owner = LuaMemberOwner::Type(type_id.clone()); - let Some(class_members) = db.get_member_index().get_members(&class_owner) else { - return false; - }; - let Some(literal_members) = db.get_member_index().get_members(literal_owner) else { - return false; - }; - literal_members.iter().any(|lit_member| { - let lit_key = lit_member.get_key(); - class_members.iter().any(|cls_member| { - cls_member.get_key() == lit_key - && db - .get_type_index() - .get_type_cache(&cls_member.get_id().into()) - .is_some_and(|tc| tc.as_type().is_nullable()) - }) - }) -} diff --git a/crates/emmylua_code_analysis/src/semantic/infer/narrow/mod.rs b/crates/emmylua_code_analysis/src/semantic/infer/narrow/mod.rs index 4bd3de91d..831ee412a 100644 --- a/crates/emmylua_code_analysis/src/semantic/infer/narrow/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/infer/narrow/mod.rs @@ -6,7 +6,7 @@ mod var_ref_id; use crate::{ CacheEntry, DbIndex, FlowAntecedent, FlowId, FlowNode, FlowTree, InferFailReason, - LuaInferCache, LuaType, infer_param, + LuaInferCache, LuaMemberOwner, LuaType, infer_param, semantic::infer::{ InferResult, infer_name::{find_decl_member_type, infer_global_type}, @@ -102,3 +102,33 @@ pub enum ResultTypeOrContinue { Result(LuaType), Continue, } + +/// Returns `true` if the table literal (identified by `literal_owner`) provides at least +/// one field that is declared optional (`field?`) in `class_type`. +pub(in crate::semantic) fn literal_provides_optional_class_field( + db: &DbIndex, + class_type: &LuaType, + literal_owner: &LuaMemberOwner, +) -> bool { + let type_id = match class_type { + LuaType::Ref(id) | LuaType::Def(id) => id, + _ => return false, + }; + let class_owner = LuaMemberOwner::Type(type_id.clone()); + let Some(class_members) = db.get_member_index().get_members(&class_owner) else { + return false; + }; + let Some(literal_members) = db.get_member_index().get_members(literal_owner) else { + return false; + }; + literal_members.iter().any(|lit_member| { + let lit_key = lit_member.get_key(); + class_members.iter().any(|cls_member| { + cls_member.get_key() == lit_key + && db + .get_type_index() + .get_type_cache(&cls_member.get_id().into()) + .is_some_and(|tc| tc.as_type().is_nullable()) + }) + }) +} 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 796b1a587..b3dcc7914 100644 --- a/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/semantic_info/mod.rs @@ -16,7 +16,7 @@ pub use resolve_global_decl::resolve_global_decl_id; pub use semantic_decl_level::SemanticDeclLevel; pub use semantic_guard::SemanticDeclGuard; -use super::{LuaInferCache, infer_expr}; +use super::{LuaInferCache, infer::literal_provides_optional_class_field, infer_expr}; #[derive(Debug, Clone, PartialEq)] pub struct SemanticInfo { @@ -192,36 +192,6 @@ fn try_narrow_local_to_instance( )) } -/// Returns `true` if the table literal (identified by `literal_owner`) provides at least -/// one field that is declared optional (`field?`) in `class_type`. -fn literal_provides_optional_class_field( - db: &DbIndex, - class_type: &LuaType, - literal_owner: &LuaMemberOwner, -) -> bool { - let type_id = match class_type { - LuaType::Ref(id) | LuaType::Def(id) => id, - _ => return false, - }; - let class_owner = LuaMemberOwner::Type(type_id.clone()); - let Some(class_members) = db.get_member_index().get_members(&class_owner) else { - return false; - }; - let Some(literal_members) = db.get_member_index().get_members(literal_owner) else { - return false; - }; - literal_members.iter().any(|lit_member| { - let lit_key = lit_member.get_key(); - class_members.iter().any(|cls_member| { - cls_member.get_key() == lit_key - && db - .get_type_index() - .get_type_cache(&cls_member.get_id().into()) - .is_some_and(|tc| tc.as_type().is_nullable()) - }) - }) -} - fn type_def_tag_info(name: &str, db: &DbIndex, cache: &mut LuaInferCache) -> Option { let type_decl = db .get_type_index() From d1682cc2f6e766d9367ec061cb0ee4723a420603 Mon Sep 17 00:00:00 2001 From: Ozay Date: Thu, 2 Apr 2026 22:17:49 +0200 Subject: [PATCH 8/9] refactor(humanize): avoid unnecessary sort for literal key lookup Replace get_sorted_members with get_members when building the literal_keys HashMap in write_instance_type, since ordering is irrelevant once collected into a map. Also clarify the doc comment to reflect that nil is only stripped for non-nullable literal values. Co-Authored-By: Claude Sonnet 4.6 --- .../src/db_index/type/humanize_type.rs | 7 ++++--- crates/emmylua_code_analysis/src/semantic/infer/mod.rs | 2 +- .../src/semantic/infer/narrow/get_type_at_flow.rs | 1 - 3 files changed, 5 insertions(+), 5 deletions(-) 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 b16968805..46c2c28a3 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 @@ -274,8 +274,9 @@ impl<'a> TypeHumanizer<'a> { // ─── 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. + /// Fields present in the literal with non-nullable values have their nil stripped, + /// while fields with nullable literal values (e.g. `nil` or `cond and 1`) + /// and 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(); @@ -310,7 +311,7 @@ impl<'a> TypeHumanizer<'a> { 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) + .get_members(&literal_owner) .map(|members| { members .iter() diff --git a/crates/emmylua_code_analysis/src/semantic/infer/mod.rs b/crates/emmylua_code_analysis/src/semantic/infer/mod.rs index ba4e75aee..eeb03117b 100644 --- a/crates/emmylua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/infer/mod.rs @@ -27,8 +27,8 @@ use infer_table::infer_table_expr; pub use infer_table::{infer_table_field_value_should_be, infer_table_should_be}; use infer_unary::infer_unary_expr; pub(in crate::semantic) use narrow::ConditionFlowAction; -pub(in crate::semantic) use narrow::literal_provides_optional_class_field; pub use narrow::VarRefId; +pub(in crate::semantic) use narrow::literal_provides_optional_class_field; use rowan::TextRange; use smol_str::SmolStr; 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 a09a7e3cc..1a04aed7c 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 @@ -525,4 +525,3 @@ fn try_narrow_decl_to_instance( } narrow_down_type(db, var_type.clone(), init_type, Some(var_type.clone())) } - From 69032e9d3593cd76a2f0dccee15bc35252756e25 Mon Sep 17 00:00:00 2001 From: Ozay Date: Fri, 3 Apr 2026 20:05:12 +0200 Subject: [PATCH 9/9] fix(infer): use explicit import for literal_provides_optional_class_field Follow-up to 0bf02652: the function was extracted to the parent module but the call site still used `super::` instead of the now-explicit import. --- .../src/semantic/infer/narrow/get_type_at_flow.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1a04aed7c..688ce1a06 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 @@ -17,7 +17,7 @@ use crate::{ }, get_multi_antecedents, get_single_antecedent, get_type_at_cast_flow::get_type_at_cast_flow, - get_var_ref_type, narrow_down_type, + get_var_ref_type, literal_provides_optional_class_field, narrow_down_type, var_ref_id::get_var_expr_var_ref_id, }, }, @@ -520,7 +520,7 @@ fn try_narrow_decl_to_instance( let literal_owner = LuaMemberOwner::Element(range.clone()); // Only create Instance when at least one provided literal field corresponds // to an optional class field — otherwise narrowing brings no benefit. - if !super::literal_provides_optional_class_field(db, var_type, &literal_owner) { + if !literal_provides_optional_class_field(db, var_type, &literal_owner) { return None; } narrow_down_type(db, var_type.clone(), init_type, Some(var_type.clone()))