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 5902b6390..c58e3db3f 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 @@ -325,6 +325,110 @@ mod test { ); } + #[test] + fn test_optional_field_narrowed_by_table_literal() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@class NarrowFieldTest + ---@field a? integer + ---@field b? integer + + ---@type NarrowFieldTest + local test = { a = 1 } + c = test.a + d = test.b + "#, + ); + + let c_ty = ws.expr_ty("c"); + assert!( + matches!(c_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type for provided field, got {:?}", + c_ty + ); + + // b is not provided in the literal, should remain integer? (nullable) + let d_ty = ws.expr_ty("d"); + let expected = + LuaType::Union(LuaUnionType::from_vec(vec![LuaType::Integer, LuaType::Nil]).into()); + assert_eq!(d_ty, expected, "expected integer? for unprovided field"); + } + + #[test] + fn test_nil_literal_preserves_nullable() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@class NilFieldTest + ---@field a? integer + + ---@type NilFieldTest + local test = { a = nil } + x = test.a + "#, + ); + + let x_ty = ws.expr_ty("x"); + let expected = + LuaType::Union(LuaUnionType::from_vec(vec![LuaType::Integer, LuaType::Nil]).into()); + assert_eq!(x_ty, expected, "{{a = nil}} should keep a as integer?"); + } + + #[test] + fn test_recursive_instance_member_resolution() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@class DeepInner + ---@field c integer + + ---@class DeepMiddle + ---@field b? DeepInner + + ---@class DeepOuter + ---@field a? DeepMiddle + + ---@type DeepOuter + local test = { a = { b = { c = 1 } } } + x = test.a.b.c + "#, + ); + + let x_ty = ws.expr_ty("x"); + assert!( + matches!(x_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type for deeply nested field, got {:?}", + x_ty + ); + } + + #[test] + fn test_optional_class_field_narrowed_by_table_literal() { + let mut ws = VirtualWorkspace::new(); + + ws.def( + r#" + ---@class InnerClass + ---@field b integer + + ---@class OuterClass + ---@field a? InnerClass + + ---@type OuterClass + local test = { a = { b = 1 } } + c = test.a + "#, + ); + + let c_ty = ws.expr_ty("c"); + assert!( + matches!(c_ty, LuaType::Ref(_) | LuaType::Instance(_)), + "expected InnerClass (non-nullable, possibly Instance) for provided optional field, got {:?}", + c_ty + ); + } + #[test] fn test_union_member_access_preserves_never() { let mut ws = VirtualWorkspace::new(); 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..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 @@ -1,13 +1,13 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fmt::{self, Write}; use itertools::Itertools; use crate::{ AsyncState, DbIndex, LuaAliasCallType, LuaConditionalType, LuaFunctionType, LuaGenericType, - LuaIntersectionType, LuaMemberKey, LuaMemberOwner, LuaObjectType, LuaSignatureId, - LuaStringTplType, LuaTupleType, LuaType, LuaTypeDeclId, LuaUnionType, TypeSubstitutor, - VariadicType, + LuaInstanceType, LuaIntersectionType, LuaMemberKey, LuaMemberOwner, LuaObjectType, + LuaSignatureId, LuaStringTplType, LuaTupleType, LuaType, LuaTypeDeclId, LuaUnionType, TypeOps, + TypeSubstitutor, VariadicType, }; use super::{LuaAliasCallKind, LuaMultiLineUnion}; @@ -201,7 +201,7 @@ impl<'a> TypeHumanizer<'a> { LuaType::TplRef(tpl) => w.write_str(tpl.get_name()), LuaType::StrTplRef(str_tpl) => self.write_str_tpl_ref_type(str_tpl, w), LuaType::Variadic(multi) => self.write_variadic_type(multi, w), - LuaType::Instance(ins) => self.write_type_inner(ins.get_base(), w), + LuaType::Instance(ins) => self.write_instance_type(ins, w), LuaType::Signature(signature_id) => self.write_signature_type(signature_id, w), LuaType::Namespace(ns) => write!(w, "{{ {} }}", ns), LuaType::MultiLineUnion(multi_union) => { @@ -271,6 +271,152 @@ 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 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(); + + // Extract the type decl id from the base type + let type_id = match base { + LuaType::Ref(id) | LuaType::Def(id) => id.clone(), + _ => return self.write_type_inner(base, w), + }; + + let type_decl = match self.db.get_type_index().get_type_decl(&type_id) { + Some(decl) => decl, + None => return self.write_type_inner(base, w), + }; + + let name = type_decl.get_full_name().to_string(); + + let max_display_count = match self.level.max_display_count() { + Some(n) => n, + None => { + w.write_str(&name)?; + return Ok(()); + } + }; + + // cycle detection + if !self.visited.insert(type_id.clone()) { + w.write_str(&name)?; + return Ok(()); + } + + // Collect keys present in the table literal, along with their types + let literal_owner = LuaMemberOwner::Element(ins.get_range().clone()); + let member_index = self.db.get_member_index(); + let literal_keys: HashMap = member_index + .get_members(&literal_owner) + .map(|members| { + members + .iter() + .map(|m| { + let ty = self + .db + .get_type_index() + .get_type_cache(&m.get_id().into()) + .map(|tc| tc.as_type().clone()) + .unwrap_or(LuaType::Any); + (m.get_key().clone(), ty) + }) + .collect() + }) + .unwrap_or_default(); + + // Get class members + let class_owner = LuaMemberOwner::Type(type_id.clone()); + let members = match member_index.get_sorted_members(&class_owner) { + Some(m) => m, + None => { + self.visited.remove(&type_id); + w.write_str(&name)?; + return Ok(()); + } + }; + + let mut member_vec = Vec::new(); + let mut function_vec = Vec::new(); + for member in members { + let member_key = member.get_key(); + let type_cache = self + .db + .get_type_index() + .get_type_cache(&member.get_id().into()); + let type_cache = match type_cache { + Some(type_cache) => type_cache, + None => &super::LuaTypeCache::InferType(LuaType::Any), + }; + if type_cache.is_function() { + function_vec.push(member_key); + } else { + member_vec.push((member_key, type_cache.as_type())); + } + } + + if member_vec.is_empty() && function_vec.is_empty() { + self.visited.remove(&type_id); + w.write_str(&name)?; + return Ok(()); + } + + let all_count = member_vec.len() + function_vec.len(); + + w.write_str(&name)?; + w.write_str(" {\n")?; + + let saved = self.level; + self.level = self.child_level(); + + let mut count = 0; + for (member_key, typ) in &member_vec { + w.write_str(" ")?; + 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 { + self.write_table_member_field(member_key, &without_nil, 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 < max_display_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 +1170,32 @@ impl<'a> TypeHumanizer<'a> { } } + /// Write an optional member field as "name?: type" (with ? after name, nil stripped from type). + fn write_optional_member_field( + &mut self, + member_key: &LuaMemberKey, + ty: &LuaType, + parent_level: RenderLevel, + w: &mut W, + ) -> fmt::Result { + match member_key { + LuaMemberKey::Name(name) => { + w.write_str(name)?; + w.write_str("?")?; + let separator = if parent_level == RenderLevel::Detailed { + ": " + } else { + " = " + }; + w.write_str(separator)?; + } + _ => { + write_member_key_and_separator(member_key, parent_level, w)?; + } + } + self.write_type(ty, w) + } + // ─── helper: write a table member (key: type) ─────────────────── fn write_table_member_field( diff --git a/crates/emmylua_code_analysis/src/db_index/type/types.rs b/crates/emmylua_code_analysis/src/db_index/type/types.rs index 4678f858e..f4c51921b 100644 --- a/crates/emmylua_code_analysis/src/db_index/type/types.rs +++ b/crates/emmylua_code_analysis/src/db_index/type/types.rs @@ -231,6 +231,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 57a820997..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 @@ -709,8 +709,25 @@ fn infer_instance_member( match base_result { Ok(typ) => match infer_table_member(db, cache, range.clone(), index_expr.clone()) { Ok(table_type) => { - return Ok(match TypeOps::Intersect.apply(db, &typ, &table_type) { - LuaType::Never => typ, + // If the literal value is nullable (e.g. `a = nil`), the field + // is effectively unset — keep the original (nullable) class type. + if table_type.is_nullable() { + return Ok(typ); + } + // Field has a concrete value — strip nil from the class type, 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(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, }); } diff --git a/crates/emmylua_code_analysis/src/semantic/infer/mod.rs b/crates/emmylua_code_analysis/src/semantic/infer/mod.rs index 2036d2c9c..eeb03117b 100644 --- a/crates/emmylua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/infer/mod.rs @@ -28,6 +28,7 @@ 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 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 0699c2a14..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 @@ -4,7 +4,8 @@ use emmylua_parser::{LuaAssignStat, LuaAstNode, LuaChunk, LuaExpr, LuaVarExpr}; use crate::{ CacheEntry, DbIndex, FlowId, FlowNode, FlowNodeKind, FlowTree, InferFailReason, LuaDeclId, - LuaInferCache, LuaMemberId, LuaSignatureId, LuaType, TypeOps, check_type_compact, infer_expr, + LuaInferCache, LuaMemberId, LuaMemberOwner, LuaSignatureId, LuaType, TypeOps, + check_type_compact, infer_expr, semantic::{ infer::{ InferResult, VarRefId, infer_expr_list_value_type_at, @@ -16,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, }, }, @@ -170,7 +171,10 @@ fn get_type_at_flow_internal( if *position <= var_ref_id.get_position() { match get_var_ref_type(db, cache, var_ref_id) { Ok(var_type) => { - result_type = var_type; + result_type = try_narrow_decl_to_instance( + db, cache, root, var_ref_id, &var_type, + ) + .unwrap_or(var_type); break; } Err(err) => { @@ -491,3 +495,33 @@ fn try_infer_decl_initializer_type( Ok(init_type) } + +/// 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, + root: &LuaChunk, + var_ref_id: &VarRefId, + var_type: &LuaType, +) -> Option { + if !var_type.is_class_type(db) { + return None; + } + let init_type = try_infer_decl_initializer_type(db, cache, root, var_ref_id).ok()??; + let LuaType::TableConst(ref range) = init_type else { + return None; + }; + 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())) +} 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 8a3faa5f4..b3dcc7914 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, LuaMemberOwner, + LuaSemanticDeclId, LuaType, LuaTypeCache, TypeOps, }; use emmylua_parser::{ LuaAstNode, LuaAstToken, LuaDocNameType, LuaDocTag, LuaExpr, LuaLocalName, LuaParamName, @@ -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 { @@ -38,8 +38,21 @@ pub fn infer_token_semantic_info( .get_type_index() .get_type_cache(&decl_id.into()) .unwrap_or(&LuaTypeCache::InferType(LuaType::Unknown)); + let mut typ = type_cache.as_type().clone(); + + // Only narrow LocalName declarations — ForStat/ForRangeStat cannot have + // table literal initializers. + if matches!(parent.kind().into(), LuaSyntaxKind::LocalName) { + let root = parent.ancestors().last()?; + if let Some(narrowed) = + try_narrow_local_to_instance(db, cache, &typ, &decl_id, &root) + { + typ = narrowed; + } + } + Some(SemanticInfo { - typ: type_cache.as_type().clone(), + typ, semantic_decl: Some(LuaSemanticDeclId::LuaDecl(decl_id)), }) } @@ -144,6 +157,41 @@ pub fn infer_node_semantic_info( } } +/// 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, + 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 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( + 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() 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(()) + } }