Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
130 changes: 126 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
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,108 @@ mod test {
value_ty
);
}

#[test]
fn test_optional_field_narrowed_by_table_literal() {
let mut ws = VirtualWorkspace::new();
ws.def(
r#"
---@class NarrowFieldTest
---@field a? integer
---@field b? integer

---@type NarrowFieldTest
local test = { a = 1 }
c = test.a
d = test.b
"#,
);

let c_ty = ws.expr_ty("c");
assert!(
matches!(c_ty, LuaType::Integer | LuaType::IntegerConst(_)),
"expected integer type for provided field, got {:?}",
c_ty
);

// b is not provided in the literal, should remain integer? (nullable)
let d_ty = ws.expr_ty("d");
let expected =
LuaType::Union(LuaUnionType::from_vec(vec![LuaType::Integer, LuaType::Nil]).into());
assert_eq!(d_ty, expected, "expected integer? for unprovided field");
}

#[test]
fn test_nil_literal_preserves_nullable() {
let mut ws = VirtualWorkspace::new();
ws.def(
r#"
---@class NilFieldTest
---@field a? integer

---@type NilFieldTest
local test = { a = nil }
x = test.a
"#,
);

let x_ty = ws.expr_ty("x");
let expected =
LuaType::Union(LuaUnionType::from_vec(vec![LuaType::Integer, LuaType::Nil]).into());
assert_eq!(x_ty, expected, "{{a = nil}} should keep a as integer?");
}

#[test]
fn test_recursive_instance_member_resolution() {
let mut ws = VirtualWorkspace::new();
ws.def(
r#"
---@class DeepInner
---@field c integer

---@class DeepMiddle
---@field b? DeepInner

---@class DeepOuter
---@field a? DeepMiddle

---@type DeepOuter
local test = { a = { b = { c = 1 } } }
x = test.a.b.c
"#,
);

let x_ty = ws.expr_ty("x");
assert!(
matches!(x_ty, LuaType::Integer | LuaType::IntegerConst(_)),
"expected integer type for deeply nested field, got {:?}",
x_ty
);
}

#[test]
fn test_optional_class_field_narrowed_by_table_literal() {
let mut ws = VirtualWorkspace::new();

ws.def(
r#"
---@class InnerClass
---@field b integer

---@class OuterClass
---@field a? InnerClass

---@type OuterClass
local test = { a = { b = 1 } }
c = test.a
"#,
);

let c_ty = ws.expr_ty("c");
assert!(
matches!(c_ty, LuaType::Ref(_) | LuaType::Instance(_)),
"expected InnerClass (non-nullable, possibly Instance) for provided optional field, got {:?}",
c_ty
);
}
}
184 changes: 179 additions & 5 deletions crates/emmylua_code_analysis/src/db_index/type/humanize_type.rs
Original file line number Diff line number Diff line change
@@ -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};
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,154 @@ impl<'a> TypeHumanizer<'a> {
w.write_char('>')
}

// ─── Instance (narrowed struct view) ───────────────────────────

/// Writes an Instance type: a class type narrowed by a table literal.
/// Fields present in the literal have their nil stripped (not optional),
/// while absent fields retain their original (possibly optional) type.
fn write_instance_type<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, 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<LuaMemberKey, LuaType> = member_index
.get_sorted_members(&literal_owner)
.map(|members| {
members
.iter()
.map(|m| {
let ty = self
.db
.get_type_index()
.get_type_cache(&m.get_id().into())
.map(|tc| tc.as_type().clone())
.unwrap_or(LuaType::Any);
(m.get_key().clone(), ty)
})
.collect()
})
.unwrap_or_default();

// Get class members
let class_owner = LuaMemberOwner::Type(type_id.clone());
let members = match member_index.get_sorted_members(&class_owner) {
Some(m) => m,
None => {
self.visited.remove(&type_id);
w.write_str(&name)?;
return Ok(());
}
};

let mut member_vec = Vec::new();
let mut function_vec = Vec::new();
for member in members {
let member_key = member.get_key();
let type_cache = self
.db
.get_type_index()
.get_type_cache(&member.get_id().into());
let type_cache = match type_cache {
Some(type_cache) => type_cache,
None => &super::LuaTypeCache::InferType(LuaType::Any),
};
if type_cache.is_function() {
function_vec.push(member_key);
} else {
member_vec.push((member_key, type_cache.as_type()));
}
}

if member_vec.is_empty() && function_vec.is_empty() {
self.visited.remove(&type_id);
w.write_str(&name)?;
return Ok(());
}

let all_count = member_vec.len() + function_vec.len();

w.write_str(&name)?;
w.write_str(" {\n")?;

let saved = self.level;
self.level = self.child_level();

let mut count = 0;
for (member_key, typ) in &member_vec {
w.write_str(" ")?;
if let Some(literal_ty) = literal_keys.get(member_key) {
if literal_ty.is_nullable() {
// Literal value is nil/nullable — keep optional display
let stripped = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil);
self.write_optional_member_field(member_key, &stripped, saved, w)?;
} else {
// Field provided with concrete value: strip nil
let narrowed = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil);
self.write_table_member_field(member_key, &narrowed, saved, w)?;
}
} else if typ.is_nullable() {
// Optional field not provided: show as "name?: type" (without nil)
let stripped = TypeOps::Remove.apply(self.db, typ, &LuaType::Nil);
self.write_optional_member_field(member_key, &stripped, saved, w)?;
} else {
self.write_table_member_field(member_key, typ, saved, w)?;
}
w.write_str(",\n")?;
count += 1;
if count >= max_display_count {
break;
}
}
if count < all_count {
for function_key in &function_vec {
w.write_str(" ")?;
write_member_key_and_separator(function_key, saved, w)?;
w.write_str("function,\n")?;
count += 1;
if count >= max_display_count {
break;
}
}
}
if count >= max_display_count {
writeln!(w, " ...(+{})", all_count - max_display_count)?;
}

self.level = saved;
self.visited.remove(&type_id);

w.write_char('}')?;
Ok(())
}

// ─── Simple (expanded struct view) ──────────────────────────────

/// Tries to write an expanded view of a named type (struct-like fields).
Expand Down Expand Up @@ -1024,6 +1172,32 @@ impl<'a> TypeHumanizer<'a> {
}
}

/// Write an optional member field as "name?: type" (with ? after name, nil stripped from type).
fn write_optional_member_field<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
10 changes: 10 additions & 0 deletions crates/emmylua_code_analysis/src/db_index/type/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
19 changes: 17 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,23 @@ fn infer_instance_member(
match base_result {
Ok(typ) => match infer_table_member(db, cache, range.clone(), index_expr.clone()) {
Ok(table_type) => {
return Ok(match TypeOps::Intersect.apply(db, &typ, &table_type) {
LuaType::Never => typ,
// If the literal value is nullable (e.g. `a = nil`), the field
// is effectively unset — keep the original (nullable) class type.
if table_type.is_nullable() {
return Ok(typ);
}
// Field has a concrete value — strip nil from the class type.
let base = TypeOps::Remove.apply(db, &typ, &LuaType::Nil);
return Ok(match TypeOps::Intersect.apply(db, &base, &table_type) {
LuaType::Never => {
// If the literal field is itself a table, wrap in Instance
// to preserve literal context for recursive member access.
if let LuaType::TableConst(nested_range) = table_type {
LuaType::Instance(LuaInstanceType::new(base, nested_range).into())
} else {
base
}
}
intersected => intersected,
});
}
Expand Down
Loading
Loading