From 51ad3501f24ebce1e5296d0ee5b76abc10618d58 Mon Sep 17 00:00:00 2001 From: Ozay Date: Thu, 19 Mar 2026 18:21:22 +0100 Subject: [PATCH 1/3] 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/3] 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/3] 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)