From 0b258337fce6b4717c7c8bba28e4a07a46aee6a8 Mon Sep 17 00:00:00 2001 From: "Henning P. Schmiedehausen" Date: Tue, 13 Oct 2026 11:24:24 -0700 Subject: [PATCH 1/2] fix(check): allow dictionary table constructor fields --- .../src/check/checker/check_field.rs | 6 ++ .../src/check/test/inject_field_test.rs | 85 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/crates/emmylua_code_analysis/src/check/checker/check_field.rs b/crates/emmylua_code_analysis/src/check/checker/check_field.rs index 78c15e1f0..77df5c458 100644 --- a/crates/emmylua_code_analysis/src/check/checker/check_field.rs +++ b/crates/emmylua_code_analysis/src/check/checker/check_field.rs @@ -376,6 +376,12 @@ fn check_against_type( return; } match target { + // A dictionary has an open key space, not the closed member surface of + // the standard library's `table` class. Match assignment checking's + // treatment of table; this checker only validates record fields. + LuaType::Generic(generic) if generic.get_base_type_id().get_name() == "table" => { + return; + } LuaType::Intersection(intersection) => { for component in intersection.get_types() { check_against_type(context, semantic_model, component, table); diff --git a/crates/emmylua_code_analysis/src/check/test/inject_field_test.rs b/crates/emmylua_code_analysis/src/check/test/inject_field_test.rs index e43728268..6f48b6e02 100644 --- a/crates/emmylua_code_analysis/src/check/test/inject_field_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/inject_field_test.rs @@ -498,3 +498,88 @@ mod test { )); } } + +#[googletest::gtest] +fn dictionary_constructor_accepts_keys_with_standard_library() { + use crate::{DiagnosticCode, VirtualWorkspace}; + use googletest::prelude::*; + for declaration in ["local signals", "signals"] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let source = format!( + r#" + ---@class Signal + ---@field name string + KEY = 'depot' + ---@type table + {declaration} = {{ + [KEY] = {{name = KEY}}, + literal = {{name = 'literal'}}, + ['bracket'] = {{name = 'bracket'}}, + }} + "# + ); + assert_that!( + ws.has_no_diagnostic(DiagnosticCode::InjectField, &source), + eq(true), + "{declaration}" + ); + } +} + +#[googletest::gtest] +fn dictionary_constructor_alias_and_call_argument() { + use crate::{DiagnosticCode, VirtualWorkspace}; + use googletest::prelude::*; + for target in ["table", "Dictionary"] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def("---@alias Dictionary table"); + let source = format!( + r#" + ---@param values {target} + local function consume(values) end + consume({{[1] = 'one', [2] = 'two'}}) + ---@type {target} + local values = {{}} + values = {{[3] = 'three'}} + "# + ); + assert_that!( + ws.has_no_diagnostic(DiagnosticCode::InjectField, &source), + eq(true), + "{target}" + ); + } +} + +#[googletest::gtest] +fn dictionary_constructor_does_not_disable_class_field_checks() { + use crate::{DiagnosticCode, VirtualWorkspace}; + use googletest::prelude::*; + for target in ["Record", "Box"] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def( + r#" + ---@class Record + ---@field name string + ---@class Box + ---@field name T + "#, + ); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::InjectField, + &format!("---@type {target}\nlocal value = {{name = 'ok', extra = true}}") + ), + eq(false), + "{target}" + ); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::MissingFields, + &format!("---@type {target}\nlocal value = {{}}") + ), + eq(false), + "{target}" + ); + } +} From 0d0408b797a8bb507203c4d66ba6de543aa1638b Mon Sep 17 00:00:00 2001 From: "Henning P. Schmiedehausen" Date: Tue, 13 Oct 2026 11:49:31 -0700 Subject: [PATCH 2/2] fix(check): distinguish builtin dictionaries from user tables --- .../src/check/checker/check_field.rs | 20 +- .../src/check/test/inject_field_test.rs | 173 ++++++++++++------ 2 files changed, 132 insertions(+), 61 deletions(-) diff --git a/crates/emmylua_code_analysis/src/check/checker/check_field.rs b/crates/emmylua_code_analysis/src/check/checker/check_field.rs index 77df5c458..ed760d31c 100644 --- a/crates/emmylua_code_analysis/src/check/checker/check_field.rs +++ b/crates/emmylua_code_analysis/src/check/checker/check_field.rs @@ -10,7 +10,7 @@ use emmylua_parser::{ use crate::DiagnosticCode; use crate::semantic_db::def::{SemanticId, TypeDef, TypeDefKind, TypeScope}; use crate::semantic_model::SemanticModel; -use crate::{LuaMemberKey, LuaType, LuaUnionType}; +use crate::{LuaMemberKey, LuaType, LuaTypeDeclId, LuaUnionType}; use super::assign_type_mismatch::signature_matches_key; use super::param_type_check::callable_candidates; @@ -376,12 +376,6 @@ fn check_against_type( return; } match target { - // A dictionary has an open key space, not the closed member surface of - // the standard library's `table` class. Match assignment checking's - // treatment of table; this checker only validates record fields. - LuaType::Generic(generic) if generic.get_base_type_id().get_name() == "table" => { - return; - } LuaType::Intersection(intersection) => { for component in intersection.get_types() { check_against_type(context, semantic_model, component, table); @@ -445,6 +439,18 @@ fn check_against_type( return; }; + // Only the canonical standard-library dictionary bypasses closed-record + // checks. A user-defined `table` still has a normal class field surface. + // This is after compound-type handling, so an intersection checks its other + // components even when its dictionary component returns here. + if let LuaType::Generic(generic) = target + && generic.get_base_type_id() == LuaTypeDeclId::global("table") + && find_type_def(semantic_model, &target_id) + .is_some_and(|def| semantic_model.is_std_file(def.file_id)) + { + return; + } + // Target type's @field members (including inheritance). let mut members = HashMap::new(); let mut index_signatures = Vec::new(); diff --git a/crates/emmylua_code_analysis/src/check/test/inject_field_test.rs b/crates/emmylua_code_analysis/src/check/test/inject_field_test.rs index 6f48b6e02..c1d8d6adb 100644 --- a/crates/emmylua_code_analysis/src/check/test/inject_field_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/inject_field_test.rs @@ -499,14 +499,18 @@ mod test { } } -#[googletest::gtest] -fn dictionary_constructor_accepts_keys_with_standard_library() { +// These tests load the standard library because its `table` declaration is +// what made dictionaries incorrectly look like closed records. +mod dictionary_constructor { use crate::{DiagnosticCode, VirtualWorkspace}; use googletest::prelude::*; - for declaration in ["local signals", "signals"] { - let mut ws = VirtualWorkspace::new_with_init_std_lib(); - let source = format!( - r#" + + #[googletest::gtest] + fn dictionary_constructor_accepts_keys_with_standard_library() { + for declaration in ["local signals", "signals"] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let source = format!( + r#" ---@class Signal ---@field name string KEY = 'depot' @@ -517,24 +521,22 @@ fn dictionary_constructor_accepts_keys_with_standard_library() { ['bracket'] = {{name = 'bracket'}}, }} "# - ); - assert_that!( - ws.has_no_diagnostic(DiagnosticCode::InjectField, &source), - eq(true), - "{declaration}" - ); + ); + assert_that!( + ws.has_no_diagnostic(DiagnosticCode::InjectField, &source), + eq(true), + "{declaration}" + ); + } } -} -#[googletest::gtest] -fn dictionary_constructor_alias_and_call_argument() { - use crate::{DiagnosticCode, VirtualWorkspace}; - use googletest::prelude::*; - for target in ["table", "Dictionary"] { - let mut ws = VirtualWorkspace::new_with_init_std_lib(); - ws.def("---@alias Dictionary table"); - let source = format!( - r#" + #[googletest::gtest] + fn dictionary_constructor_alias_and_call_argument() { + for target in ["table", "Dictionary"] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def("---@alias Dictionary table"); + let source = format!( + r#" ---@param values {target} local function consume(values) end consume({{[1] = 'one', [2] = 'two'}}) @@ -542,44 +544,107 @@ fn dictionary_constructor_alias_and_call_argument() { local values = {{}} values = {{[3] = 'three'}} "# - ); - assert_that!( - ws.has_no_diagnostic(DiagnosticCode::InjectField, &source), - eq(true), - "{target}" - ); + ); + assert_that!( + ws.has_no_diagnostic(DiagnosticCode::InjectField, &source), + eq(true), + "{target}" + ); + } } -} -#[googletest::gtest] -fn dictionary_constructor_does_not_disable_class_field_checks() { - use crate::{DiagnosticCode, VirtualWorkspace}; - use googletest::prelude::*; - for target in ["Record", "Box"] { - let mut ws = VirtualWorkspace::new_with_init_std_lib(); - ws.def( - r#" + #[googletest::gtest] + fn dictionary_constructor_does_not_disable_class_field_checks() { + for target in ["Record", "Box"] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def( + r#" ---@class Record ---@field name string ---@class Box ---@field name T "#, - ); - assert_that!( - ws.has_no_diagnostic( - DiagnosticCode::InjectField, - &format!("---@type {target}\nlocal value = {{name = 'ok', extra = true}}") - ), - eq(false), - "{target}" - ); - assert_that!( - ws.has_no_diagnostic( - DiagnosticCode::MissingFields, - &format!("---@type {target}\nlocal value = {{}}") - ), - eq(false), - "{target}" - ); + ); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::InjectField, + &format!("---@type {target}\nlocal value = {{name = 'ok', extra = true}}") + ), + eq(false), + "{target}" + ); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::MissingFields, + &format!("---@type {target}\nlocal value = {{}}") + ), + eq(false), + "{target}" + ); + } + } + + #[googletest::gtest] + fn dictionary_constructor_preserves_user_defined_table_types() { + for private in [false, true] { + let mut ws = if private { + VirtualWorkspace::new_with_init_std_lib() + } else { + VirtualWorkspace::new() + }; + let modifier = if private { "(private) " } else { "" }; + let source = format!( + "---@class {modifier}table\n---@field name T\n---@type table\nlocal value = {{name = 'ok', extra = true}}" + ); + // has_no_diagnostic enables and matches exactly the requested code. + assert_that!( + ws.has_no_diagnostic(DiagnosticCode::InjectField, &source), + eq(false), + "{modifier}" + ); + } + } + + #[googletest::gtest] + fn dictionary_constructor_compound_targets_keep_record_checks() { + for target in [ + "table & Record", + "Record & table", + ] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def(&format!( + "---@class Record\n---@field name string\n---@alias Combined {target}" + )); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::MissingFields, + "---@type Combined\nlocal value = {}" + ), + eq(false), + "{target}" + ); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::InjectField, + "---@type Combined\nlocal value = {name = 'ok', extra = true}" + ), + eq(false), + "{target}" + ); + } + for target in [ + "table | nil", + "nil | table", + ] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + assert_that!( + ws.has_no_diagnostic( + DiagnosticCode::InjectField, + &format!("---@type {target}\nlocal value = {{answer = 42}}") + ), + eq(true), + "{target}" + ); + } } }