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
76 changes: 48 additions & 28 deletions crates/emmylua_code_analysis/src/check/checker/undefined_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;
}
// 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));
}

let key_ty = key_type(semantic_model, index_key);
Expand Down Expand Up @@ -297,10 +299,27 @@ 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;
}
// 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;
}
// 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<String> = Vec::new();
for member_ref in semantic_model.members_of_owner(&decl.id).iter() {
Expand All @@ -309,7 +328,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();
Expand Down Expand Up @@ -530,31 +549,32 @@ 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<'db>(
semantic_model: &SemanticModel<'db>,
prefix: &LuaExpr,
) -> Option<&'db Decl> {
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_some(decl)
}

/// Generic parameter projection fallback (`Ref("T")`): replace with the constraint type declared in the signature doc.
Expand Down
114 changes: 114 additions & 0 deletions crates/emmylua_code_analysis/src/check/test/undefined_field_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1188,3 +1188,117 @@ 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}"
);
}
}

#[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)
);
}
Loading