From 83985e976fb99525abfdafe10fdaa5e798a05947 Mon Sep 17 00:00:00 2001 From: "Henning P. Schmiedehausen" Date: Tue, 13 Oct 2026 11:24:26 -0700 Subject: [PATCH 1/2] fix(check): resolve cross-file enum runtime members --- .../src/check/checker/undefined_field.rs | 67 +++++++++++-------- .../src/check/test/undefined_field_test.rs | 59 ++++++++++++++++ 2 files changed, 99 insertions(+), 27 deletions(-) diff --git a/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs b/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs index 4345343d9..fd72e276e 100644 --- a/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs +++ b/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs @@ -11,7 +11,9 @@ use emmylua_parser::{LuaAstNode, LuaExpr, LuaIndexExpr, LuaIndexKey, LuaSyntaxKi use rowan::TextRange; use crate::DiagnosticCode; -use crate::semantic_db::def::{Decl, DeclKind, ModuleExport, TypeDef, TypeDefKind, TypeVisibility}; +use crate::semantic_db::def::{ + Decl, DeclKind, ModuleExport, SemanticId, TypeDef, TypeDefKind, TypeVisibility, +}; use crate::semantic_model::SemanticModel; use crate::semantic_model::type_check::is_compatible; use crate::{FileId, InFiled, LuaMemberKey, LuaType, LuaTypeDeclId, TypeScope}; @@ -253,10 +255,10 @@ fn valid_named_member( if let Some(def) = named_type_def(semantic_model, &prefix_ty) && def.kind == TypeDefKind::Enum { - let direct_enum_table = prefix_directly_names_decl(semantic_model, prefix); - if !direct_enum_table { - return false; - } + // Enum values have no fields; their runtime table has the constructor's + // keys. Validate that surface before permissive conditional-key handling. + return enum_runtime_decl(semantic_model, prefix) + .is_some_and(|decl| enum_table_access(semantic_model, &decl, &def, index_key)); } let key_ty = key_type(semantic_model, index_key); @@ -301,6 +303,19 @@ fn enum_table_access( if matches!(index_key, LuaIndexKey::Expr(_)) { return true; } + // Constructor members are owned by the table syntax, not by its variable + // declaration. Query that runtime surface in the declaration's source file. + if let Some(value) = decl.value_expr_syntax + && let Some(key) = static_member_key(semantic_model, index_key) + && semantic_model + .member_info( + &LuaType::TableConst(InFiled::new(decl.file_id, value.get_range())), + &key, + ) + .is_some() + { + return true; + } let key_text = index_key.get_path_part(); let mut accepted: Vec = Vec::new(); for member_ref in semantic_model.members_of_owner(&decl.id).iter() { @@ -309,7 +324,7 @@ fn enum_table_access( && let Some(member) = facts.member_by_id(&member_ref.id) && let Some(value_syntax) = member.value_syntax && let Some(node) = semantic_model - .syntax_tree() + .syntax_tree_of(member_ref.file_id) .and_then(|tree| value_syntax.to_node_from_root(&tree.get_red_root())) { let raw = node.text().to_string(); @@ -530,31 +545,29 @@ fn is_mapped_alias(semantic_model: &SemanticModel<'_>, def: &TypeDef) -> bool { ) } -/// Whether the prefix expression directly references an enum runtime table (`Enum.A`) rather than an enum-typed variable (`p.a`). +/// Resolve the declaration of an enum runtime table (`Enum.A`), excluding enum-typed values (`p.a`). /// Both global and local enum runtime tables count as direct references; pure `---@type` variables do not. -fn prefix_directly_names_decl(semantic_model: &SemanticModel<'_>, prefix: &LuaExpr) -> bool { +fn enum_runtime_decl(semantic_model: &SemanticModel<'_>, prefix: &LuaExpr) -> Option { let LuaExpr::NameExpr(name_expr) = prefix else { - return false; + return None; }; - let Some(decl_id) = semantic_model.resolve_name(name_expr.get_position()) else { - return false; + let decl_id = semantic_model.resolve_name(name_expr.get_position())?; + let SemanticId::Decl(decl_key) = &decl_id else { + return None; }; - semantic_model - .file_facts() - .and_then(|facts| facts.decl_by_id(&decl_id)) - .is_some_and(|decl| { - decl.doc_type_syntax.is_none() - && matches!(decl.kind, DeclKind::Global | DeclKind::Local { .. }) - && decl.value_expr_syntax.is_some_and(|syntax| { - let Some(tree) = semantic_model.syntax_tree_of(decl.file_id) else { - return false; - }; - let Some(node) = syntax.to_node_from_root(&tree.get_red_root()) else { - return false; - }; - matches!(LuaExpr::cast(node), Some(LuaExpr::TableExpr(_))) - }) - }) + // Globals can resolve to a declaration in another file. Use that file's + // facts to distinguish the enum's runtime table from an enum-typed value. + let facts = semantic_model.file_facts_of(decl_key.file_id)?; + let decl = facts.decl_by_id(&decl_id)?; + if decl.doc_type_syntax.is_some() + || !matches!(decl.kind, DeclKind::Global | DeclKind::Local { .. }) + { + return None; + } + let syntax = decl.value_expr_syntax?; + let tree = semantic_model.syntax_tree_of(decl.file_id)?; + let node = syntax.to_node_from_root(&tree.get_red_root())?; + matches!(LuaExpr::cast(node), Some(LuaExpr::TableExpr(_))).then(|| decl.clone()) } /// Generic parameter projection fallback (`Ref("T")`): replace with the constraint type declared in the signature doc. diff --git a/crates/emmylua_code_analysis/src/check/test/undefined_field_test.rs b/crates/emmylua_code_analysis/src/check/test/undefined_field_test.rs index 023203958..e1b3c9f25 100644 --- a/crates/emmylua_code_analysis/src/check/test/undefined_field_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/undefined_field_test.rs @@ -1188,3 +1188,62 @@ function KPlayer.GetPlayerObjById() end", )); } } + +#[googletest::gtest] +fn enum_runtime_cross_file_global_members() { + use crate::{DiagnosticCode, VirtualWorkspace}; + use googletest::prelude::*; + let mut ws = VirtualWorkspace::new(); + ws.def_file( + "constants.lua", + r#" + ---@enum transport.DistanceResult + DISTANCE_RESULT = { AVAILABLE = 0, UNREACHABLE = -2 } + "#, + ); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::UndefinedField, + r#" + local a = DISTANCE_RESULT.UNREACHABLE + local b = DISTANCE_RESULT['AVAILABLE'] + return a, b + "# + ), + eq(true) + ); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::UndefinedField, + "return DISTANCE_RESULT.TYPO" + ), + eq(false) + ); +} + +#[googletest::gtest] +fn enum_runtime_cross_file_values_are_not_runtime_tables() { + use crate::{DiagnosticCode, VirtualWorkspace}; + use googletest::prelude::*; + let mut ws = VirtualWorkspace::new(); + ws.def_file( + "constants.lua", + r#" + ---@enum transport.DistanceResult + DISTANCE_RESULT = { AVAILABLE = 0, UNREACHABLE = -2 } + ---@type transport.DistanceResult + CURRENT_DISTANCE = DISTANCE_RESULT.AVAILABLE + "#, + ); + for source in [ + "return CURRENT_DISTANCE.UNREACHABLE", + "---@param result transport.DistanceResult\nlocal function f(result) return result.UNREACHABLE end", + "---@type transport.DistanceResult\nlocal DISTANCE_RESULT = 0\nreturn DISTANCE_RESULT.UNREACHABLE", + ] { + assert_that!( + ws.has_no_diagnostic(DiagnosticCode::UndefinedField, source), + eq(false), + "{source}" + ); + } +} From 81d89c9c09681dcf9af6bad055607945a1fffd25 Mon Sep 17 00:00:00 2001 From: "Henning P. Schmiedehausen" Date: Tue, 13 Oct 2026 11:52:22 -0700 Subject: [PATCH 2/2] refactor(check): borrow enum declarations and cover local tables --- .../src/check/checker/undefined_field.rs | 19 +++++-- .../src/check/test/undefined_field_test.rs | 55 +++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs b/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs index fd72e276e..dc24bc98c 100644 --- a/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs +++ b/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs @@ -255,10 +255,10 @@ fn valid_named_member( if let Some(def) = named_type_def(semantic_model, &prefix_ty) && def.kind == TypeDefKind::Enum { - // Enum values have no fields; their runtime table has the constructor's - // keys. Validate that surface before permissive conditional-key handling. + // Only the enum's runtime table exposes constructor keys. An enum-typed + // value resolves to None and remains invalid, including in conditions. return enum_runtime_decl(semantic_model, prefix) - .is_some_and(|decl| enum_table_access(semantic_model, &decl, &def, index_key)); + .is_some_and(|decl| enum_table_access(semantic_model, decl, &def, index_key)); } let key_ty = key_type(semantic_model, index_key); @@ -299,7 +299,8 @@ fn enum_table_access( _def: &TypeDef, index_key: &LuaIndexKey, ) -> bool { - // Dynamic keys (parameter enum type / expression) are allowed broadly. + // Preserve permissive handling of expression keys before static lookup; + // static_member_key below is never asked to resolve a dynamic expression. if matches!(index_key, LuaIndexKey::Expr(_)) { return true; } @@ -316,6 +317,9 @@ fn enum_table_access( { return true; } + // Keep the existing declaration-owned member/value fallback for entries + // attached outside the constructor. Constructor keys take precedence above; + // this fallback preserves the checker's prior acceptance policy. let key_text = index_key.get_path_part(); let mut accepted: Vec = Vec::new(); for member_ref in semantic_model.members_of_owner(&decl.id).iter() { @@ -547,7 +551,10 @@ fn is_mapped_alias(semantic_model: &SemanticModel<'_>, def: &TypeDef) -> bool { /// Resolve the declaration of an enum runtime table (`Enum.A`), excluding enum-typed values (`p.a`). /// Both global and local enum runtime tables count as direct references; pure `---@type` variables do not. -fn enum_runtime_decl(semantic_model: &SemanticModel<'_>, prefix: &LuaExpr) -> Option { +fn enum_runtime_decl<'db>( + semantic_model: &SemanticModel<'db>, + prefix: &LuaExpr, +) -> Option<&'db Decl> { let LuaExpr::NameExpr(name_expr) = prefix else { return None; }; @@ -567,7 +574,7 @@ fn enum_runtime_decl(semantic_model: &SemanticModel<'_>, prefix: &LuaExpr) -> Op let syntax = decl.value_expr_syntax?; let tree = semantic_model.syntax_tree_of(decl.file_id)?; let node = syntax.to_node_from_root(&tree.get_red_root())?; - matches!(LuaExpr::cast(node), Some(LuaExpr::TableExpr(_))).then(|| decl.clone()) + matches!(LuaExpr::cast(node), Some(LuaExpr::TableExpr(_))).then_some(decl) } /// Generic parameter projection fallback (`Ref("T")`): replace with the constraint type declared in the signature doc. diff --git a/crates/emmylua_code_analysis/src/check/test/undefined_field_test.rs b/crates/emmylua_code_analysis/src/check/test/undefined_field_test.rs index e1b3c9f25..d020176a8 100644 --- a/crates/emmylua_code_analysis/src/check/test/undefined_field_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/undefined_field_test.rs @@ -1247,3 +1247,58 @@ fn enum_runtime_cross_file_values_are_not_runtime_tables() { ); } } + +#[googletest::gtest] +fn enum_runtime_same_file_local_members() { + use crate::{DiagnosticCode, VirtualWorkspace}; + use googletest::prelude::*; + for name in ["LocalEnum", "Alias"] { + let mut ws = VirtualWorkspace::new(); + let declaration = + format!("---@enum LocalEnum\nlocal {name} = {{READY = 1, STOPPED = -2}}\n"); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::UndefinedField, + &format!( + "{declaration} local a = {name}.READY\nif {name}['STOPPED'] then return a end" + ) + ), + eq(true), + "{name}" + ); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::UndefinedField, + &format!("{declaration} if {name}['TYPO'] then return end") + ), + eq(false), + "{name}" + ); + } +} + +#[googletest::gtest] +fn enum_runtime_cross_file_dynamic_and_conditional_keys() { + use crate::{DiagnosticCode, VirtualWorkspace}; + use googletest::prelude::*; + let mut ws = VirtualWorkspace::new(); + ws.def_file("constants.lua", "---@enum Status\nSTATUS = { READY = 1 }"); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::UndefinedField, + r#" + ---@param key string + local function get(key) return STATUS[key] end + if STATUS['READY'] then return get('READY') end + "# + ), + eq(true) + ); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::UndefinedField, + "if STATUS['TYPO'] then return end" + ), + eq(false) + ); +}