Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 108 additions & 4 deletions crates/emmylua_code_analysis/src/compilation/test/member_infer_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {:?}",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why match LuaType::Integer | LuaType::IntegerConst?

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]
Expand Down Expand Up @@ -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(_)),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why keep two type match?

Copy link
Copy Markdown
Author

@NeOzay NeOzay Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, the two variants are there because the current inference intersects the declared type with the literal value (e.g. { a = 1 } → IntegerConst(1) instead of Integer).
Should accessing a provided field return the declared type or the narrowed literal constant?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The returned type must be one of these; the | here indicates you're not sure which one it is, and that shouldn't happen in tests.

"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 {:?}",
Comment thread
NeOzay marked this conversation as resolved.
Outdated
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
);
}
}
163 changes: 159 additions & 4 deletions crates/emmylua_code_analysis/src/db_index/type/humanize_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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.
Comment thread
NeOzay marked this conversation as resolved.
Outdated
fn write_instance_type<W: Write>(&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<LuaMemberKey> = member_index
.get_sorted_members(&literal_owner)
Comment thread
NeOzay marked this conversation as resolved.
Outdated
.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).
Expand Down Expand Up @@ -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<W: Write>(
&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<W: Write>(
Expand Down
16 changes: 14 additions & 2 deletions crates/emmylua_code_analysis/src/semantic/infer/infer_index/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
NeOzay marked this conversation as resolved.
Outdated
}
}
intersected => intersected,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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())
}
Loading
Loading