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..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; @@ -439,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 e43728268..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 @@ -498,3 +498,153 @@ mod test { )); } } + +// 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::*; + + #[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' + ---@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() { + 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() { + 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}" + ); + } + } + + #[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}" + ); + } + } +}