From f1b65e7f1e969cef89f108bf759698bea59f68b6 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Tue, 13 Oct 2026 13:39:52 +0100 Subject: [PATCH] fix(types): define and implement the flow typing model Define the intended model in docs/emmylua_code_analysis/flow-typing.md and apply it across inference and shared type checks. Check the document's Lua examples through the public diagnostic path. Stable types and flow types: Use stable types for writes and whole-table field checks. Keep assignment and guard narrowing local to reads: ---@type {text?: string} local value = {} value.text = 'hello' -- value.text reads as 'hello'; text stays optional in value's type. Widen inferred primitive literals in stable types while keeping flow literals precise and explicit annotations exact. Apply named casts to later reads and writes. Discard old field flow when the table is rebound. Table construction and sealing: Track fresh unannotated local tables in source order. Direct field and dynamic-key writes establish stable fields and index signatures. Join reaching paths and make fields optional when a path does not supply them. local module = {} function module.read() return module.text end module.text = 'hello' local text = module.read() -- Seals the table before the call. module.extra = 1 -- Error: the sealed type has no extra field. Build the construction plan before resolving field types. Keep access snapshots and check direct function fields against the sealed structure. Seal at the specified uses and control-flow boundaries. Later writes, including through aliases and other modules, must fit the sealed type. Shared table checks: Check fresh constructors from supplied values and existing tables from stable fields. Later writes cannot repair an earlier constructor check. Use shared compatibility for arrays, dictionaries, records and classes, with exact literals, complete union alternatives and all intersection components. Preserve optional fields and Lua key and result rules. Use one bound call candidate for type and field diagnostics, including pcall, generics and method receivers. Prefer matching argument counts and report call errors as param-type-mismatch. Prefer enabled field errors over duplicate argument errors. Reuse the shared comparison queue for recursive fields. Stabilise unused recursive generic arguments while preserving arguments that affect data, so cyclic tables terminate without hiding invalid leaves. --- crates/emmylua_code_analysis/README.md | 2 + .../src/check/checker/assign_type_mismatch.rs | 650 ++----- .../src/check/checker/attribute_check.rs | 7 +- .../src/check/checker/check_field.rs | 497 ++--- .../src/check/checker/mod.rs | 42 +- .../src/check/checker/param_count.rs | 28 +- .../src/check/checker/param_type_check.rs | 328 ++-- .../src/check/checker/return_type_mismatch.rs | 11 +- .../src/check/checker/undefined_field.rs | 40 +- .../check/test/assign_type_mismatch_test.rs | 198 +- .../src/check/test/check_export_test.rs | 6 +- .../src/check/test/inject_field_test.rs | 1729 ++++++++++++++++- .../src/check/test/misc_checker_test.rs | 6 +- .../src/check/test/param_type_check_test.rs | 73 + .../check/test/return_type_mismatch_test.rs | 39 + .../src/semantic_db/facade.rs | 16 + .../src/semantic_db/facts.rs | 51 +- .../src/semantic_db/flow/engine.rs | 22 +- .../src/semantic_db/flow/flow_node.rs | 1 + .../src/semantic_db/flow/flow_tree.rs | 29 +- .../src/semantic_db/phase_tests/p0_tests.rs | 12 +- .../src/semantic_db/phase_tests/p5_tests.rs | 20 +- .../src/semantic_db/query.rs | 120 ++ .../src/semantic_model/access.rs | 20 +- .../src/semantic_model/cache.rs | 23 + .../src/semantic_model/doc_types.rs | 47 +- .../src/semantic_model/flow.rs | 857 ++++---- .../semantic_model/infer/legacy_tests/flow.rs | 103 +- .../infer/legacy_tests/flow_contract.rs | 899 +++++++++ .../semantic_model/infer/legacy_tests/mod.rs | 1 + .../legacy_tests/return_overload_flow_test.rs | 2 +- .../src/semantic_model/infer/test.rs | 4 +- .../src/semantic_model/infer/vm.rs | 142 +- .../src/semantic_model/member.rs | 47 +- .../src/semantic_model/mod.rs | 3 + .../src/semantic_model/signature.rs | 4 + .../src/semantic_model/table_construction.rs | 1364 +++++++++++++ .../src/semantic_model/table_constructor.rs | 256 +++ .../src/semantic_model/table_value.rs | 277 +++ .../semantic_model/type_check/comparison.rs | 20 +- .../semantic_model/type_check/complex_type.rs | 14 +- .../src/semantic_model/type_check/context.rs | 17 +- .../semantic_model/type_check/fail_reason.rs | 25 +- .../src/semantic_model/type_check/mod.rs | 30 +- .../semantic_model/type_check/simple_type.rs | 198 +- .../src/semantic_model/type_eval.rs | 5 +- .../type_eval/alias_expansion.rs | 100 +- .../src/semantic_model/types.rs | 88 +- docs/emmylua_code_analysis/flow-typing.md | 544 ++++++ 49 files changed, 7389 insertions(+), 1628 deletions(-) create mode 100644 crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow_contract.rs create mode 100644 crates/emmylua_code_analysis/src/semantic_model/table_construction.rs create mode 100644 crates/emmylua_code_analysis/src/semantic_model/table_constructor.rs create mode 100644 crates/emmylua_code_analysis/src/semantic_model/table_value.rs create mode 100644 docs/emmylua_code_analysis/flow-typing.md diff --git a/crates/emmylua_code_analysis/README.md b/crates/emmylua_code_analysis/README.md index 4409caf42..36a52ee72 100644 --- a/crates/emmylua_code_analysis/README.md +++ b/crates/emmylua_code_analysis/README.md @@ -1,3 +1,5 @@ # EmmyLua Code Analysis EmmyLua Code Analysis is a static analysis tool for Lua code. It can be used to analyze Lua code and provide code completion, type checking, and other features in IDEs. + +See the [flow typing contract](../../docs/emmylua_code_analysis/flow-typing.md) for supported flow features and scope limits. diff --git a/crates/emmylua_code_analysis/src/check/checker/assign_type_mismatch.rs b/crates/emmylua_code_analysis/src/check/checker/assign_type_mismatch.rs index 957187610..6bc087fd8 100644 --- a/crates/emmylua_code_analysis/src/check/checker/assign_type_mismatch.rs +++ b/crates/emmylua_code_analysis/src/check/checker/assign_type_mismatch.rs @@ -1,24 +1,28 @@ use std::rc::Rc; -use std::collections::{HashMap, HashSet}; - use emmylua_parser::{ - LuaAssignStat, LuaAst, LuaAstNode, LuaCallExpr, LuaClosureExpr, LuaExpr, LuaIndexExpr, - LuaIndexKey, LuaLiteralToken, LuaLocalStat, LuaTableExpr, LuaVarExpr, NumberResult, + LuaAssignStat, LuaAst, LuaAstNode, LuaCallExpr, LuaExpr, LuaLiteralToken, LuaLocalStat, + LuaSyntaxId, LuaTableExpr, LuaVarExpr, }; use crate::DiagnosticCode; use crate::semantic_model::SemanticModel; -use crate::semantic_model::type_check::{TypeCheckFailReason, check_type_detail, is_compatible}; -use crate::{Decl, LuaMemberKey, LuaType, LuaTypeNode, SemanticId, TypeDef, TypeDefKind}; +use crate::semantic_model::table_constructor::table_expr; +use crate::semantic_model::type_check::{TypeCheckFailReason, check_type_at, is_compatible}; +use crate::{LuaObjectType, LuaType, LuaTypeNode, SemanticId, TypeDefKind}; -use super::{CheckContext, Checker, check_field::static_member_key}; +use super::check_field::{class_fields, table_literal_mismatches}; +use super::{CheckContext, Checker}; use crate::semantic_model::render::humanize_type; pub struct AssignTypeMismatchChecker; impl Checker for AssignTypeMismatchChecker { - const CODES: &[DiagnosticCode] = &[DiagnosticCode::AssignTypeMismatch]; + const CODES: &[DiagnosticCode] = &[ + DiagnosticCode::AssignTypeMismatch, + DiagnosticCode::MissingFields, + DiagnosticCode::InjectField, + ]; fn check(context: &mut CheckContext<'_>, semantic_model: &SemanticModel<'_>) { let Some(tree) = semantic_model.syntax_tree() else { @@ -32,7 +36,6 @@ impl Checker for AssignTypeMismatchChecker { LuaAst::LuaTableExpr(table) => { check_inline_table_fields(context, semantic_model, &table) } - LuaAst::LuaCallExpr(call) => check_call_args(context, semantic_model, &call), _ => {} } } @@ -132,42 +135,6 @@ fn str_tpl_uses_name(ty: &LuaType, name: &str) -> bool { } } -/// Table literals in call arguments: check each field against the callee parameter type (`f({ t = "" })`). -fn check_call_args( - context: &mut CheckContext<'_>, - semantic_model: &SemanticModel<'_>, - call: &LuaCallExpr, -) { - if call.get_prefix_expr().is_none() { - return; - } - let analysis = semantic_model.call_site_analysis(call); - let candidates = analysis.candidates; - if candidates.is_empty() { - return; - } - let Some(args) = call.get_args_list() else { - return; - }; - for candidate in &candidates { - let params = candidate.get_params(); - for (index, arg) in args.get_args().enumerate() { - let Some((_, Some(param_ty))) = params.get(index) else { - break; - }; - if !matches!(arg, LuaExpr::TableExpr(_)) { - continue; - } - let LuaExpr::TableExpr(table) = arg else { - continue; - }; - if is_structured_table_target(semantic_model, param_ty) { - check_table_fields(context, semantic_model, param_ty, &table); - } - } - } -} - fn check_inline_table_fields( context: &mut CheckContext<'_>, semantic_model: &SemanticModel<'_>, @@ -236,15 +203,8 @@ fn check_local( } if decl.doc_type_syntax.is_none() { // `---@class B` directly attached to `local b = a`: use the type definition as the declaration contract. - if let Some(target) = class_contract_for_decl(semantic_model, &decl) { - // `local B = setmetatable({}, A)` is the standard class-inheritance idiom: B's runtime - // table uses A as its metatable, so an RHS type of A is expected and must not be treated as a base-class assignment mismatch. - if let LuaExpr::CallExpr(call) = expr - && is_setmetatable_call(call) - { - continue; - } - check_value_against(context, semantic_model, &target, &expr); + if let Some(target) = class_contract_for_owner(semantic_model, stat.get_syntax_id()) { + check_class_initializer(context, semantic_model, &target, expr); } continue; } @@ -252,6 +212,48 @@ fn check_local( } } +fn check_class_initializer( + context: &mut CheckContext<'_>, + semantic_model: &SemanticModel<'_>, + target: &LuaType, + value_expr: &LuaExpr, +) { + let mut expr = value_expr.clone(); + while let LuaExpr::ParenExpr(paren) = &expr { + let Some(inner) = paren.get_expr() else { + return; + }; + expr = inner; + } + // A class table may inherit its members through a metatable. + if let LuaExpr::CallExpr(call) = &expr + && is_setmetatable_call(call) + { + return; + } + let value_ty = assignment_value_type(semantic_model, &expr) + .get_result_slot_type(0) + .unwrap_or(LuaType::Nil); + let is_constructor = match &value_ty { + LuaType::TableConst(_) => true, + LuaType::Union(union) => union.iter().all(|ty| matches!(ty, LuaType::TableConst(_))), + _ => false, + }; + if !is_constructor { + check_value_against(context, semantic_model, target, &expr); + return; + } + // A class definition may fill its top-level fields later. Check supplied + // values through the same contract regardless of where the table was created. + let (fields, indexes) = class_fields(semantic_model, target); + let fields = fields + .into_iter() + .map(|(key, ty)| (key, LuaType::from_vec(vec![ty, LuaType::Nil]))) + .collect(); + let initializer = LuaType::Object(Rc::new(LuaObjectType::new_with_fields(fields, indexes))); + check_value_against(context, semantic_model, &initializer, &expr); +} + fn is_setmetatable_call(call: &LuaCallExpr) -> bool { call.get_prefix_expr().is_some_and(|prefix| match prefix { LuaExpr::NameExpr(name_expr) => { @@ -282,6 +284,16 @@ fn check_assign( } continue; }; + if let Some(decl) = semantic_model + .file_facts() + .and_then(|facts| facts.decl_by_id(&decl_id)) + && decl.doc_type_syntax.is_none() + && let Some(target) = + class_contract_for_owner(semantic_model, stat.get_syntax_id()) + { + check_class_initializer(context, semantic_model, &target, expr); + continue; + } check_assign_against( context, semantic_model, @@ -291,35 +303,11 @@ fn check_assign( ); } LuaVarExpr::IndexExpr(index_expr) => { - let Some(resolved) = semantic_model.resolve_member(index_expr) else { - check_self_member_assign(context, semantic_model, index_expr, &expr); - continue; - }; - let Some(member_id) = resolved.member_id else { - check_self_member_assign(context, semantic_model, index_expr, &expr); - continue; - }; - let owner_is_self = semantic_model - .file_facts() - .and_then(|facts| facts.member_by_id(&member_id)) - .is_some_and(|member| { - matches!( - member.owner, - SemanticId::Name(ref name) - if name.as_str() == "self" - ) - }); - if owner_is_self { - check_self_member_assign(context, semantic_model, index_expr, &expr); - continue; + if let Some(targets) = semantic_model.index_assignment_targets(index_expr) { + for target in targets { + check_value_against_target(context, semantic_model, &target, expr); + } } - check_member_assign( - context, - semantic_model, - &member_id, - index_expr.get_range().start(), - &expr, - ); } } } @@ -350,96 +338,6 @@ fn check_assign_against( check_value_against(context, semantic_model, &target, value_expr); } -/// `function mt:init() self.x = value`: self member falls back to the same-named member on the method owner. -fn check_self_member_assign( - context: &mut CheckContext<'_>, - semantic_model: &SemanticModel<'_>, - index_expr: &LuaIndexExpr, - value_expr: &LuaExpr, -) -> Option<()> { - let prefix = index_expr.get_prefix_expr()?; - let LuaExpr::NameExpr(name_expr) = &prefix else { - return None; - }; - if name_expr.get_text() != "self" { - return None; - } - let key = index_expr - .get_index_key() - .map(|key| key.get_path_part()) - .unwrap_or_default(); - let closure = index_expr.ancestors::().next()?; - let facts = semantic_model.file_facts()?; - let methods: Vec<_> = facts - .members - .iter() - .filter(|member| member.value_syntax == Some(closure.get_syntax_id())) - .collect(); - let method = methods.into_iter().find(|member| member.is_method)?; - let owner_ty = semantic_model - .type_of_decl(&method.owner) - .unwrap_or(LuaType::Unknown); - // The owner's VM type may be Unknown: directly take the declaration type of the same-named runtime member on the owner. - let target = facts - .members - .iter() - .find(|member| { - member.owner == method.owner - && member.key.name() == Some(key.as_str()) - && member.value_syntax != Some(value_expr.get_syntax_id()) - }) - .map(|member| semantic_model.type_of_member_at(&member.id, index_expr.get_range().start())) - .or_else(|| { - semantic_model.member_type(&owner_ty, &LuaMemberKey::Name(key.clone().into())) - })?; - check_value_against(context, semantic_model, &target, value_expr); - Some(()) -} - -fn check_member_assign( - context: &mut CheckContext<'_>, - semantic_model: &SemanticModel<'_>, - member_id: &SemanticId, - target_offset: rowan::TextSize, - value_expr: &LuaExpr, -) { - let mut target = semantic_model.member_assignment_type( - member_id, - semantic_model.type_of_member_at(member_id, target_offset), - ); - // `t.x = value`: resolve_member first returns the synthesized member for this assignment; prefer the same-named member on the prefix's declared type. - if let Some(facts) = semantic_model.file_facts() - && let Some(member) = facts.member_by_id(member_id) - { - if member.value_syntax == Some(value_expr.get_syntax_id()) { - let SemanticId::Decl(owner) = &member.owner else { - return check_value_against_target(context, semantic_model, &target, value_expr); - }; - let prefix_ty = semantic_model - .type_of_decl_assign_target_at(&SemanticId::Decl(owner.clone()), target_offset); - let key = member.key.clone(); - let infos = semantic_model.member_infos_with_key(&prefix_ty, &key); - let declared_ty = infos - .into_iter() - .find(|info| info.id.as_ref() != Some(member_id)) - .map(|info| match info.id { - Some(id) => semantic_model.member_assignment_type(&id, info.typ), - None => info.typ, - }) - .or_else(|| semantic_model.member_type(&prefix_ty, &key)); - if let Some(declared_ty) = declared_ty { - target = declared_ty; - } - } - } - let value_ty = - semantic_model.type_of_expr_at(value_expr.get_syntax_id(), value_expr.get_range().start()); - if matches!(value_ty, LuaType::Nil) || matches!(target, LuaType::Nil) { - return; - } - check_value_against_target(context, semantic_model, &target, value_expr); -} - fn check_value_against_target( context: &mut CheckContext<'_>, semantic_model: &SemanticModel<'_>, @@ -453,77 +351,37 @@ fn check_value_against_target( check_value_against(context, semantic_model, target, value_expr); } -fn is_structured_table_target(semantic_model: &SemanticModel<'_>, ty: &LuaType) -> bool { - match ty { - LuaType::Array(_) | LuaType::Object(_) => true, - LuaType::Union(union) => union - .into_vec() - .iter() - .any(|component| is_structured_table_target(semantic_model, component)), - LuaType::Generic(generic) => { - generic.get_base_type_id().get_name() == "array" - || is_structured_table_target( - semantic_model, - &LuaType::Ref(generic.get_base_type_id()), - ) - } - LuaType::Ref(_) | LuaType::Def(_) => { - let Some(def) = named_def(semantic_model, ty) else { - return false; - }; - match def.kind { - TypeDefKind::Class => true, - TypeDefKind::Alias => semantic_model - .alias_target(&def) - .is_some_and(|target| is_structured_table_target(semantic_model, &target)), - TypeDefKind::Enum => false, - } - } - _ => false, - } -} - fn check_value_against( context: &mut CheckContext<'_>, semantic_model: &SemanticModel<'_>, target: &LuaType, value_expr: &LuaExpr, ) { - if let LuaExpr::TableExpr(table) = value_expr { - // Plain table target without a declared type: keep old behavior — don't check each field or report a type mismatch (same table identity). - if matches!( - target, - LuaType::Table | LuaType::TableConst(_) | LuaType::TableGeneric(_) - ) { - return; - } - if let LuaType::Generic(generic) = target - && generic.get_base_type_id().get_name() == "table" - { - return; - } - if is_structured_table_target(semantic_model, target) { - check_table_fields(context, semantic_model, target, table); - } else { - let value_ty = semantic_model - .type_of_expr_at(value_expr.get_syntax_id(), value_expr.get_range().start()); - if matches!(value_ty, LuaType::Unknown | LuaType::Any | LuaType::Never) { - return; - } - // Compatibility resolves tuple aliases and unions. Keep the intersection guard: - // the general check currently accepts a table matching only one component. - if !matches!(target, LuaType::Intersection(_)) - && is_compatible(semantic_model, &value_ty, target) - { - return; - } - add_mismatch(context, value_expr.get_range(), target, &value_ty); - } - return; + let field_errors = table_expr(value_expr).map_or_else(Vec::new, |table| { + table_literal_mismatches(semantic_model, target, &table) + }); + let has_field_error = field_errors + .iter() + .any(|error| context.is_diagnostic_enabled(error.code, error.range)); + for error in field_errors { + context.add_diagnostic(error.code, error.range, error.message); } let value_ty = assignment_value_type(semantic_model, value_expr); + let compatible = if matches!(value_ty, LuaType::TableConst(_)) { + check_type_at( + semantic_model, + &value_ty, + target, + value_expr.get_range(), + false, + ) + .is_ok() + } else { + is_compatible(semantic_model, &value_ty, target) + }; if matches!(value_ty, LuaType::Unknown | LuaType::Any | LuaType::Never) - || is_compatible(semantic_model, &value_ty, target) + || compatible + || has_field_error { return; } @@ -575,310 +433,17 @@ fn resolve_unbound_generic(semantic_model: &SemanticModel<'_>, ty: LuaType) -> L ty } -fn check_table_fields( - context: &mut CheckContext<'_>, - semantic_model: &SemanticModel<'_>, - target: &LuaType, - table: &LuaTableExpr, -) { - // Union target: use the first structured component to check the table literal (`Callback | Callback[]` scenario). - if let LuaType::Union(union) = target { - for component in union.into_vec().iter() { - if is_structured_table_target(semantic_model, component) { - check_table_fields(context, semantic_model, component, table); - return; - } - } - } - // Array target: check every table-literal element against the element type. - if let LuaType::Array(array) = target { - let expected = array.get_base(); - for (field, _) in table.get_fields_with_keys() { - let Some(value_expr) = field.get_value_expr() else { - continue; - }; - check_table_value(context, semantic_model, &value_expr, expected); - } - return; - } - // Projection form where the `array` alias / `T[]` is not expanded into `Array`. - if let LuaType::Generic(generic) = target - && generic.get_base_type_id().get_name() == "array" - && let Some(expected) = generic.get_params().first() - { - for (field, _) in table.get_fields_with_keys() { - let Some(value_expr) = field.get_value_expr() else { - continue; - }; - check_table_value(context, semantic_model, &value_expr, expected); - } - return; - } - // Object target: static fields first, then `{[string]: string}` index signatures. - if let LuaType::Object(object) = target { - for (field, key) in table.get_fields_with_keys() { - let Some(value_expr) = field.get_value_expr() else { - continue; - }; - let member_key = index_key_to_member_key(semantic_model, &key); - if let Some(expected) = member_key - .as_ref() - .and_then(|key| object.get_fields().get(key)) - { - check_table_value(context, semantic_model, &value_expr, expected); - continue; - } - let key_ty = index_key_type(semantic_model, &key); - if let Some((_, expected)) = object - .get_index_access() - .iter() - .find(|(signature_key, _)| is_compatible(semantic_model, &key_ty, signature_key)) - { - check_table_value(context, semantic_model, &value_expr, expected); - } - } - return; - } - - // Alias target: expand it, then check as object/array/class. - if let LuaType::Ref(_) | LuaType::Def(_) = target - && let Some(def) = named_def(semantic_model, target) - && def.kind == TypeDefKind::Alias - && let Some(alias_target) = semantic_model.alias_target(&def) - && is_structured_table_target(semantic_model, &alias_target) - { - check_table_fields(context, semantic_model, &alias_target, table); - return; - } - - let Some(target_def) = named_def(semantic_model, target) else { - return; - }; - let mut members = HashMap::new(); - let mut required_members: Vec<(String, LuaType)> = Vec::new(); - let mut index_signatures: Vec<(String, LuaType)> = Vec::new(); - collect_members_with_index_signatures( - semantic_model, - &target_def, - &mut Vec::new(), - &mut members, - &mut required_members, - &mut index_signatures, - ); - let fields: Vec<_> = table - .get_fields_with_keys() - .into_iter() - .map(|(field, key)| { - let member_key = match &key { - LuaIndexKey::Expr(_) => None, - _ => static_member_key(semantic_model, &key), - }; - (field, key, member_key) - }) - .collect(); - for (field, key, member_key) in &fields { - if let Some(expected) = member_key.as_ref().and_then(|key| members.get(key)) { - let Some(value_expr) = field.get_value_expr() else { - continue; - }; - check_table_value(context, semantic_model, &value_expr, expected); - continue; - } - // Static member not found -> check against index signatures (`@field [integer] string`). - let Some((_, expected)) = index_signatures - .iter() - .find(|(signature, _)| signature_matches_key(semantic_model, signature, key)) - else { - continue; - }; - let Some(value_expr) = field.get_value_expr() else { - continue; - }; - check_table_value(context, semantic_model, &value_expr, expected); - } - // Array-shaped table literal + integer index signature: required static members from class inheritance must still appear - // (an Opts with `@field [integer] string` still requires the parent's `@field foo boolean`). - if fields - .iter() - .any(|(_, key, _)| matches!(key, LuaIndexKey::Integer(_) | LuaIndexKey::Idx(_))) - { - let field_names: HashSet = fields - .iter() - .map(|(_, key, member_key)| { - member_key - .as_ref() - .map(|key| key.to_path()) - .unwrap_or_else(|| key.get_path_part()) - }) - .collect(); - for (name, required_ty) in &required_members { - if !field_names.contains(name.as_str()) { - if let Some((first_field, _, _)) = fields.first() - && let Some(value_expr) = first_field.get_value_expr() - { - let value_ty = semantic_model.type_of_expr_at( - value_expr.get_syntax_id(), - value_expr.get_range().start(), - ); - add_mismatch(context, value_expr.get_range(), required_ty, &value_ty); - } - break; - } - } - } -} - -fn check_table_value( - context: &mut CheckContext<'_>, - semantic_model: &SemanticModel<'_>, - value_expr: &LuaExpr, - expected: &LuaType, -) { - if matches!(value_expr, LuaExpr::TableExpr(_)) { - check_value_against(context, semantic_model, expected, value_expr); - return; - } - let value_ty = - semantic_model.type_of_expr_at(value_expr.get_syntax_id(), value_expr.get_range().start()); - // Nil omits an entry from a table constructor. - let expected_or_absent = LuaType::from_vec(vec![expected.clone(), LuaType::Nil]); - if matches!(value_ty, LuaType::Unknown | LuaType::Any | LuaType::Never) - || is_compatible(semantic_model, &value_ty, &expected_or_absent) - { - return; - } - add_mismatch(context, value_expr.get_range(), expected, &value_ty); -} - -fn index_key_type(semantic_model: &SemanticModel<'_>, key: &LuaIndexKey) -> LuaType { - match key { - LuaIndexKey::Name(_) | LuaIndexKey::String(_) => LuaType::String, - LuaIndexKey::Integer(_) | LuaIndexKey::Idx(_) => LuaType::Integer, - LuaIndexKey::Expr(expr) => semantic_model.type_of_expr(expr.get_syntax_id()), - } -} - -fn index_key_to_member_key( - _semantic_model: &SemanticModel<'_>, - key: &LuaIndexKey, -) -> Option { - match key { - LuaIndexKey::Name(_) | LuaIndexKey::String(_) => { - Some(LuaMemberKey::Name(key.get_path_part().into())) - } - LuaIndexKey::Integer(_) | LuaIndexKey::Idx(_) => { - let value = match key { - LuaIndexKey::Integer(token) => Some(token.get_number_value()), - LuaIndexKey::Idx(_) => key - .get_path_part() - .parse::() - .ok() - .map(NumberResult::Int), - _ => None, - }; - match value { - Some(NumberResult::Int(i)) => Some(LuaMemberKey::Integer(i)), - Some(NumberResult::Uint(u)) => Some(LuaMemberKey::Integer(u as i64)), - _ => None, - } - } - LuaIndexKey::Expr(_) => None, - } -} - -pub(super) fn signature_matches_key( - semantic_model: &SemanticModel<'_>, - signature: &str, - key: &LuaIndexKey, -) -> bool { - let key_ty = index_key_type(semantic_model, key); - match signature { - "integer" | "number" | "int" => { - matches!(key, LuaIndexKey::Integer(_) | LuaIndexKey::Idx(_)) - || is_compatible(semantic_model, &key_ty, &LuaType::Integer) - } - "string" => matches!(key, LuaIndexKey::Name(_) | LuaIndexKey::String(_)), - "any" | "unknown" => true, - _ => false, - } -} - -fn collect_members_with_index_signatures( +/// Statement attached to a `---@class B` comment -> class reference type. +fn class_contract_for_owner( semantic_model: &SemanticModel<'_>, - def: &TypeDef, - visited: &mut Vec, - out: &mut HashMap, - required_out: &mut Vec<(String, LuaType)>, - index_signatures: &mut Vec<(String, LuaType)>, -) { - if visited.contains(&def.id) { - return; - } - visited.push(def.id.clone()); - for member_ref in semantic_model.members_of_owner(&def.id).iter() { - let Some(member_file_facts) = semantic_model.file_facts_of(member_ref.file_id) else { - continue; - }; - let Some(member) = member_file_facts.member_by_id(&member_ref.id) else { - continue; - }; - let name = member_ref.name.to_string(); - let ty = semantic_model - .type_of_member(&member_ref.id) - .unwrap_or(LuaType::Unknown); - if member.is_index_signature { - if !index_signatures - .iter() - .any(|(existing, _)| existing == &name) - { - index_signatures.push((name, ty)); - } - } else { - out.entry(member.key.clone()).or_insert_with(|| ty.clone()); - if !member.is_nullable && !required_out.iter().any(|(existing, _)| existing == &name) { - required_out.push((name, ty)); - } - } - } - for super_def in semantic_model.base_type_defs(&def.type_decl_id()) { - collect_members_with_index_signatures( - semantic_model, - &super_def, - visited, - out, - required_out, - index_signatures, - ); - } -} - -/// Local declaration attached to a `---@class B` comment -> class reference type. -fn class_contract_for_decl(semantic_model: &SemanticModel<'_>, decl: &Decl) -> Option { - let def = facts_type_def_for_decl(semantic_model, decl)?; - if def.kind == TypeDefKind::Enum { + owner: LuaSyntaxId, +) -> Option { + let facts = semantic_model.file_facts()?; + let def = facts.type_def_by_owner_syntax(owner)?; + if def.kind != TypeDefKind::Class { return None; } - Some(semantic_model.type_def_ref(&def)) -} - -fn facts_type_def_for_decl(semantic_model: &SemanticModel<'_>, decl: &Decl) -> Option { - let facts = semantic_model.file_facts()?; - facts - .type_defs - .iter() - .find(|def| def.owner_syntax.is_some() && def.owner_syntax == decl.owner_syntax) - .cloned() -} - -fn named_def(semantic_model: &SemanticModel<'_>, ty: &LuaType) -> Option { - let id = match ty { - LuaType::Ref(id) | LuaType::Def(id) => id, - LuaType::Generic(generic) => { - return named_def(semantic_model, &LuaType::Ref(generic.get_base_type_id())); - } - _ => return None, - }; - semantic_model.type_def_of(id) + Some(semantic_model.type_def_ref(def)) } /// The `---@type C` comment immediately before an assignment statement -> target type (`_ = c` scenario). @@ -911,14 +476,21 @@ fn preceding_doc_type_target( fn add_mismatch( context: &mut CheckContext<'_>, - range: rowan::TextRange, + mut range: rowan::TextRange, target: &LuaType, value_ty: &LuaType, ) { - let reason = match check_type_detail(context.semantic_model, value_ty, target) { - Err(TypeCheckFailReason::TypeNotMatchWithReason(reason)) => Some(reason), - _ => None, + let failure = check_type_at(context.semantic_model, value_ty, target, range, true).err(); + let (target, value_ty) = if let Some(TypeCheckFailReason::TypeNotMatchAtField(field)) = &failure + && field.location.file_id == context.semantic_model.file_id() + && range.contains_range(field.location.value) + { + range = field.location.value; + (&field.target, &field.source) + } else { + (target, value_ty) }; + let reason = failure.as_ref().and_then(TypeCheckFailReason::reason); let message = match reason { Some(reason) => t!( "expected `%{target}` but found `%{value}`.\n %{reason}", diff --git a/crates/emmylua_code_analysis/src/check/checker/attribute_check.rs b/crates/emmylua_code_analysis/src/check/checker/attribute_check.rs index e3c0ac91c..d1ae1a143 100644 --- a/crates/emmylua_code_analysis/src/check/checker/attribute_check.rs +++ b/crates/emmylua_code_analysis/src/check/checker/attribute_check.rs @@ -14,7 +14,7 @@ use emmylua_parser::{ use crate::DiagnosticCode; use crate::semantic_model::SemanticModel; use crate::semantic_model::type_check; -use crate::semantic_model::type_check::{TypeCheckFailReason, check_type_detail}; +use crate::semantic_model::type_check::check_type_detail; use crate::{LuaFunctionType, LuaType, LuaTypeDeclId, TypeDef}; use super::{CheckContext, Checker}; @@ -275,8 +275,9 @@ fn add_type_check_diagnostic( param_type: &LuaType, ) { let reason = match check_type_detail(semantic_model, arg_type, param_type) { - Err(TypeCheckFailReason::TypeNotMatchWithReason(reason)) => format!(" {reason}"), - Err(TypeCheckFailReason::TypeNotMatch) => String::new(), + Err(err) if err.is_type_not_match() => err + .reason() + .map_or(String::new(), |reason| format!(" {reason}")), _ => return, }; context.add_diagnostic( 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 35cf26271..fdf65a9e1 100644 --- a/crates/emmylua_code_analysis/src/check/checker/check_field.rs +++ b/crates/emmylua_code_analysis/src/check/checker/check_field.rs @@ -1,91 +1,37 @@ -use std::rc::Rc; +use std::collections::HashMap; -use std::collections::{HashMap, HashSet}; - -use emmylua_parser::{ - LuaAssignStat, LuaAst, LuaAstNode, LuaCallExpr, LuaDocType, LuaExpr, LuaIndexKey, LuaLocalStat, - LuaTableExpr, LuaVarExpr, -}; +use emmylua_parser::{LuaAssignStat, LuaAstNode, LuaDocType, LuaTableExpr, LuaVarExpr}; use crate::DiagnosticCode; -use crate::semantic_db::def::{SemanticId, TypeDef, TypeDefKind}; +use crate::semantic_db::def::{TypeDef, TypeDefKind}; use crate::semantic_model::SemanticModel; -use crate::semantic_model::type_eval::expand_alias_branches; -use crate::{LuaMemberKey, LuaType, LuaTypeDeclId, LuaUnionType}; +use crate::semantic_model::type_eval::{expand_alias_branches, expand_alias_head}; +use crate::{LuaMemberKey, LuaType, LuaTypeDeclId}; -use super::assign_type_mismatch::signature_matches_key; -use super::param_type_check::callable_candidates; -use super::{CheckContext, Checker}; +use super::{CheckContext, Checker, Mismatch}; use crate::semantic_model::render::humanize_type; +use crate::semantic_model::table_constructor::{TableConstructor, static_member_key, table_expr}; +use crate::semantic_model::type_check::table_index_accepts; pub struct CheckFieldChecker; impl Checker for CheckFieldChecker { - const CODES: &[DiagnosticCode] = &[DiagnosticCode::MissingFields, DiagnosticCode::InjectField]; + const CODES: &[DiagnosticCode] = &[DiagnosticCode::InjectField]; fn check(context: &mut CheckContext<'_>, semantic_model: &SemanticModel<'_>) { let Some(tree) = semantic_model.syntax_tree() else { return; }; - let root = tree.get_red_root(); - for node in root.descendants().filter_map(LuaAst::cast) { - match node { - LuaAst::LuaLocalStat(local_stat) => { - check_local(context, semantic_model, &local_stat); - } - LuaAst::LuaAssignStat(assign_stat) => { - check_assign(context, semantic_model, &assign_stat); - check_index_assign(context, semantic_model, &assign_stat); - } - LuaAst::LuaCallExpr(call_expr) => { - check_call_args(context, semantic_model, &call_expr) - } - _ => {} - } + for stat in tree + .get_red_root() + .descendants() + .filter_map(LuaAssignStat::cast) + { + check_index_assign(context, semantic_model, &stat); } } } -/// `---@type C\nlocal c = { ... }`. -fn check_local( - context: &mut CheckContext<'_>, - semantic_model: &SemanticModel<'_>, - local_stat: &LuaLocalStat, -) { - let name_list = local_stat.get_local_name_list().collect::>(); - let value_exprs = local_stat.get_value_exprs().collect::>(); - for (index, local_name) in name_list.iter().enumerate() { - let Some(LuaExpr::TableExpr(table)) = value_exprs.get(index) else { - continue; - }; - let Some(decl_id) = semantic_model.decl_by_offset(local_name.get_range().start()) else { - continue; - }; - check_against(context, semantic_model, &decl_id, table); - } -} - -/// `c = { ... }` (c has a doc type). -fn check_assign( - context: &mut CheckContext<'_>, - semantic_model: &SemanticModel<'_>, - assign_stat: &LuaAssignStat, -) { - let (vars, exprs) = assign_stat.get_var_and_expr_list(); - for (var, expr) in vars.iter().zip(exprs.iter()) { - let LuaExpr::TableExpr(table) = expr else { - continue; - }; - let LuaVarExpr::NameExpr(name_expr) = var else { - continue; - }; - let Some(decl_id) = semantic_model.resolve_name(name_expr.get_position()) else { - continue; - }; - check_against(context, semantic_model, &decl_id, table); - } -} - /// `test.a = 1` (test is a named type): if the target type has no such static member -> InjectField. /// M0: only handle named classes without a parent type; dynamic keys, index signatures, and Object types are left for later. fn check_index_assign( @@ -134,16 +80,13 @@ fn check_index_assign( } continue; } - let Some(target_id) = resolve_target(semantic_model, &prefix_ty) else { - continue; - }; - let Some(def) = find_type_def(semantic_model, &target_id) else { + let Some(def) = target_def(semantic_model, &prefix_ty) else { continue; }; // Classes with a parent type or index signature (`[string]`) are handled broadly (M0). if !def.base_types.is_empty() || semantic_model - .members_of_owner(&target_id) + .members_of_owner(&def.id) .iter() .any(|member| member.name.starts_with('[')) || is_mapped_alias(semantic_model, &def) @@ -167,67 +110,10 @@ fn check_index_assign( } } -/// Follows alias definitions until a non-alias type is reached. Returns `None` when the -/// input is not an alias (or an alias cycle/unresolved target prevents expansion). +/// Resolve alias heads through the shared evaluator. fn expand_alias_type(semantic_model: &SemanticModel<'_>, ty: &LuaType) -> Option { - let mut current = ty.clone(); - let mut visited = Vec::new(); - loop { - let id = match ¤t { - LuaType::Ref(id) | LuaType::Def(id) => id.clone(), - _ => { - return if visited.is_empty() { - None - } else { - Some(current) - }; - } - }; - let def = semantic_model.type_def_of(&id)?; - if def.kind != TypeDefKind::Alias { - return if visited.is_empty() { - None - } else { - Some(current) - }; - } - if visited.contains(&id) { - return None; - } - visited.push(id.clone()); - current = semantic_model.alias_target(&def)?; - } -} - -/// Static table key -> `LuaMemberKey` (dynamic keys are not checked). -pub(super) fn static_member_key( - semantic_model: &SemanticModel<'_>, - key: &LuaIndexKey, -) -> Option { - match key { - LuaIndexKey::Name(name) => Some(LuaMemberKey::Name(name.get_name_text().into())), - LuaIndexKey::String(string) => Some(LuaMemberKey::Name(string.get_value().into())), - LuaIndexKey::Integer(integer) => match integer.get_number_value() { - emmylua_parser::NumberResult::Int(idx) => Some(LuaMemberKey::Integer(idx)), - // The upper bound rounds up to 2^63, so it must be exclusive. - emmylua_parser::NumberResult::Float(idx) - if idx.fract() == 0.0 && (i64::MIN as f64..i64::MAX as f64).contains(&idx) => - { - Some(LuaMemberKey::Integer(idx as i64)) - } - _ => None, - }, - LuaIndexKey::Idx(idx) => Some(LuaMemberKey::Integer(*idx as i64)), - LuaIndexKey::Expr(expr) => match semantic_model.type_of_expr(expr.get_syntax_id()) { - LuaType::StringConst(s) | LuaType::DocStringConst(s) => { - Some(LuaMemberKey::Name(s.as_ref().clone())) - } - LuaType::IntegerConst(i) | LuaType::DocIntegerConst(i) => { - Some(LuaMemberKey::Integer(i)) - } - _ => None, - }, - } + let expanded = expand_alias_head(semantic_model, ty)?; + (expanded != *ty).then_some(expanded) } /// Whether an object index-signature key type accepts this member key (`{[number]: ...}` does not accept string keys). @@ -250,44 +136,6 @@ fn key_type_contains(key_ty: &LuaType, expected: &LuaType) -> bool { } } -/// `---@class C: { a: number }`: when the parent type is an object literal, complete the field surface. -fn collect_object_super_fields( - semantic_model: &SemanticModel<'_>, - def: &TypeDef, - out: &mut HashMap, -) { - let Some(tree) = semantic_model.syntax_tree_of(def.file_id) else { - return; - }; - let root = tree.get_red_root(); - for syntax in &def.base_types { - let Some(LuaDocType::Object(object)) = - syntax.to_node_from_root(&root).and_then(LuaDocType::cast) - else { - continue; - }; - for field in object.get_fields() { - let Some(key) = field.get_field_key() else { - continue; - }; - let key = match &key { - emmylua_parser::LuaDocObjectFieldKey::Name(name) => { - LuaMemberKey::Name(name.get_name_text().into()) - } - emmylua_parser::LuaDocObjectFieldKey::String(str) => { - LuaMemberKey::Name(str.get_value().into()) - } - _ => continue, - }; - let ty = field - .get_type() - .map(|ty| semantic_model.doc_type_lua_rich_in(def.file_id, ty.get_syntax_id())) - .unwrap_or(LuaType::Unknown); - out.entry(key).or_insert(ty); - } - } -} - /// Whether the alias target is a mapped type. fn is_mapped_alias(semantic_model: &SemanticModel<'_>, def: &TypeDef) -> bool { let Some(syntax) = def.alias_type else { @@ -302,92 +150,90 @@ fn is_mapped_alias(semantic_model: &SemanticModel<'_>, def: &TypeDef) -> bool { matches!(LuaDocType::cast(node), Some(LuaDocType::Mapped(_))) } -/// `f({ ... })`: check call-argument table literals for missing/extra fields against the parameter type. -fn check_call_args( - context: &mut CheckContext<'_>, +pub(super) fn table_literal_mismatches( semantic_model: &SemanticModel<'_>, - call_expr: &LuaCallExpr, -) { - let Some(callee) = call_expr.get_prefix_expr() else { - return; - }; - let candidates = callable_candidates(semantic_model, &callee); - let Some(args) = call_expr.get_args_list() else { - return; - }; - for candidate in &candidates { - let params = candidate.get_params(); - for (index, arg) in args.get_args().enumerate() { - let LuaExpr::TableExpr(table) = arg else { - continue; - }; - let Some((_, Some(param_ty))) = params.get(index) else { - continue; - }; - check_against_type(context, semantic_model, param_ty, &table); - } - } + target: &LuaType, + table: &LuaTableExpr, +) -> Vec { + let mut mismatches = Vec::new(); + collect_table_mismatches(&mut mismatches, semantic_model, target, table); + mismatches } -fn check_against( - context: &mut CheckContext<'_>, +fn collect_table_mismatches( + mismatches: &mut Vec, semantic_model: &SemanticModel<'_>, - decl_id: &SemanticId, + target: &LuaType, table: &LuaTableExpr, ) { - // Target type: projection of the declared doc annotation. - let Some(facts) = semantic_model.file_facts() else { + // Resolve union aliases once. A remaining alias is an unresolved cycle; + // following it again here would bypass the evaluator's active alias path. + let Some(target) = expand_alias_branches(semantic_model, target) else { return; }; - let Some(decl) = facts.decl_by_id(decl_id) else { - return; - }; - let Some(type_syntax) = decl.doc_type_syntax else { - return; - }; - let target = semantic_model.doc_type_lua(type_syntax); - check_against_type(context, semantic_model, &target, table); + check_table_head(mismatches, semantic_model, &target, table); } -fn check_against_type( - context: &mut CheckContext<'_>, +fn check_table_head( + mismatches: &mut Vec, semantic_model: &SemanticModel<'_>, target: &LuaType, table: &LuaTableExpr, ) { - // Expand aliases before checking table literals against them. This makes alias-to-object, - // alias-to-class and alias-to-unknown behave like their target types. - if let Some(expanded) = expand_alias_type(semantic_model, target) { - check_against_type(context, semantic_model, &expanded, table); - return; - } match target { LuaType::Intersection(intersection) => { for component in intersection.get_types() { - check_against_type(context, semantic_model, component, table); + check_table_head(mismatches, semantic_model, component, table); } return; } LuaType::Union(union) => { - // Check the first named-class component; empty tables pass through the array component. - let fields_count = table.get_fields_with_keys().len(); - for component in union.into_vec() { - let named = matches!( - component, - LuaType::Ref(_) | LuaType::Def(_) | LuaType::Generic(_) - ); - if named && fields_count > 0 { - check_against_type(context, semantic_model, &component, table); - return; + let source = LuaType::TableConst(crate::InFiled::new( + semantic_model.file_id(), + table.get_range(), + )); + let mut best: Option> = None; + for component in union.iter() { + // An unresolved alias must not restart expansion of this branch. + if target_def(semantic_model, component) + .is_some_and(|def| def.kind == TypeDefKind::Alias) + || crate::semantic_model::type_check::check_type_at( + semantic_model, + &source, + component, + table.get_range(), + false, + ) + .is_err() + { + continue; } - if matches!(component, LuaType::Array(_)) && fields_count == 0 { + let mut errors = Vec::new(); + check_table_head(&mut errors, semantic_model, component, table); + if errors.is_empty() { return; } + if best + .as_ref() + .is_none_or(|current| errors.len() < current.len()) + { + best = Some(errors); + } + } + if let Some(errors) = best { + mismatches.extend(errors); + } else if let Some(component) = union.iter().find(|component| { + matches!(component, LuaType::Object(_)) + || target_def(semantic_model, component) + .is_some_and(|def| def.kind == TypeDefKind::Class) + }) { + // Keep a missing-field diagnostic when no alternative accepts the record. + check_table_head(mismatches, semantic_model, component, table); } return; } LuaType::Object(object) => { - let fields = table.get_fields_with_keys().to_vec(); + let constructor = TableConstructor::new(semantic_model, table); let mut missing = Vec::new(); for (key, ty) in object.get_fields() { let name = match key { @@ -395,35 +241,27 @@ fn check_against_type( LuaMemberKey::Integer(i) => i.to_string(), _ => continue, }; - if !fields - .iter() - .any(|(_, field_key)| field_key.get_path_part() == name) - && !ty.is_nullable() - { - missing.push(name); + match constructor.member_value(key) { + Ok(None) if !is_optional(semantic_model, ty) => missing.push(name), + Ok(Some(value)) => { + if let Some(child) = value.expr.and_then(table_expr) { + collect_table_mismatches(mismatches, semantic_model, ty, &child); + } + } + _ => {} } } - if !missing.is_empty() { - context.add_diagnostic( - DiagnosticCode::MissingFields, - table.get_range(), - format!( - "Missing required fields: {}", - missing - .iter() - .map(|name| format!("`{}`", name)) - .collect::>() - .join(", ") - ), - ); - } + report_missing_fields(mismatches, table, missing); return; } _ => {} } - let Some(target_id) = resolve_target(semantic_model, target) else { + let Some(def) = target_def(semantic_model, target) else { return; }; + if def.kind != TypeDefKind::Class { + return; + } // Only the canonical standard-library dictionary bypasses closed-record // checks. A user-defined `table` still has a normal class field surface. @@ -431,70 +269,59 @@ fn check_against_type( // 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)) + && 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(); - let mut visited = Vec::new(); - collect_fields( - semantic_model, - &target_id, - &mut visited, - &mut members, - &mut index_signatures, - ); - if let Some(def) = find_type_def(semantic_model, &target_id) { - collect_object_super_fields(semantic_model, &def, &mut members); - } + let (members, index_signatures) = class_fields(semantic_model, target); let fields = table.get_fields_with_keys(); - let mut field_keys = HashSet::with_capacity(fields.len()); + let constructor = TableConstructor::new(semantic_model, table); // InjectField: table field is not in the target members. for (_, key) in &fields { - // Expression keys still need the index-signature checks for their values. - let member_key = match key { - LuaIndexKey::Expr(_) => None, - _ => static_member_key(semantic_model, key), - }; + let member_key = static_member_key(semantic_model, key); if !member_key .as_ref() .is_some_and(|key| members.contains_key(key)) && !index_signatures .iter() - .any(|signature| signature_matches_key(semantic_model, signature, key)) + .any(|(index, _)| table_index_accepts(semantic_model, index, key)) { - context.add_diagnostic( - DiagnosticCode::InjectField, - table.get_range(), - format!( + mismatches.push(Mismatch { + code: DiagnosticCode::InjectField, + range: table.get_range(), + message: format!( "Field `{}` cannot be injected into the target type.", key.get_path_part() ), - ); - } - if let Some(key) = member_key { - field_keys.insert(key); + }); } } // MissingFields: required (non-nullable) members are missing. let mut missing: Vec<_> = members .iter() - .filter(|(key, ty)| !field_keys.contains(*key) && !is_optional(semantic_model, ty)) + .filter(|(key, ty)| { + matches!(constructor.member_value(key), Ok(None)) && !is_optional(semantic_model, ty) + }) .map(|(key, _)| key.to_path()) .collect(); missing.sort_unstable(); + report_missing_fields(mismatches, table, missing); +} + +fn report_missing_fields( + mismatches: &mut Vec, + table: &LuaTableExpr, + missing: Vec, +) { if !missing.is_empty() { - context.add_diagnostic( - DiagnosticCode::MissingFields, - table.get_range(), - format!( + mismatches.push(Mismatch { + code: DiagnosticCode::MissingFields, + range: table.get_range(), + message: format!( "Missing required fields: {}", missing .iter() @@ -502,87 +329,49 @@ fn check_against_type( .collect::>() .join(", ") ), - ); + }); } } -/// Target type -> named type id (Ref/Def/Generic base class). -fn resolve_target(semantic_model: &SemanticModel<'_>, ty: &LuaType) -> Option { +/// The definition of a named target, before alias expansion. +fn target_def(semantic_model: &SemanticModel<'_>, ty: &LuaType) -> Option { match ty { - LuaType::Ref(id) | LuaType::Def(id) => semantic_model.type_def_of(id).map(|def| def.id), - LuaType::Generic(generic) => semantic_model - .type_def_of(&generic.get_base_type_id()) - .map(|def| def.id), + LuaType::Ref(id) | LuaType::Def(id) => semantic_model.type_def_of(id), + LuaType::Generic(generic) => semantic_model.type_def_of(&generic.get_base_type_id()), _ => None, } } -/// Type definition's `@field` members (including the inheritance chain; `visited` prevents cycles). -fn collect_fields( +/// Project class fields and index signatures, including inherited generic bindings. +pub(super) fn class_fields( semantic_model: &SemanticModel<'_>, - type_def_id: &SemanticId, - visited: &mut Vec, - out: &mut HashMap, - index_signatures: &mut Vec, -) { - if visited.contains(type_def_id) { - return; - } - visited.push(type_def_id.clone()); - for member_ref in semantic_model.members_of_owner(type_def_id).iter() { - let Some(facts) = semantic_model.file_facts_of(member_ref.file_id) else { - continue; - }; - let Some(member) = facts.member_by_id(&member_ref.id) else { - continue; - }; - if member.is_index_signature { - index_signatures.push(member_ref.name.to_string()); - continue; - } - let mut ty = semantic_model - .type_of_member(&member_ref.id) - .unwrap_or(LuaType::Unknown); - if let LuaType::Ref(id) | LuaType::Def(id) = &ty - && let Some(def) = semantic_model.type_def_of(id) - && let Some(alias_target) = semantic_model.alias_target(&def) - { - ty = alias_target; - } - if member.is_nullable && !ty.is_nullable() { - ty = LuaType::Union(Rc::new(LuaUnionType::from_vec(vec![ty, LuaType::Nil]))); - } - out.entry(member.key.clone()).or_insert(ty); - } - // Inheritance chain: locate the type definition; needs def info. - if let Some(def) = find_type_def(semantic_model, type_def_id) { - for super_def in semantic_model.base_type_defs(&def.type_decl_id()) { - collect_fields( - semantic_model, - &super_def.id, - visited, - out, - index_signatures, - ); + target: &LuaType, +) -> (HashMap, Vec<(LuaType, LuaType)>) { + let mut fields = HashMap::new(); + let mut indexes = Vec::new(); + for info in crate::semantic_model::member::class_field_infos(semantic_model, target) { + if let Some(key) = info.index_key_type { + indexes.push((key, info.typ)); + } else { + fields.insert(info.key, info.typ); } } -} - -/// Find a type definition by id (walk the workspace type index). -fn find_type_def(semantic_model: &SemanticModel<'_>, type_def_id: &SemanticId) -> Option { - let SemanticId::TypeDef(key) = type_def_id else { - return None; - }; - let defs = semantic_model.type_defs_in_scope(key.scope, &key.full_name); - defs.iter().next().cloned() + (fields, indexes) } /// Whether the field is optional (nullable / unknown). fn is_optional(semantic_model: &SemanticModel<'_>, ty: &LuaType) -> bool { - let ty = expand_alias_branches(semantic_model, ty).unwrap_or_else(|| ty.clone()); - match &ty { - LuaType::Any | LuaType::Unknown | LuaType::Nil => true, - LuaType::Union(union) => union.into_vec().iter().any(|t| matches!(t, LuaType::Nil)), - _ => false, + fn contains_nil(ty: &LuaType) -> bool { + match ty { + LuaType::Any | LuaType::Unknown | LuaType::Nil => true, + LuaType::Union(union) => union.iter().any(contains_nil), + LuaType::Intersection(intersection) => { + intersection.get_types().iter().all(contains_nil) + } + _ => false, + } } + + let ty = expand_alias_branches(semantic_model, ty).unwrap_or_else(|| ty.clone()); + contains_nil(&ty) } diff --git a/crates/emmylua_code_analysis/src/check/checker/mod.rs b/crates/emmylua_code_analysis/src/check/checker/mod.rs index a149d639e..53a356b5e 100644 --- a/crates/emmylua_code_analysis/src/check/checker/mod.rs +++ b/crates/emmylua_code_analysis/src/check/checker/mod.rs @@ -65,6 +65,13 @@ pub struct Diagnostic { pub data: Option, } +/// A validation error before diagnostic settings are applied. +pub(super) struct Mismatch { + pub code: DiagnosticCode, + pub range: TextRange, + pub message: String, +} + /// Checker trait (mirrors `diagnostic::checker::Checker`). pub trait Checker { /// Diagnostic codes handled by this check (used to skip the whole check by config). @@ -124,20 +131,7 @@ impl<'a> CheckContext<'a> { message: String, data: Option, ) { - if let Some(facts) = self.semantic_model.file_facts() { - let force_enable = facts.file_diagnostic_enabled.contains(&code); - if !force_enable { - if !self.config.is_code_enabled(&code) { - return; - } - if facts.file_diagnostic_disabled.contains(&code) { - return; - } - } - if facts.is_range_diagnostic_disabled(&code, &range) { - return; - } - } else if !self.config.is_code_enabled(&code) { + if !self.is_diagnostic_enabled(code, range) { return; } let severity = self.config.severity_of(&code); @@ -152,6 +146,26 @@ impl<'a> CheckContext<'a> { }); } + pub(super) fn is_diagnostic_enabled(&self, code: DiagnosticCode, range: TextRange) -> bool { + if let Some(facts) = self.semantic_model.file_facts() { + let force_enable = facts.file_diagnostic_enabled.contains(&code); + if !force_enable { + if !self.config.is_code_enabled(&code) { + return false; + } + if facts.file_diagnostic_disabled.contains(&code) { + return false; + } + } + if facts.is_range_diagnostic_disabled(&code, &range) { + return false; + } + } else if !self.config.is_code_enabled(&code) { + return false; + } + true + } + /// Whether the name is in the `emmyrc.diagnostics.globals` / `globals_regex` allowlist. pub fn is_global_disabled(&self, name: &str) -> bool { self.config.is_global_disabled(name) diff --git a/crates/emmylua_code_analysis/src/check/checker/param_count.rs b/crates/emmylua_code_analysis/src/check/checker/param_count.rs index cc2c8a38e..7f07e0ae6 100644 --- a/crates/emmylua_code_analysis/src/check/checker/param_count.rs +++ b/crates/emmylua_code_analysis/src/check/checker/param_count.rs @@ -120,15 +120,7 @@ fn check_call( let colon_call = analysis.colon_call; for candidate in &candidates { - let range = param_count_range(semantic_model, candidate, colon_call); - let colon_extra = usize::from( - colon_call && !candidate.is_colon_define() && !first_param_is_self(candidate), - ); - let call_min = arg_range.min + colon_extra; - let call_max = arg_range.max.map(|max| max + colon_extra); - let enough = call_max.is_none_or(|max| max >= range.min); - let not_too_many = range.max.is_none_or(|max| call_min <= max); - if enough && not_too_many { + if candidate_accepts_arguments(semantic_model, candidate, &args, colon_call) { return; } } @@ -667,6 +659,24 @@ struct ParamCountRange { variadic: bool, } +pub(super) fn candidate_accepts_arguments( + semantic_model: &SemanticModel<'_>, + candidate: &LuaFunctionType, + args: &[LuaExpr], + colon_call: bool, +) -> bool { + let actual = expanded_arg_range(semantic_model, args); + let expected = param_count_range(semantic_model, candidate, colon_call); + let colon_extra = + usize::from(colon_call && !candidate.is_colon_define() && !first_param_is_self(candidate)); + actual + .max + .is_none_or(|max| max + colon_extra >= expected.min) + && expected + .max + .is_none_or(|max| actual.min + colon_extra <= max) +} + fn param_count_range( semantic_model: &SemanticModel<'_>, func: &LuaFunctionType, diff --git a/crates/emmylua_code_analysis/src/check/checker/param_type_check.rs b/crates/emmylua_code_analysis/src/check/checker/param_type_check.rs index 682e47f87..c9566cde4 100644 --- a/crates/emmylua_code_analysis/src/check/checker/param_type_check.rs +++ b/crates/emmylua_code_analysis/src/check/checker/param_type_check.rs @@ -1,7 +1,7 @@ //! # param_type_check - call argument type does not match parameter type //! //! M0+: callee candidates = primary signature + `---@overload`; if any candidate accepts all arguments, don't report, -//! otherwise pick the candidate with the fewest mismatches and report `ParamTypeMismatch` per argument. Colon calls: +//! otherwise report type and field errors from the candidate with the fewest mismatches. Colon calls: //! strip one slot for the self parameter; plain functions treat the receiver as the first argument. use std::rc::Rc; @@ -13,9 +13,10 @@ use emmylua_parser::{ use crate::semantic_model::infer::callable::CallableCandidateSet; use crate::semantic_model::infer::function_solver::functions_compatible; use crate::semantic_model::infer::unify; -use crate::semantic_model::infer::vm::unify_call_bindings; +use crate::semantic_model::infer::vm::{bind_signature_generics, unify_call_bindings}; use crate::semantic_model::render::humanize_type; -use crate::semantic_model::type_check::is_compatible; +use crate::semantic_model::table_constructor::table_expr; +use crate::semantic_model::type_check::{check_type_at, is_compatible}; use crate::semantic_model::{SemanticModel, type_eval}; use crate::{ DiagnosticCode, LuaFunctionType, LuaMemberKey, LuaTupleStatus, LuaTupleType, LuaType, @@ -23,12 +24,16 @@ use crate::{ }; use super::param_count::first_param_is_self; -use super::{CheckContext, Checker}; +use super::{CheckContext, Checker, Mismatch}; pub struct ParamTypeChecker; impl Checker for ParamTypeChecker { - const CODES: &[DiagnosticCode] = &[DiagnosticCode::ParamTypeMismatch]; + const CODES: &[DiagnosticCode] = &[ + DiagnosticCode::ParamTypeMismatch, + DiagnosticCode::MissingFields, + DiagnosticCode::InjectField, + ]; fn check(context: &mut CheckContext<'_>, semantic_model: &SemanticModel<'_>) { let Some(tree) = semantic_model.syntax_tree() else { @@ -44,11 +49,6 @@ impl Checker for ParamTypeChecker { } } -struct Mismatch { - range: rowan::TextRange, - message: String, -} - /// pcall forwarding check: take the first argument's function signature and check the remaining `pcall` arguments against its parameters. /// Returns true if handled (whether or not there are mismatches); returns false if the callback signature cannot be resolved. fn check_pcall_forward( @@ -59,11 +59,16 @@ fn check_pcall_forward( let Some(callback_expr) = args.first() else { return false; }; - let callback_candidates = callable_candidates(semantic_model, callback_expr); + let forwarded = &args[1..]; + let callback_candidates = candidates_for_arguments( + semantic_model, + forwarded, + false, + semantic_model.callable_candidates_for_expr(callback_expr), + ); if callback_candidates.is_empty() { return false; } - let forwarded = &args[1..]; let mut best: Option> = None; for candidate in &callback_candidates { let mismatches = check_candidate( @@ -80,19 +85,13 @@ fn check_pcall_forward( } if best .as_ref() - .is_none_or(|current| mismatches.len() < current.len()) + .is_none_or(|current| mismatch_count(&mismatches) < mismatch_count(current)) { best = Some(mismatches); } } if let Some(mismatches) = best { - for mismatch in mismatches { - context.add_diagnostic( - DiagnosticCode::ParamTypeMismatch, - mismatch.range, - mismatch.message, - ); - } + report_mismatches(context, mismatches, &[]); } true } @@ -219,6 +218,27 @@ fn index_expr_callable_candidates( candidates } +/// Prefer signatures whose argument counts fit, before comparing argument types. +fn candidates_for_arguments( + model: &SemanticModel<'_>, + args: &[LuaExpr], + colon_call: bool, + candidates: Vec, +) -> Vec { + let matching = candidates + .iter() + .filter(|candidate| { + super::param_count::candidate_accepts_arguments(model, candidate, args, colon_call) + }) + .cloned() + .collect::>(); + if matching.is_empty() { + candidates + } else { + matching + } +} + fn check_call( context: &mut CheckContext<'_>, semantic_model: &SemanticModel<'_>, @@ -228,25 +248,16 @@ fn check_call( return; }; let analysis = semantic_model.call_site_analysis(call_expr); - let mut candidates = analysis.candidates; - // Local `---@type F1` (fun alias) declaration structure takes precedence over closure body inference. - if let LuaExpr::NameExpr(name_expr) = &callee - && let Some(decl) = semantic_model.resolve_name(name_expr.get_position()) - { - if let Some(facts) = semantic_model.file_facts() - && let Some(decl_facts) = facts.decl_by_id(&decl) - && let Some(doc_syntax) = decl_facts.doc_type_syntax - { - let doc_ty = semantic_model.doc_type_lua(doc_syntax); - if let Some(func) = doc_function_of(semantic_model, &doc_ty) { - candidates = vec![func]; - } - } - } let args = call_expr .get_args_list() .map(|list| list.get_args().collect::>()) .unwrap_or_default(); + let candidates = candidates_for_arguments( + semantic_model, + &args, + analysis.colon_call, + analysis.candidates, + ); // `pcall(callback, ...)`: forward the remaining arguments to the callback check. if let LuaExpr::NameExpr(callee_name) = &callee && callee_name.get_name_text().as_deref() == Some("pcall") @@ -286,15 +297,54 @@ fn check_call( } if best .as_ref() - .is_none_or(|current| mismatches.len() < current.len()) + .is_none_or(|current| mismatch_count(&mismatches) < mismatch_count(current)) { best = Some(mismatches); } } if let Some(mismatches) = best { let literal_names = doc_overload_literal_names(semantic_model, &callee); - for mismatch in mismatches { - let message = if literal_names.len() >= 2 { + report_mismatches(context, mismatches, &literal_names); + } +} + +fn field_error_covers(field: &Mismatch, range: rowan::TextRange) -> bool { + matches!( + field.code, + DiagnosticCode::MissingFields | DiagnosticCode::InjectField + ) && range.contains_range(field.range) +} + +fn mismatch_count(mismatches: &[Mismatch]) -> usize { + mismatches + .iter() + .filter(|mismatch| { + mismatch.code != DiagnosticCode::ParamTypeMismatch + || !mismatches + .iter() + .any(|field| field_error_covers(field, mismatch.range)) + }) + .count() +} + +fn report_mismatches( + context: &mut CheckContext<'_>, + mismatches: Vec, + literal_names: &[String], +) { + for mismatch in &mismatches { + // Prefer an enabled field error over the whole argument's type error. + // Candidate validation and selection use the unfiltered errors. + if mismatch.code == DiagnosticCode::ParamTypeMismatch + && mismatches.iter().any(|field| { + field_error_covers(field, mismatch.range) + && context.is_diagnostic_enabled(field.code, field.range) + }) + { + continue; + } + let message = + if mismatch.code == DiagnosticCode::ParamTypeMismatch && literal_names.len() >= 2 { let found = mismatch .message .split_once("but found") @@ -302,10 +352,9 @@ fn check_call( .unwrap_or_else(|| mismatch.message.clone()); format!("expected `{}` {found}", literal_names.join(" | ")) } else { - mismatch.message + mismatch.message.clone() }; - context.add_diagnostic(DiagnosticCode::ParamTypeMismatch, mismatch.range, message); - } + context.add_diagnostic(mismatch.code, mismatch.range, message); } } @@ -339,64 +388,6 @@ fn doc_overload_literal_names(semantic_model: &SemanticModel<'_>, callee: &LuaEx .collect() } -/// When object / intersection / union contains an object, check table literals field by field. -fn has_table_literal_shape(ty: &LuaType) -> bool { - match ty { - LuaType::Object(_) => true, - LuaType::Intersection(intersection) => { - intersection.get_types().iter().any(has_table_literal_shape) - } - LuaType::Union(union) => union.into_vec().iter().any(has_table_literal_shape), - _ => false, - } -} - -fn table_literal_mismatch( - semantic_model: &SemanticModel<'_>, - param_ty: &LuaType, - table: &emmylua_parser::LuaTableExpr, -) -> Option { - match param_ty { - LuaType::Object(object) => { - let fields = object.get_fields(); - for (field, key) in table.get_fields_with_keys() { - let path = key.get_path_part(); - let Some(expected) = fields.get(&LuaMemberKey::Name(path.clone().into())) else { - continue; - }; - let Some(value_expr) = field.get_value_expr() else { - continue; - }; - let value_ty = semantic_model.type_of_expr(value_expr.get_syntax_id()); - if value_ty == *expected || is_compatible(semantic_model, &value_ty, expected) { - continue; - } - return Some(Mismatch { - range: value_expr.get_range(), - message: mismatch_message(semantic_model, &value_ty, expected, &path), - }); - } - None - } - LuaType::Intersection(intersection) => intersection - .get_types() - .iter() - .find_map(|component| table_literal_mismatch(semantic_model, component, table)), - LuaType::Union(union) => { - let components = union.into_vec(); - let mut last: Option = None; - for component in components.iter() { - { - let mismatch = table_literal_mismatch(semantic_model, component, table)?; - last = Some(mismatch); - } - } - last - } - _ => None, - } -} - /// A cheap literal/scalar-shape score used only to order call candidates. /// It does not replace the real compatibility check; it only avoids running /// the expensive type-check path on obviously wrong overloads first. @@ -497,7 +488,18 @@ fn check_candidate( explicit_generics: &[LuaSyntaxId], arg_types: Option<&[LuaType]>, ) -> Vec { - let params = func.get_params(); + // Signature projections can leave generic names as `Ref("T")`. + let params = func + .get_params() + .iter() + .map(|(name, ty)| { + ( + name.clone(), + ty.as_ref() + .map(|ty| bind_signature_generics(ty, func.get_generic_params())), + ) + }) + .collect::>(); let self_param = first_param_is_self(func); let param_start = usize::from(colon_call && self_param).min(params.len()); // Method definition called with dot syntax (`obj.method(obj, ...)` / `pcall(obj.method, obj, ...)`): @@ -510,7 +512,7 @@ fn check_candidate( // Explicit call generics `test--[[@]]()` fill bindings first instead of inferring from arguments. for (index, syntax) in explicit_generics.iter().enumerate() { if let Some(tpl) = func.get_generic_params().get(index) { - let ty = semantic_model.doc_type_lua_in(semantic_model.file_id(), *syntax, &[]); + let ty = semantic_model.doc_type_lua_rich(*syntax); bindings.insert(tpl.get_tpl_id(), ty); } } @@ -526,13 +528,14 @@ fn check_candidate( let _ = unify_call_bindings(semantic_model, ¶m_ty, receiver_ty, &mut bindings); if !is_compatible(semantic_model, receiver_ty, ¶m_ty) { mismatches.push(Mismatch { + code: DiagnosticCode::ParamTypeMismatch, range: receiver_range(args), message: mismatch_message(semantic_model, receiver_ty, ¶m_ty, name), }); } } let remainder_params = if params.is_empty() { - params + params.as_slice() } else { ¶ms[1..] }; @@ -674,6 +677,7 @@ fn check_arg_pairs( && !unify_call_bindings(semantic_model, &slot_ty, &arg_ty, bindings) { out.push(Mismatch { + code: DiagnosticCode::ParamTypeMismatch, range: arg.get_range(), message: mismatch_message(semantic_model, &arg_ty, &slot_ty, name), }); @@ -681,10 +685,17 @@ fn check_arg_pairs( } slot_ty = unify::substitute(&slot_ty, bindings); slot_ty = type_eval::expand_alias_generic(semantic_model, &slot_ty); + if let Some(mismatches) = + table_argument_mismatches(semantic_model, arg, &arg_ty, &slot_ty) + { + out.extend(mismatches); + continue; + } if arg_ty == slot_ty || is_compatible(semantic_model, &arg_ty, &slot_ty) { continue; } out.push(Mismatch { + code: DiagnosticCode::ParamTypeMismatch, range: arg.get_range(), message: mismatch_message(semantic_model, &arg_ty, &slot_ty, name), }); @@ -699,6 +710,7 @@ fn check_arg_pairs( unify_call_bindings(semantic_model, ¶m_ty_orig, &arg_ty, bindings); if param_ty_orig.contains_tpl_node() && !unified { out.push(Mismatch { + code: DiagnosticCode::ParamTypeMismatch, range: arg.get_range(), message: mismatch_message( semantic_model, @@ -710,6 +722,12 @@ fn check_arg_pairs( continue; } let param_ty = unify::substitute(¶m_ty_orig, bindings); + if let Some(mismatches) = + table_argument_mismatches(semantic_model, arg, &arg_ty, ¶m_ty) + { + out.extend(mismatches); + continue; + } if arg_ty == param_ty || param_generic_base_match(semantic_model, &arg_ty, ¶m_ty) || is_compatible(semantic_model, &arg_ty, ¶m_ty) @@ -717,6 +735,7 @@ fn check_arg_pairs( continue; } out.push(Mismatch { + code: DiagnosticCode::ParamTypeMismatch, range: arg.get_range(), message: mismatch_message(semantic_model, &arg_ty, ¶m_ty, name), }); @@ -744,27 +763,12 @@ fn check_arg_pairs( && semantic_model.member_missing_in_union(index_expr) { out.push(Mismatch { + code: DiagnosticCode::ParamTypeMismatch, range: arg.get_range(), message: mismatch_message(semantic_model, &LuaType::Nil, ¶m_ty, name), }); continue; } - // Table literals targeting object / intersection: check field by field and then fully take over. - if let LuaExpr::TableExpr(table) = arg { - if has_table_literal_shape(¶m_ty) { - if let Some(mismatch) = table_literal_mismatch(semantic_model, ¶m_ty, table) { - out.push(mismatch); - } - continue; - } - // Named generic class `Params`: table literals missing required `@field`s are also incompatible. - if let Some(mismatch) = - generic_table_required_mismatch(semantic_model, ¶m_ty, table) - { - out.push(mismatch); - continue; - } - } if skip_arg(&call_arg_ty, ¶m_ty) { continue; } @@ -774,14 +778,15 @@ fn check_arg_pairs( { if !is_compatible(semantic_model, &call_arg_ty, ¶m_ty) { out.push(Mismatch { + code: DiagnosticCode::ParamTypeMismatch, range: arg.get_range(), message: mismatch_message(semantic_model, &call_arg_ty, ¶m_ty, name), }); } continue; } - // `---@param x string|true`: non-function arguments pass when they match any union component. - // Unions containing generic components (`` `T`|T ``) cannot be swallowed early by is_compatible, otherwise T won't bind. + // Keep constructors and callbacks on the full check path below. + // Generic unions (`` `T`|T ``) must bind T before compatibility can accept them. let has_generic_union_tpl = matches!( ¶m_ty, LuaType::Union(union) @@ -793,7 +798,10 @@ fn check_arg_pairs( if !has_generic_union_tpl && !matches!( call_arg_ty, - LuaType::DocFunction(_) | LuaType::Signature(_) | LuaType::Function + LuaType::DocFunction(_) + | LuaType::Signature(_) + | LuaType::Function + | LuaType::TableConst(_) ) && let LuaType::Union(union) = ¶m_ty { @@ -813,6 +821,14 @@ fn check_arg_pairs( // Unify first: T[] <- (A|B|C|D)[] and similar. let _ = unify_call_bindings(semantic_model, ¶m_ty, &call_arg_ty, bindings); let param_ty = unify::substitute(¶m_ty, bindings); + // Check constructors after binding this argument, including field constraints + // that do not depend on unresolved generics. + if let Some(mismatches) = + table_argument_mismatches(semantic_model, arg, &call_arg_ty, ¶m_ty) + { + out.extend(mismatches); + continue; + } // After substituting generic unions (`T|StrTpl` -> `Table|...`), arguments pass if they match any component. if let LuaType::Union(union) = ¶m_ty { if union @@ -841,6 +857,7 @@ fn check_arg_pairs( continue; } out.push(Mismatch { + code: DiagnosticCode::ParamTypeMismatch, range: arg.get_range(), message: mismatch_message(semantic_model, &arg_ty, ¶m_ty, name), }); @@ -848,61 +865,26 @@ fn check_arg_pairs( out } -fn generic_table_required_mismatch( - semantic_model: &SemanticModel<'_>, - param_ty: &LuaType, - table: &emmylua_parser::LuaTableExpr, -) -> Option { - let id = match param_ty { - LuaType::Generic(generic) => generic.get_base_type_id().clone(), - LuaType::Ref(id) | LuaType::Def(id) => id.clone(), - LuaType::Union(union) => { - return union.into_vec().iter().find_map(|component| { - generic_table_required_mismatch(semantic_model, component, table) - }); - } - LuaType::Intersection(intersection) => { - return intersection.get_types().iter().find_map(|component| { - generic_table_required_mismatch(semantic_model, component, table) - }); - } - _ => return None, - }; - let def = semantic_model.type_def_of(&id)?; - if def.kind != TypeDefKind::Class { +fn table_argument_mismatches( + model: &SemanticModel<'_>, + arg: &LuaExpr, + actual: &LuaType, + expected: &LuaType, +) -> Option> { + if !matches!(actual, LuaType::TableConst(_)) { return None; } - let provided: Vec = table - .get_fields_with_keys() - .iter() - .map(|(_, key)| key.get_path_part()) - .collect(); - for member_ref in semantic_model.members_of_owner(&def.id).iter() { - let Some(facts) = semantic_model.file_facts_of(member_ref.file_id) else { - continue; - }; - let Some(member) = facts.member_by_id(&member_ref.id) else { - continue; - }; - if member.is_nullable || member.is_index_signature { - continue; - } - if !provided.contains(&member_ref.name.to_string()) { - let expected = semantic_model - .type_of_member(&member_ref.id) - .unwrap_or(LuaType::Unknown); - return Some(Mismatch { - range: table.get_range(), - message: mismatch_message( - semantic_model, - &semantic_model.type_of_expr(table.get_syntax_id()), - &expected, - &member_ref.name, - ), - }); - } + let mut mismatches = table_expr(arg).map_or_else(Vec::new, |table| { + super::check_field::table_literal_mismatches(model, expected, &table) + }); + if check_type_at(model, actual, expected, arg.get_range(), false).is_err() { + mismatches.push(Mismatch { + code: DiagnosticCode::ParamTypeMismatch, + range: arg.get_range(), + message: mismatch_message(model, actual, expected, ""), + }); } - None + Some(mismatches) } /// Object index signature vs `V[]`: an integer-key signature satisfies an array and binds the values to V. diff --git a/crates/emmylua_code_analysis/src/check/checker/return_type_mismatch.rs b/crates/emmylua_code_analysis/src/check/checker/return_type_mismatch.rs index 75f646960..eaf1f89be 100644 --- a/crates/emmylua_code_analysis/src/check/checker/return_type_mismatch.rs +++ b/crates/emmylua_code_analysis/src/check/checker/return_type_mismatch.rs @@ -8,7 +8,7 @@ use emmylua_parser::{LuaAstNode, LuaClosureExpr, LuaReturnStat}; use crate::LuaType; use crate::semantic_model::SemanticModel; -use crate::semantic_model::type_check::is_compatible; +use crate::semantic_model::type_check::check_type_at; use crate::{DiagnosticCode, SemanticId, SignatureDoc, TypeDef}; use super::{CheckContext, Checker}; @@ -84,7 +84,7 @@ fn check_return_stat( // Uninstantiated function generic argument `Ref("T")`: check against the constraint when present, otherwise skip. if is_unresolved_generic_ref(semantic_model, &raw, docs) { if let Some(constraint) = generic_constraint_type(semantic_model, &raw) - && !return_compatible(semantic_model, &constraint, expected) + && !return_compatible(semantic_model, &constraint, expected, expr.get_range()) { context.add_diagnostic( DiagnosticCode::ReturnTypeMismatch, @@ -101,7 +101,7 @@ fn check_return_stat( } let actual = generic_constraint_type(semantic_model, &raw).unwrap_or(raw); if matches!(actual, LuaType::Unknown | LuaType::Any | LuaType::Never) - || return_compatible(semantic_model, &actual, expected) + || return_compatible(semantic_model, &actual, expected, expr.get_range()) { continue; } @@ -126,18 +126,19 @@ fn return_compatible( semantic_model: &SemanticModel<'_>, source: &LuaType, target: &LuaType, + range: rowan::TextRange, ) -> bool { if source == target { return true; } - if is_compatible(semantic_model, source, target) { + if check_type_at(semantic_model, source, target, range, false).is_ok() { return true; } if let LuaType::Union(target_union) = target { return target_union .into_vec() .iter() - .any(|component| return_compatible(semantic_model, source, component)); + .any(|component| return_compatible(semantic_model, source, component, range)); } generic_extends_named(semantic_model, source, target) } 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 21d95350f..6475266b0 100644 --- a/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs +++ b/crates/emmylua_code_analysis/src/check/checker/undefined_field.rs @@ -41,12 +41,43 @@ fn check_index( semantic_model: &SemanticModel<'_>, index_expr: &LuaIndexExpr, ) { + if let Some(construction) = + crate::semantic_model::table_construction::access(semantic_model, index_expr) + { + let is_write = index_expr.syntax().parent().is_some_and(|parent| { + emmylua_parser::LuaAssignStat::cast(parent.clone()).is_some_and(|stat| { + stat.get_var_and_expr_list() + .0 + .iter() + .any(|var| var.get_syntax_id() == index_expr.get_syntax_id()) + }) || emmylua_parser::LuaFuncStat::cast(parent).is_some_and(|stat| { + stat.get_func_name() + .is_some_and(|name| name.get_syntax_id() == index_expr.get_syntax_id()) + }) + }); + let valid = if is_write { + construction.write_targets().is_some() + } else { + construction.has_field() + }; + if !valid { + let key = index_expr.get_index_key(); + context.add_diagnostic( + DiagnosticCode::UndefinedField, + key.as_ref() + .and_then(|key| key.get_range()) + .unwrap_or_else(|| index_expr.get_range()), + t!( + "Undefined field: `%{name}`", + name = key.map(|key| key.get_path_part()).unwrap_or_default() + ), + ); + } + return; + } let Some(resolved) = semantic_model.resolve_member(index_expr) else { return; }; - if resolved.member_id.is_some() { - return; - } let Some(prefix) = index_expr.get_prefix_expr() else { return; }; @@ -67,6 +98,9 @@ fn check_index( .unwrap_or_else(|| semantic_model.type_of_expr(prefix.get_syntax_id())), _ => semantic_model.type_of_expr(prefix.get_syntax_id()), }; + if resolved.member_id.is_some() && !matches!(prefix_ty, LuaType::Object(_)) { + return; + } if is_invalid_prefix_type(&prefix_ty) { return; } diff --git a/crates/emmylua_code_analysis/src/check/test/assign_type_mismatch_test.rs b/crates/emmylua_code_analysis/src/check/test/assign_type_mismatch_test.rs index ef9f50c60..845e2a124 100644 --- a/crates/emmylua_code_analysis/src/check/test/assign_type_mismatch_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/assign_type_mismatch_test.rs @@ -3,6 +3,136 @@ mod tests { use crate::check::test::{check_source, check_source_with_emmyrc}; use crate::{DiagnosticCode, Emmyrc, VirtualWorkspace}; + #[test] + fn test_constructor_errors_point_to_the_invalid_value() { + for (target, constructor, invalid_value) in [ + ( + "{item: {count: integer}}", + "{item = {count = 'bad'}}", + "'bad'", + ), + ("integer[]", "{1, 'bad'}", "'bad'"), + ("table", "{1, 'bad'}", "'bad'"), + ("{item: integer}|{other: string}", "{item = 'bad'}", "'bad'"), + ("{[1]: string, [2]: integer}", "{pair()}", "pair()"), + ] { + let source = format!( + "local function pair() return 'ok', false end\n---@type {target}\nlocal value = ({constructor})\nreturn value, pair" + ); + let diagnostics = check_source(&source); + assert_eq!( + diagnostics + .iter() + .map(|d| (d.code, &source[d.range])) + .collect::>(), + [(DiagnosticCode::AssignTypeMismatch, invalid_value)], + "{source}\n{diagnostics:#?}" + ); + } + } + + #[test] + fn test_class_initializers_can_omit_instance_fields() { + for (setup, scope) in [("", "local "), ("", ""), ("local Record, Broken", "")] { + for (initializers, valid, invalid) in [ + ("", "{}", "{count = 'bad'}"), + ("", "({})", "({count = 'bad'})"), + ( + "local valid = {}; local invalid = {count = 'bad'}", + "valid", + "invalid", + ), + ( + "local function valid() return {} end; local function invalid() return {count = 'bad'} end", + "valid()", + "invalid()", + ), + ( + "local function valid() return {}, 1 end; local function invalid() return {count = 'bad'}, 1 end", + "valid()", + "invalid()", + ), + ( + "local function valid(flag) if flag then return {} end return {count = 1} end; local function invalid(flag) if flag then return {} end return {count = 'bad'} end", + "valid(true)", + "invalid(false)", + ), + ] { + let source = format!( + r#" + {setup} + {initializers} + ---@class Record + ---@field count integer + {scope}Record = {valid} + ---@class Broken + ---@field count integer + {scope}Broken = {invalid} + ---@type Record + local instance = {{}} + instance = {{}} + return Record, Broken, instance + "# + ); + let diagnostics = check_source(&source); + assert_eq!( + diagnostics.iter().map(|d| d.code).collect::>(), + [ + DiagnosticCode::AssignTypeMismatch, + DiagnosticCode::MissingFields, + DiagnosticCode::MissingFields, + ], + "{source}\n{diagnostics:?}" + ); + } + } + } + + #[test] + fn test_class_factory_initializers_check_nested_fields_across_files() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_file( + "class_factory.lua", + r#" + local factory = {} + function factory.empty() return {} end + function factory.valid() return {child = {count = 1}} end + function factory.invalid() return {child = {}} end + return factory + "#, + ); + let source = r#" + local factory = require('class_factory') + ---@class Child + ---@field count integer + local _Child = {} + ---@class Empty + ---@field child Child + local Empty = factory.empty() + ---@class Valid + ---@field child Child + local Valid = factory.valid() + ---@class Invalid + ---@field child Child + local Invalid = factory.invalid() + return Empty, Valid, Invalid + "#; + let file = ws.def(source); + let diagnostics = ws + .analysis + .diagnose_file(file, tokio_util::sync::CancellationToken::new()) + .unwrap(); + assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); + assert_eq!( + diagnostics[0].code, + Some(lsp_types::NumberOrString::String( + DiagnosticCode::AssignTypeMismatch.get_name().into() + )), + "{diagnostics:?}" + ); + assert_eq!(diagnostics[0].range.start.line, 13, "{diagnostics:?}"); + } + #[test] fn test_numeric_member_guards_use_equivalent_integer_keys() { for (key, write, accepted) in [ @@ -727,6 +857,26 @@ mod tests { } } + #[test] + fn test_table_constructor_union_values_check_all_non_nil_alternatives() { + let source = r#" + ---@param text string? + ---@param mixed string|integer|nil + local function pack(text, mixed) + ---@type string[] + local valid = {text} + ---@type string[] + local invalid = {mixed} + return valid, invalid + end + return pack + "#; + let diagnostics = check_source(source); + assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}"); + assert_eq!(diagnostics[0].code, DiagnosticCode::AssignTypeMismatch); + assert_eq!(&source[diagnostics[0].range], "mixed", "{diagnostics:#?}"); + } + #[test] fn test_vararg_packing_keeps_tuple_assignment_checks() { let source = r#" @@ -1483,6 +1633,31 @@ mod tests { )); } + #[test] + fn test_table_constructor_intersection_checks_all_fields() { + let source = r#" + ---@type {a: string} & {b: integer} + local valid = {a = 'ok', b = 1} + ---@type {a: string} & {b: integer} + local wrong = {a = 'ok', b = false} + ---@type {a: string} & {b: integer} + local missing = {a = 'ok'} + return valid, wrong, missing + "#; + let diagnostics = check_source(source); + assert_eq!( + diagnostics + .iter() + .map(|d| (d.code, &source[d.range])) + .collect::>(), + [ + (DiagnosticCode::AssignTypeMismatch, "false"), + (DiagnosticCode::MissingFields, "{a = 'ok'}"), + ], + "{diagnostics:#?}" + ); + } + #[test] fn test_intersection_assign_tableconst_conflict() { let mut ws = VirtualWorkspace::new(); @@ -1606,7 +1781,7 @@ m.ints = {} DiagnosticCode::AssignTypeMismatch, r#" ---@class A ----@field x A +---@field x? A ---@type A local t @@ -1620,6 +1795,19 @@ t.x = {} DiagnosticCode::AssignTypeMismatch, r#" ---@class A +---@field x A + +---@type A +local t + +t.x = {} + "# + )); + + assert!(!ws.has_no_diagnostic_in_namespace( + DiagnosticCode::AssignTypeMismatch, + r#" +---@class A ---@field x integer ---@type A @@ -1730,7 +1918,7 @@ t = nil "# )); - assert!(ws.has_no_diagnostic_in_namespace( + assert!(!ws.has_no_diagnostic_in_namespace( DiagnosticCode::AssignTypeMismatch, r#" local t = { true } @@ -1897,7 +2085,7 @@ b.x = a.x "# )); - assert!(ws.has_no_diagnostic_in_namespace( + assert!(!ws.has_no_diagnostic_in_namespace( DiagnosticCode::AssignTypeMismatch, r#" local mt = {} @@ -2371,7 +2559,7 @@ return t fn test_param_tbale() { let mut ws = VirtualWorkspace::new(); assert!(!ws.has_no_diagnostic( - DiagnosticCode::AssignTypeMismatch, + DiagnosticCode::ParamTypeMismatch, r#" ---@class ability ---@field t abilityType @@ -2420,7 +2608,7 @@ return t "#, ); assert!(!ws.has_no_diagnostic( - DiagnosticCode::AssignTypeMismatch, + DiagnosticCode::ParamTypeMismatch, r#" name({ toBe = 1, diff --git a/crates/emmylua_code_analysis/src/check/test/check_export_test.rs b/crates/emmylua_code_analysis/src/check/test/check_export_test.rs index 5786ef050..715e26e94 100644 --- a/crates/emmylua_code_analysis/src/check/test/check_export_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/check_export_test.rs @@ -106,9 +106,9 @@ fn test_export_direct_require() { assert_eq!(count_by_code(&diags, DiagnosticCode::InjectField), 1); } -/// Non-require local tables are unrestricted. +/// Local construction allows new fields but reports reads of missing fields. #[test] -fn test_local_table_not_limited() { +fn test_local_table_construction_checks_reads() { let diags = check_source( r#" local t = {} @@ -117,5 +117,5 @@ fn test_local_table_not_limited() { "#, ); assert_eq!(count_by_code(&diags, DiagnosticCode::InjectField), 0); - assert_eq!(count_by_code(&diags, DiagnosticCode::UndefinedField), 0); + assert_eq!(count_by_code(&diags, DiagnosticCode::UndefinedField), 1); } 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 c1d8d6adb..77e195555 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 @@ -3,6 +3,1695 @@ mod test { use crate::check::test::check_source; use crate::{DiagnosticCode, VirtualWorkspace}; + mod generic_tables { + use super::*; + + use DiagnosticCode::{AssignTypeMismatch, ParamTypeMismatch}; + + fn assert_diagnostics(source: &str, expected: &[DiagnosticCode]) { + let diagnostics = check_source(source); + assert_eq!( + diagnostics.iter().map(|d| d.code).collect::>(), + expected, + "{source}\n{diagnostics:?}" + ); + } + + fn assert_workspace_diagnostics( + ws: &mut VirtualWorkspace, + source: &str, + expected: &[(DiagnosticCode, u32)], + ) { + let file = ws.def(source); + let diagnostics = ws + .analysis + .diagnose_file(file, tokio_util::sync::CancellationToken::new()) + .unwrap(); + let expected: Vec<_> = expected + .iter() + .map(|(code, line)| { + ( + Some(lsp_types::NumberOrString::String(code.get_name().into())), + *line, + ) + }) + .collect(); + assert_eq!( + diagnostics + .iter() + .map(|d| (d.code.clone(), d.range.start.line)) + .collect::>(), + expected, + "{source}\n{diagnostics:?}" + ); + } + + #[test] + fn test_disabled_field_errors_preserve_type_errors() { + use crate::Emmyrc; + use crate::check::test::check_source_with_emmyrc; + + for (code, declaration, target, argument) in [ + ( + DiagnosticCode::MissingFields, + "---@param opts {label: string, required: string}", + "{label: string, required: string}", + "{label = 1}", + ), + ( + DiagnosticCode::InjectField, + "---@class Options\n---@field label string\nlocal _Options = {}\n---@param opts Options", + "Options", + "{label = 1, extra = true}", + ), + ] { + let source = format!( + "{declaration}\nlocal function accept(opts) return opts end\naccept({argument})\npcall(accept, {argument})\n---@type {target}\nlocal assignment = {argument}\nreturn assignment" + ); + let mut config = Emmyrc::default(); + config.diagnostics.disable.push(code); + let diagnostics = check_source_with_emmyrc(&source, config); + assert_eq!( + diagnostics.iter().map(|d| d.code).collect::>(), + [ParamTypeMismatch, ParamTypeMismatch, AssignTypeMismatch], + "{source}\n{diagnostics:?}" + ); + assert_diagnostics( + &format!("---@diagnostic disable: {}\n{source}", code.get_name()), + &[ParamTypeMismatch, ParamTypeMismatch, AssignTypeMismatch], + ); + } + assert_diagnostics( + r#" + ---@diagnostic disable: missing-fields + ---@type {label: string} + local value = {} + return value + "#, + &[AssignTypeMismatch], + ); + assert_diagnostics( + r#" + ---@param opts {label: string} + ---@overload fun(opts: string) + local function accept(opts) return opts end + accept({}) + "#, + &[DiagnosticCode::MissingFields], + ); + } + + #[test] + fn test_class_index_signatures_use_projected_key_and_value_types() { + for (target, valid_key, invalid_key) in [ + ("Dict", "1", "'extra'"), + ("Derived", "1", "'extra'"), + ("Dict", "1", "'extra'"), + ("ObjectBase", "1", "'extra'"), + ("MixedBase", "1", "true"), + ("Dict", "'left'", "'extra'"), + ] { + let source = format!( + r#" + ---@alias NumericKey integer + ---@alias LabelKey 'left'|'right' + ---@class Dict + ---@field [K] V + ---@field label? V + local _Dict = {{}} + ---@class Derived: Dict + local _Derived = {{}} + ---@class ObjectBase: {{[integer]: V}} + ---@field label? V + local _ObjectBase = {{}} + ---@class MixedBase: {{[integer]: V, [string]: V}} + local _MixedBase = {{}} + ---@param values {target} + local function accept(values) return values end + accept({{[{valid_key}] = 'ok', label = 'ok'}}) + accept({{[{invalid_key}] = 'ok'}}) + accept({{[{valid_key}] = true}}) + ---@type {target} + local valid = {{[{valid_key}] = 'ok'}} + ---@type {target} + local invalid_key = {{[{invalid_key}] = 'ok'}} + ---@type {target} + local invalid_value = {{[{valid_key}] = true}} + return valid, invalid_key, invalid_value + "# + ); + assert_diagnostics( + &source, + &[ + DiagnosticCode::InjectField, + ParamTypeMismatch, + DiagnosticCode::InjectField, + AssignTypeMismatch, + ], + ); + } + } + + #[test] + fn test_array_assignments_check_keys_and_expanded_results() { + assert_diagnostics( + r#" + ---@return string, integer + local function pair() return 'ok', 1 end + ---@param values string[] + local function accept(values) return values end + accept({pair()}) + accept({name = 'ok'}) + ---@type string[] + local values = {(pair()), pair(), 'last'} + values = {[1] = pair()} + values = {pair()} + values = {name = 'ok'} + ---@type string[] + local invalid_results = {pair()} + ---@type string[] + local invalid_key = {name = 'ok'} + ---@type {values: string[]} + local nested = {values = {pair()}} + return values, invalid_results, invalid_key, nested + "#, + &[ + ParamTypeMismatch, + ParamTypeMismatch, + AssignTypeMismatch, + AssignTypeMismatch, + AssignTypeMismatch, + AssignTypeMismatch, + AssignTypeMismatch, + ], + ); + } + + #[test] + fn test_dictionary_values_keep_referenced_table_types() { + assert_diagnostics( + r#" + local valid = {'ok'} + local invalid = {name = 1} + local holder = {child = invalid} + local function child() return {name = 1} end + ---@param items table> + local function accept(items) return items end + accept({valid}) + accept({invalid}) + accept({holder.child}) + accept({child()}) + ---@type table> + local assignment = {invalid} + return assignment + "#, + &[ + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + AssignTypeMismatch, + ], + ); + } + + #[test] + #[ntest::timeout(10000)] + fn test_recursive_dictionary_references_check_invalid_leaves() { + for (alias, target) in [ + ("Tree table", "Tree"), + ("Tree table>", "Tree"), + ] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_files(vec![ + ("a.lua", "return {child = require('b'), label = 1}"), + ("b.lua", "return {child = require('a')}"), + ]); + assert_workspace_diagnostics( + &mut ws, + &format!( + "---@alias {alias}\n---@param values {target}\nlocal function accept(values) return values end\naccept({{child = require('a')}})\naccept({{child = require('b')}})" + ), + &[(ParamTypeMismatch, 3), (ParamTypeMismatch, 4)], + ); + } + } + + #[test] + #[ntest::timeout(10000)] + fn test_recursive_dictionary_references_keep_used_generic_arguments() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_files(vec![ + ("a.lua", "return {child = require('b'), label = 'ok'}"), + ("b.lua", "return {child = require('a')}"), + ]); + assert_workspace_diagnostics( + &mut ws, + "---@alias Tree table>\n---@param values Tree\nlocal function accept(values) return values end\naccept({child = require('a')})\naccept({child = require('b')})", + &[(ParamTypeMismatch, 3), (ParamTypeMismatch, 4)], + ); + } + + #[test] + fn test_record_union_selection_checks_required_fields() { + assert_diagnostics( + r#" + ---@alias Options {left: string}|{right: integer} + ---@param opts Options + local function accept(opts) return opts end + accept({left = 'ok'}) + accept({right = 1}) + accept({}) + ---@type Options + local valid = {right = 1} + return valid + "#, + &[DiagnosticCode::MissingFields], + ); + assert_diagnostics( + r#" + ---@class Record + ---@field label string + local _Record = {} + ---@param opts Record|table + local function accept(opts) return opts end + accept({label = 'ok', extra = 1}) + "#, + &[], + ); + } + + #[test] + fn test_nil_values_do_not_create_dictionary_entries() { + assert_diagnostics( + r#" + ---@param values table + local function accept(values) return values end + accept({name = nil}) + accept({[false] = nil}) + accept({[1] = nil}) + accept({name = 'bad'}) + accept({1}) + ---@type table + local empty = {name = nil} + return empty + "#, + &[ParamTypeMismatch, ParamTypeMismatch], + ); + } + + #[test] + fn test_nullable_dictionary_values_check_only_present_entries() { + assert_diagnostics( + r#" + ---@alias MaybeText string? + + ---@class Texts + ---@field [string] string + local _Texts = {} + ---@param values table + local function accept(values) return values end + ---@param values Texts + local function named(values) return values end + ---@param text MaybeText + ---@param invalid integer? + local function build(text, invalid) + accept({text}) + accept({[1] = text}) + named({name = text}) + ---@type table + local valid = {text} + accept({name = text}) + accept({invalid}) + named({name = invalid}) + ---@type table + local wrong_key = {name = text} + ---@type table + local wrong_value = {invalid} + return valid, wrong_key, wrong_value + end + return build + "#, + &[ + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + AssignTypeMismatch, + AssignTypeMismatch, + ], + ); + } + + #[test] + #[ntest::timeout(10000)] + fn test_recursive_dictionaries_ignore_only_unused_arguments() { + for (alias, target) in [ + ( + "Tree table>|boolean", + "Tree", + ), + ( + "Tree table>", + "Tree", + ), + ] { + for (leaf, expected) in [ + ("'ok'", &[][..]), + ( + "1", + &[ + (ParamTypeMismatch, 5), + (ParamTypeMismatch, 6), + (AssignTypeMismatch, 8), + ][..], + ), + ] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_files(vec![ + ( + "a.lua", + &format!("return {{child = require('b'), label = {leaf}}}"), + ), + ("b.lua", "return {child = require('a')}"), + ]); + assert_workspace_diagnostics( + &mut ws, + &format!( + "---@alias {alias}\nlocal a = require('a')\nlocal b = require('b')\n---@param values {target}\nlocal function accept(values) return values end\naccept({{child = a}})\npcall(accept, {{child = b}})\n---@type {target}\nlocal root = {{child = a}}\nreturn root" + ), + expected, + ); + } + } + } + + #[test] + #[ntest::timeout(10000)] + fn test_recursive_arguments_keep_indirect_field_dependencies() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_files(vec![ + ("a.lua", "return {label = 'ok', child = require('b')}"), + ("b.lua", "return {label = 1, child = require('a')}"), + ]); + assert_workspace_diagnostics( + &mut ws, + "---@alias Tree {label: T, child?: Tree}\nlocal root = require('a')\n---@param value Tree\nlocal function accept(value) return value end\naccept(root)\n---@param value Tree\nlocal function reject(value) return value end\nreject(root)", + &[(ParamTypeMismatch, 7)], + ); + } + + #[test] + fn test_dictionary_constructors_expand_only_the_final_unkeyed_field() { + assert_diagnostics( + r#" + ---@return string, integer + local function pair() return 'ok', 1 end + ---@return string, string + local function texts() return 'one', 'two' end + local function nothing() end + ---@param values table + local function accept(values) return values end + accept({pair()}) + accept({(pair())}) + accept({[1] = pair()}) + accept({pair(), 'last'}) + accept({texts()}) + accept({nothing()}) + ---@class TextList + ---@field [integer] string + local _TextList = {} + ---@param values TextList + local function named(values) return values end + named({texts()}) + named({pair()}) + ---@param values table<1, string> + local function single(values) return values end + single({texts()}) + ---@type table + local invalid = {pair()} + return invalid + "#, + &[ + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + AssignTypeMismatch, + ], + ); + } + + #[test] + fn test_dictionary_constructors_check_repeated_result_values_and_keys() { + assert_diagnostics( + r#" + ---@param values table + local function accept(values) return values end + ---@param values table<1, string> + local function single(values) return values end + ---@param ... string + local function text_values(...) + accept({...}) + accept({(...), 'last'}) + single({...}) + end + ---@param ... string|integer + local function mixed_values(...) + accept({...}) + end + text_values('ok') + mixed_values('ok', 1) + "#, + &[ParamTypeMismatch, ParamTypeMismatch], + ); + } + + #[test] + fn test_dictionary_constructors_check_all_return_alternatives() { + assert_diagnostics( + r#" + ---@param flag boolean + local function alternatives(flag) + if flag then return 'one', 'two', 3 end + return 'one', 'two' + end + ---@param values table + local function accept(values) return values end + accept({alternatives(true)}) + accept({(alternatives(true))}) + local function prefixed(flag) return 'prefix', alternatives(flag) end + accept({prefixed(true)}) + accept({(prefixed(true))}) + "#, + &[ParamTypeMismatch, ParamTypeMismatch], + ); + } + + #[test] + fn test_call_field_errors_use_one_bound_candidate() { + let valid = r#" + ---@param opts {left: string} + ---@overload fun(opts: {right: integer}) + local function accept(opts) return opts end + accept({left = 'ok'}) + accept({right = 1}) + + local receiver = {} + ---@param opts {child: {label: string}} + function receiver:accept(opts) return self, opts end + receiver.accept({}, {child = {label = 'ok'}}) + receiver:accept({child = {label = 'ok'}}) + "#; + assert_diagnostics(valid, &[]); + let mut ws = VirtualWorkspace::new(); + assert!(ws.has_no_diagnostic(DiagnosticCode::MissingFields, valid)); + let invalid = r#" + local receiver = {} + ---@param opts {child: {label: string}} + function receiver:accept(opts) return self, opts end + receiver.accept(receiver, {child = {}}) + receiver:accept({child = {}}) + + ---@generic T + ---@param opts {child: T} + local function accept(opts) return opts end + accept--[[@<{label: string}>]]({child = {label = 'ok'}}) + accept--[[@<{label: string}>]]({child = {}}) + + ---@param ... {label: string} + local function many(...) return ... end + many({label = 'ok'}, {}) + pcall(many, {label = 'ok'}, {}) + "#; + assert_diagnostics( + invalid, + &[ + DiagnosticCode::MissingFields, + DiagnosticCode::MissingFields, + DiagnosticCode::MissingFields, + DiagnosticCode::MissingFields, + DiagnosticCode::MissingFields, + ], + ); + assert!(!ws.has_no_diagnostic(DiagnosticCode::MissingFields, invalid)); + } + + #[test] + fn test_nested_table_arguments_use_receiver_and_generic_bindings() { + assert_diagnostics( + r#" + local receiver = {} + ---@param opts {label: string, values?: table} + function receiver:accept(opts) return self, opts end + receiver.accept(receiver, {label = 'ok', values = {'ok'}}) + receiver.accept(receiver, {label = {}}) + receiver.accept(receiver, {label = 'ok', values = {1}}) + receiver:accept({label = 'ok', values = {1}}) + + ---@generic T + ---@param opts {values: table} + local function accept(opts) return opts end + accept--[[@]]({values = {'ok'}}) + accept--[[@]]({values = {1}}) + + ---@generic T + ---@param value T + ---@param opts {values: table} + local function inferred(value, opts) return value, opts end + inferred('ok', {values = {'ok'}}) + inferred('ok', {values = {1}}) + "#, + &[ + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + ], + ); + } + + #[test] + fn test_cross_file_record_parameters_keep_explicit_generics() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_file( + "accept.lua", + r#" + ---@generic T + ---@param opts {values: table, [integer]: T} + function accept(opts) return opts end + "#, + ); + assert_workspace_diagnostics( + &mut ws, + "accept--[[@]]({values = {'ok'}, 'ok'})\naccept--[[@]]({values = {1}, 'ok'})\naccept--[[@]]({values = {'ok'}, 1})", + &[(ParamTypeMismatch, 1), (ParamTypeMismatch, 2)], + ); + } + + #[test] + fn test_record_unions_and_overloads_accept_a_complete_alternative() { + assert_diagnostics( + r#" + ---@alias Options {values: table}|{values: table} + ---@param opts Options + local function accept(opts) return opts end + accept({values = {'ok'}}) + accept({values = {1}}) + accept({values = {true}}) + ---@type Options + local valid = {values = {1}} + ---@type Options + local invalid = {values = {true}} + + ---@param opts {values: table} + ---@param mode string + ---@overload fun(opts: {values: table}, mode: integer) + local function overloaded(opts, mode) return opts, mode end + overloaded({values = {'ok'}}, 'text') + overloaded({values = {1}}, 1) + overloaded({values = {1}}, 'text') + return valid, invalid + "#, + &[ParamTypeMismatch, ParamTypeMismatch, AssignTypeMismatch], + ); + } + + #[test] + fn test_dictionary_record_alternatives_check_class_index_values() { + assert_diagnostics( + r#" + ---@class Entry + ---@field label string + ---@field [integer] 'ok' + local _Entry = {} + ---@param values table|Entry + local function accept(values) return values end + accept({label = 'ok', 'ok'}) + accept({label = 'ok', 1}) + accept({label = 'ok', 'bad'}) + ---@type table + local valid = {child = {label = 'ok', 'ok'}} + ---@type table + local invalid = {child = {label = 'ok', 1}} + return valid, invalid + "#, + &[ParamTypeMismatch, ParamTypeMismatch, AssignTypeMismatch], + ); + } + + #[test] + fn test_parentheses_preserve_nested_dictionary_literal_checks() { + for value in ["{'ok'}", "({'ok'})", "(({'ok'}))"] { + assert_diagnostics( + &format!( + "---@type table>\nlocal value = {{child = {value}}}\nreturn value" + ), + &[], + ); + } + for value in ["{1}", "({1})", "(({1}))"] { + assert_diagnostics( + &format!( + r#" + ---@type table> + local value = {{child = {value}}} + ---@param values table> + local function accept(values) return values end + accept({{child = {value}}}) + return value + "# + ), + &[ParamTypeMismatch, AssignTypeMismatch], + ); + } + } + + #[test] + #[ntest::timeout(10000)] + fn test_recursive_scalar_alias_rejects_table_arguments() { + for value in ["{}", "{name = 1}"] { + assert_diagnostics( + &format!( + r#" + ---@alias A A|string + ---@param value A + local function accept(value) return value end + accept({value}) + "#, + ), + &[ParamTypeMismatch], + ); + } + } + + #[test] + #[ntest::timeout(10000)] + fn test_branching_recursive_aliases_do_not_repeat_union_checks() { + assert_diagnostics( + r#" + ---@alias A B|C + ---@alias B A|string + ---@alias C A|table + ---@param value A + local function accept(value) return value end + accept({'ok'}) + accept({true}) + "#, + &[ParamTypeMismatch], + ); + } + + #[test] + #[ntest::timeout(10000)] + fn test_recursive_record_aliases_check_the_concrete_alternative() { + assert_diagnostics( + r#" + ---@class Entry + ---@field label string + ---@alias Record Record|Entry + ---@type Record + local valid = {label = 'ok'} + ---@type Record + local invalid = {label = 1} + ---@type Record + local missing = {} + return valid, invalid, missing + "#, + &[AssignTypeMismatch, DiagnosticCode::MissingFields], + ); + } + + #[test] + #[ntest::timeout(10000)] + fn test_growing_recursive_aliases_check_other_alternatives() { + assert_diagnostics( + r#" + ---@alias Values Values|table + ---@alias Key Key|integer + ---@param values Values + ---@param keyed table, string> + local function accept(values, keyed) return values, keyed end + accept({'ok'}, {[1] = 'ok'}) + accept({true}, {[1] = 'ok'}) + accept({'ok'}, {name = 'bad'}) + accept({name = 'bad'}, {[1] = 'ok'}) + "#, + &[ + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + DiagnosticCode::TypeComplexity, + DiagnosticCode::TypeComplexity, + ], + ); + } + + #[test] + fn test_record_constructors_resolve_constant_expression_keys() { + let source = r#" + local key = 'text' + ---@param opts {text: string} + local function accept(opts) return opts end + accept({[key] = 'ok'}) + pcall(accept, {[key] = 'ok'}) + ---@type {text: string} + local value = {[key] = 'ok'} + value = {[key] = 'updated'} + accept(value) + return value + "#; + assert_diagnostics(source, &[]); + assert_diagnostics( + &format!("---@diagnostic disable: missing-fields\n{source}"), + &[], + ); + assert_diagnostics( + r#" + local key = 'text' + ---@param opts {text: string} + local function accept(opts) return opts end + accept({[key] = 1}) + ---@type {text: string} + local value = {[key] = 1} + return value + "#, + &[ParamTypeMismatch, AssignTypeMismatch], + ); + } + + #[test] + fn test_record_constructors_allow_absent_nullable_alias_fields() { + assert_diagnostics( + r#" + ---@alias OptionalText string|nil + ---@param opts {text: OptionalText} + local function accept(opts) return opts end + accept({}) + pcall(accept, {}) + ---@type {text: OptionalText} + local value = {} + value = {} + accept({text = 1}) + ---@type {text: OptionalText} + local invalid = {text = 1} + ---@type {text: string} + local missing = {} + return value, invalid, missing + "#, + &[ + ParamTypeMismatch, + AssignTypeMismatch, + DiagnosticCode::MissingFields, + ], + ); + } + + #[test] + fn test_constructor_keys_use_the_first_call_result() { + assert_diagnostics( + r#" + ---@return 'text', integer + local function name() return 'text', 1 end + local function index() return 1.0, 'unused' end + ---@param opts {text: string} + local function accept(opts) return opts end + accept({[name()] = 'ok'}) + ---@type {text: string} + local record = {[name()] = 'ok'} + ---@type table + local dictionary = {[index()] = 'ok'} + ---@type table + local invalid = {[index()] = false} + return record, dictionary, invalid + "#, + &[AssignTypeMismatch], + ); + } + + #[test] + fn test_record_constructors_resolve_integral_float_keys() { + assert_diagnostics( + r#" + local key = 1.0 + ---@class NumericRecord + ---@field [1] string + ---@type NumericRecord + local named = {[key] = 'ok'} + ---@type {[1]: string} + local record = {[key] = 'ok'} + ---@type {[1]: string} + local invalid = {[key] = false} + return named, record, invalid + "#, + &[AssignTypeMismatch], + ); + } + + #[test] + fn test_required_record_fields_include_final_call_results() { + assert_diagnostics( + r#" + local function pair() return 'ok', 1 end + ---@class Pair + ---@field [1] string + ---@field [2] integer + ---@type Pair + local named = {pair()} + ---@type {[1]: string, [2]: integer} + local record = {pair()} + ---@param opts {[1]: string, [2]: integer} + local function accept(opts) return opts end + accept({pair()}) + accept({pair(), false}) + ---@type {[1]: string, [2]: integer} + local missing = {(pair())} + return named, record, missing + "#, + &[ParamTypeMismatch, DiagnosticCode::MissingFields], + ); + } + + #[test] + fn test_record_constructors_ignore_overwritten_nested_values() { + for constructor in [ + "{item = {}, [key] = {required = 'ok'}}", + "({item = {}, [key] = ({required = 'ok'})})", + ] { + assert_diagnostics( + &format!( + "local key = 'item'\n---@param opts {{item: {{required: string}}}}\nlocal function accept(opts) return opts end\naccept({constructor})\npcall(accept, {constructor})\n---@type {{item: {{required: string}}}}\nlocal value = {constructor}\nreturn value" + ), + &[], + ); + } + } + + #[test] + fn test_record_references_include_fields_added_during_construction() { + for target in ["{text: string}", "{text: 'ok'}", "Record"] { + for initial in ["{}", "{text = 'initial'}"] { + assert_diagnostics( + &format!( + "---@class Record\n---@field text string\nlocal _Record = {{}}\nlocal value = {initial}\nvalue.text = 'ok'\n---@param opts {target}\nlocal function accept(opts) return opts end\naccept(value)\npcall(accept, value)\n---@type {target}\nlocal copy = value\nreturn copy" + ), + if target == "{text: 'ok'}" { + &[ParamTypeMismatch, ParamTypeMismatch, AssignTypeMismatch] + } else { + &[] + }, + ); + } + } + } + + #[test] + fn test_optional_field_flow_does_not_make_the_record_field_required() { + assert_diagnostics( + r#" + ---@param text string + local function text(text) return text end + ---@param record {text: string} + local function record(record) return record end + + ---@return {text: string} + local function run() + ---@type {text?: string} + local value = {} + value.text = 'hello' + text(value.text) + record({text = value.text}) + pcall(record, {text = value.text}) + ---@type {text: string} + local _complete = {text = value.text} + record(value) + pcall(record, value) + ---@type {text: string} + local _copy = value + return value + end + return run + "#, + &[ + ParamTypeMismatch, + ParamTypeMismatch, + DiagnosticCode::ReturnTypeMismatch, + AssignTypeMismatch, + ], + ); + } + + #[test] + fn test_inferred_fields_use_stable_types_for_whole_table_checks() { + for target in ["{text: 'ok'}", "table"] { + assert_diagnostics( + &format!( + r#" + ---@param opts {target} + local function accept(opts) return opts end + ---@param text 'ok' + local function literal(text) return text end + accept({{text = 'ok'}}) + local value = {{text = 'ok'}} + literal(value.text) + accept({{text = value.text}}) + accept(value) + pcall(accept, value) + ---@type {target} + local before = value + value.text = 'bad' + accept(value) + pcall(accept, value) + ---@type {target} + local after = value + return before, after + "# + ), + &[ + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + AssignTypeMismatch, + AssignTypeMismatch, + ], + ); + assert_diagnostics( + &format!( + r#" + ---@param opts {target} + local function accept(opts) return opts end + ---@type {target} + local value = {{text = 'ok'}} + accept(value) + return value + "# + ), + &[], + ); + } + } + + #[test] + fn test_whole_table_assignment_updates_the_variable_flow_type() { + assert_diagnostics( + r#" + ---@param opts {text: string} + local function accept(opts) return opts end + local value = {text = 'initial'} + value = {text = 1} + accept(value) + return value + "#, + &[ParamTypeMismatch], + ); + } + + #[test] + fn test_conditional_construction_preserves_existing_contracts_and_adds_optional_fields() { + assert_diagnostics( + r#" + ---@param opts {text: string} + local function accept(opts) return opts end + ---@param condition boolean + local function run(condition) + local value = {text = 'ok'} + accept(value) + if condition then value.text = nil end + accept(value) + return value + end + return run + "#, + &[AssignTypeMismatch], + ); + assert_diagnostics( + r#" + ---@param opts {text: string} + local function accept(opts) return opts end + ---@param condition boolean + local function run(condition) + local value = {} + if condition then value.text = 'ok' end + accept(value) + return value + end + return run + "#, + &[ParamTypeMismatch], + ); + } + + #[test] + fn test_record_references_use_metatable_members() { + assert_diagnostics( + r#" + ---@param opts {text: string} + local function accept(opts) return opts end + local valid = setmetatable({}, {__index = {text = 'ok'}}) + accept(valid) + pcall(accept, valid) + ---@type {text: string} + local copy = valid + local invalid = setmetatable({}, {__index = {text = 1}}) + accept(invalid) + pcall(accept, invalid) + ---@type {text: string} + local wrong = invalid + return copy, wrong + "#, + &[ParamTypeMismatch, ParamTypeMismatch, AssignTypeMismatch], + ); + } + + #[test] + fn test_inline_metatables_supply_record_fields() { + assert_diagnostics( + r#" + ---@param opts {text: string} + local function accept(opts) return opts end + accept(setmetatable({}, {__index = {text = 'ok'}})) + pcall(accept, setmetatable({}, {__index = {text = 'ok'}})) + ---@type {text: string} + local copy = (setmetatable({}, {__index = {text = 'ok'}})) + ---@type {item: {text: string}} + local nested = {item = setmetatable({}, {__index = {text = 'ok'}})} + ---@return {text: string} + local function make() + return setmetatable({}, {__index = {text = 'ok'}}) + end + accept(setmetatable({}, {__index = {text = 1}})) + ---@type {text: string} + local wrong = setmetatable({}, {__index = {text = 1}}) + return copy, nested, make, wrong + "#, + &[ParamTypeMismatch, AssignTypeMismatch], + ); + } + + #[test] + fn test_record_constructors_check_values_before_later_writes() { + assert_diagnostics( + r#" + ---@diagnostic disable: missing-fields + ---@type {text: string} + local later = {} + later.text = 'ok' + + local key = 'text' + ---@type {text: string} + local overwritten = {text = 'ok', [key] = 1} + return later, overwritten + "#, + &[AssignTypeMismatch, AssignTypeMismatch], + ); + } + + #[test] + fn test_record_constructors_keep_absent_return_alternatives() { + assert_diagnostics( + r#" + local function pair(full) + if full then return 'ok', 1 end + return 'ok' + end + ---@param opts {[1]: string, [2]: integer?} + local function optional(opts) return opts end + ---@param opts {[1]: string, [2]: integer} + local function required(opts) return opts end + optional({pair(true)}) + required({pair(true)}) + "#, + &[ParamTypeMismatch], + ); + } + + #[test] + fn test_absent_fields_require_nullable_intersection_components() { + assert_diagnostics( + r#" + ---@alias OptionalText (string|nil)&(integer|nil) + ---@alias RequiredText (string|nil)&integer + + ---@class OptionalRecord + ---@field text OptionalText + + ---@type OptionalRecord + local named = {} + ---@type {text: OptionalText} + local record = {} + ---@param opts {text: OptionalText} + local function accept(opts) return opts end + accept({}) + ---@type {text: RequiredText} + local missing = {} + return named, record, missing + "#, + &[DiagnosticCode::MissingFields], + ); + } + + #[test] + fn test_record_fields_distinguish_optional_and_required_values() { + assert_diagnostics( + r#" + ---@param opts {label?: string, values?: table} + local function optional(opts) return opts end + ---@type table? + local values + optional({}) + optional({label = nil, values = nil}) + optional({values = values}) + optional({values = {name = 'ok'}}) + optional({values = {name = 1}}) + + ---@param opts {label: string, values: table} + local function required(opts) return opts end + required({label = 'ok', values = {}}) + required({label = nil, values = {}}) + required({label = 'ok', values = nil}) + "#, + &[ParamTypeMismatch, ParamTypeMismatch, ParamTypeMismatch], + ); + } + + #[test] + fn test_object_index_signatures_allow_nil_entries() { + assert_diagnostics( + r#" + ---@param opts {values: table, [string]: string} + local function accept(opts) return opts end + accept({values = {}, label = nil}) + "#, + &[], + ); + } + + #[test] + fn test_dictionary_records_require_declared_fields() { + for (target, valid, missing) in [ + ( + "{label: string, values: table}", + "{label = 'ok', values = {'ok'}}", + "{values = {'ok'}}", + ), + ( + "{child: {label: string}, values: table}", + "{child = {label = 'ok'}, values = {'ok'}}", + "{child = {}, values = {'ok'}}", + ), + ] { + assert_diagnostics( + &format!( + r#" + ---@param opts {target} + local function accept(opts) return opts end + accept({valid}) + accept({missing}) + "# + ), + &[DiagnosticCode::MissingFields], + ); + } + } + + #[test] + fn test_generic_dictionary_records_preserve_optional_fields() { + for alias in [ + "Options {label: string, values?: table}", + "Options T extends string and {label: string, values?: table} or never", + ] { + assert_diagnostics( + &format!( + r#" + ---@alias {alias} + ---@param opts Options + local function accept(opts) return opts end + accept({{label = 'ok'}}) + accept({{label = 'ok', values = {{name = 'ok'}}}}) + accept({{values = {{name = 'ok'}}}}) + accept({{label = 'ok', values = {{name = 1}}}}) + "# + ), + &[DiagnosticCode::MissingFields, ParamTypeMismatch], + ); + } + } + + #[test] + fn test_dictionary_union_checks_named_records() { + assert_diagnostics( + r#" + ---@class Record + ---@field name string + local _Record = {} + + ---@param values table|Record + local function accept(values) return values end + accept({'a'}) + accept({name = 'a'}) + accept({name = 1}) + "#, + &[ParamTypeMismatch], + ); + } + + #[test] + fn test_dictionary_checks_each_variadic_argument() { + assert_diagnostics( + r#" + ---@param ... table + local function accept(...) return ... end + accept({'a'}, {'b'}) + accept({'a'}, {name = 'b'}) + "#, + &[ParamTypeMismatch], + ); + } + + #[test] + #[ntest::timeout(10000)] + fn test_recursive_dictionary_keeps_checking_other_fields() { + assert_diagnostics( + r#" + ---@alias Tree table + local root = {} + root.child = root + ---@param values Tree + local function accept(values) return values end + accept({child = root, label = 'ok'}) + accept({child = root, label = 1}) + accept({child = {label = 'ok'}}) + "#, + &[ParamTypeMismatch], + ); + } + + #[test] + fn test_dictionary_values_can_call_generic_functions() { + assert_diagnostics( + r#" + ---@generic T + ---@param _seed T + ---@param values table + ---@return table + local function identity(_seed, values) return values end + ---@type table> + local valid = {child = identity('ok', {label = 'ok'})} + ---@type table> + local invalid = {child = identity(1, {label = 1})} + return valid, invalid + "#, + &[AssignTypeMismatch], + ); + } + + #[test] + fn test_dictionary_calls_use_explicit_and_inferred_generics() { + assert_diagnostics( + r#" + ---@generic T + ---@param values table + local function explicit(values) return values end + explicit--[[@]]({1}) + explicit--[[@]]({'a'}) + + ---@generic T + ---@param seed T + ---@param values table + local function inferred(seed, values) return seed, values end + inferred(1, {2}) + inferred(1, {'a'}) + inferred--[[@]](1, {2}) + inferred--[[@]](1, {'a'}) + + ---@generic T + ---@param value T + ---@param other T + local function same(value, other) return value, other end + same({}, {}) + same({}, 1) + "#, + &[ + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + ParamTypeMismatch, + ], + ); + } + + #[test] + fn test_generic_class_arguments_preserve_field_constraints() { + for (target, valid, invalid) in [ + ("Box", "{value = 1}", "{value = 1, extra = true}"), + ("Box|nil", "{value = 1}", "{value = 1, extra = true}"), + ("Dict", "{1}", "{name = 1}"), + ("Dict|nil", "{1}", "{name = 1}"), + ] { + assert_diagnostics( + &format!( + r#" + ---@class Box + ---@field value T + local _Box = {{}} + ---@class Dict + ---@field [integer] T + local _Dict = {{}} + + ---@generic T + ---@param values {target} + local function accept(values) return values end + accept({valid}) + accept({invalid}) + pcall(accept, {valid}) + pcall(accept, {invalid}) + "# + ), + &[DiagnosticCode::InjectField, DiagnosticCode::InjectField], + ); + } + } + + #[test] + fn test_generic_table_overload_requires_the_whole_call_to_match() { + assert_diagnostics( + r#" + ---@param values table + ---@param mode string + ---@overload fun(values: table, mode: integer) + local function accept(values, mode) return values, mode end + accept({'a'}, 'a') + accept({count = 1}, 1) + accept({count = 1}, 'a') + "#, + &[ParamTypeMismatch], + ); + } + + #[test] + fn test_generic_table_method_arguments_use_the_declared_parameter() { + assert_diagnostics( + r#" + local receiver = {} + ---@param values table + function receiver:accept(values) return self, values end + receiver:accept({'a'}) + receiver.accept(receiver, {'a'}) + receiver.accept(receiver, {name = 'a'}) + receiver:accept({1}) + "#, + &[ParamTypeMismatch, ParamTypeMismatch], + ); + } + + #[test] + fn test_generic_aliases_preserve_table_constraints() { + for (alias, target, valid, wrong_value, wrong_key) in [ + ( + "Values table", + "Values", + "{'ok'}", + "{true}", + "{name = 'bad'}", + ), + ( + "Values table", + "table>", + "{items = {'a'}}", + "{items = {1}}", + "{items = {name = 'a'}}", + ), + ( + "Identity T", + "Identity>>", + "{'ok'}", + "{true}", + "{name = 'bad'}", + ), + ( + "Values T extends string and table or table", + "Values", + "{'ok'}", + "{true}", + "{name = 'bad'}", + ), + ( + "Options {[K in 'values']: table}", + "Options", + "{values = {'ok'}}", + "{values = {true}}", + "{values = {name = 'bad'}}", + ), + ( + "Options {[K in 'values']?: table}", + "Options", + "{}", + "{values = {true}}", + "{values = {name = 'bad'}}", + ), + ] { + assert_diagnostics( + &format!( + r#" + ---@alias {alias} + ---@type {target} + local _valid = {valid} + ---@type {target} + local _wrong_value = {wrong_value} + ---@type {target} + local _wrong_key = {wrong_key} + ---@param values {target} + local function accept(values) return values end + accept({valid}) + accept({wrong_value}) + accept({wrong_key}) + "# + ), + &[ + ParamTypeMismatch, + ParamTypeMismatch, + AssignTypeMismatch, + AssignTypeMismatch, + ], + ); + } + } + + #[test] + fn test_table_alternatives_must_accept_the_whole_literal() { + for target in [ + "Values", + "table|table", + "table|table", + "table|integer[]", + "integer[]|table", + ] { + assert_diagnostics( + &format!( + r#" + ---@alias Values table|table + ---@type {target} + local strings = {{'a'}} + ---@type {target} + local numbers = {{1}} + ---@type {target} + local mixed = {{'a', 1}} + ---@param values {target} + local function accept(values) return values end + accept({{'a'}}) + accept({{1}}) + accept({{'a', 1}}) + return strings, numbers, mixed + "# + ), + &[ParamTypeMismatch, AssignTypeMismatch], + ); + } + assert_diagnostics( + r#" + ---@param values table + ---@overload fun(values: table) + local function overloaded(values) return values end + overloaded({'a'}) + overloaded({1, 'a'}) + ---@param values table|table> + local function nested(values) return values end + nested({{'a'}}) + nested({{1, 'a'}}) + "#, + &[ParamTypeMismatch, ParamTypeMismatch], + ); + } + + #[test] + fn test_dictionary_constructors_check_keys_and_values() { + for (target, valid, invalid) in [ + ("table", "{}", "{name = 'a'}"), + ("table", "{'a'}", "{1}"), + ("table", "{nil}", "{[1.5] = 'a'}"), + ( + "table", + "{[1.0] = 'a', [-1.0] = 'b', [(-2e0)] = 'c'}", + "{[1.5] = 'a'}", + ), + ("table", "{[-1.0] = 'a'}", "{[-1.5] = 'a'}"), + ("table", "{[1.5] = 'a'}", "{name = 'a'}"), + ( + "table<'allowed', string>", + "{allowed = 'a'}", + "{other = 'a'}", + ), + ("table<1, string>", "{[1.0] = 'a'}", "{[2] = 'a'}"), + ( + "table<'left'|'right', string>", + "{right = 'a'}", + "{other = 'a'}", + ), + ( + "table>", + "{{'a'}}", + "{{name = 'a'}}", + ), + ] { + assert_diagnostics( + &format!( + r#" + ---@param values {target} + local function accept(values) return values end + accept({valid}) + accept({invalid}) + ---@type {target} + local valid = {valid} + ---@type {target} + local invalid = {invalid} + return valid, invalid + "# + ), + &[ParamTypeMismatch, AssignTypeMismatch], + ); + } + } + + #[test] + fn test_dictionary_literal_keys_require_known_values() { + assert_diagnostics( + r#" + ---@param name string + ---@param index integer + ---@param known 'left'|'right' + local function build(name, index, known) + ---@type table<'left'|'right', string> + local valid = {[known] = 'ok'} + ---@type table<'left'|'right', string> + local invalid_name = {[name] = 'bad'} + ---@type table<1, string> + local invalid_index = {[index] = 'bad'} + return valid, invalid_name, invalid_index + end + return build + "#, + &[AssignTypeMismatch, AssignTypeMismatch], + ); + } + + #[test] + fn test_every_member_of_a_value_union_must_be_assignable() { + assert_diagnostics( + r#" + ---@alias Label 'left'|'right' + ---@param label Label + ---@param mixed integer|string + local function accept(label, mixed) + ---@type table + local valid = {label} + ---@type table + local invalid = {mixed} + return valid, invalid + end + accept('left', 1) + "#, + &[AssignTypeMismatch], + ); + } + + #[test] + #[ntest::timeout(10000)] + fn test_recursive_literals_check_deep_leaves() { + for (depth, leaf, expected) in [ + (20, "{label = 'ok'}", &[][..]), + (20, "{label = true}", &[ParamTypeMismatch][..]), + (120, "{label = 'ok'}", &[][..]), + (120, "{label = true}", &[ParamTypeMismatch][..]), + ] { + let nested = format!("{}{}{}", "{child = ".repeat(depth), leaf, "}".repeat(depth)); + assert_diagnostics( + &format!( + r#" + ---@alias Tree table> + ---@param values Tree + local function accept(values) return values end + accept({nested}) + "# + ), + expected, + ); + } + } + + #[test] + #[ntest::timeout(10000)] + fn test_cyclic_modules_do_not_reopen_tables_or_hide_other_errors() { + for (alias, target) in [ + ("Tree table", "Tree"), + ("Tree table>", "Tree"), + ] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_files(vec![ + ("a.lua", "return {child = require('b')}"), + ("b.lua", "return {child = require('a')}"), + ("valid.lua", "return {label = 'ok'}"), + ("invalid.lua", "return false"), + ]); + assert_workspace_diagnostics( + &mut ws, + &format!( + r#" + ---@alias {alias} + ---@param values {target} + local function accept(values) return values end + local child = require('a') + accept({{child = child, label = 'ok'}}) + accept({{child = child, label = 1}}) + accept({{child = child, label = 'ok'}}) + accept({{child = require('valid')}}) + accept({{child = require('invalid')}}) + "# + ), + &[(ParamTypeMismatch, 6), (ParamTypeMismatch, 9)], + ); + } + } + + #[test] + fn test_standard_table_declaration_keeps_dictionary_permissions() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + assert_workspace_diagnostics( + &mut ws, + r#" + ---@param values table + local function any_keys(values) return values end + any_keys({'reset', 'file.txt'}) + any_keys({command = 'reset', [true] = 1}) + ---@param values table + local function integer_keys(values) return values end + integer_keys({'ok'}) + integer_keys({name = 'bad'}) + integer_keys({1}) + "#, + &[(ParamTypeMismatch, 8), (ParamTypeMismatch, 9)], + ); + } + } + #[test] fn test_table_literal_integral_float_keys() { let diagnostics = check_source( @@ -19,6 +1708,9 @@ mod test { ---@field [9223372036854774784] integer ---@type LargeNumeric local large = {[9223372036854774784.0] = 1} + ---@param value Numeric + local function accept(value) return value end + accept({[0.0] = 0, [1e0] = 1}) return decimal, exponent, large "#, ); @@ -34,12 +1726,18 @@ mod test { ---@field [{key}]? integer ---@type Numeric local value = {{[{key}.0] = "bad"}} + ---@param record Numeric + local function accept(record) return record end + accept({{[{key}.0] = "bad"}}) return value "#, )); assert_eq!( diagnostics.iter().map(|d| d.code).collect::>(), - vec![DiagnosticCode::AssignTypeMismatch], + vec![ + DiagnosticCode::ParamTypeMismatch, + DiagnosticCode::AssignTypeMismatch + ], "{key}: {diagnostics:?}" ); } @@ -505,6 +2203,35 @@ mod dictionary_constructor { use crate::{DiagnosticCode, VirtualWorkspace}; use googletest::prelude::*; + #[test] + fn dictionary_constructor_rejects_wrong_entries_with_standard_library() { + for value in ["{name = 'a'}", "{1}"] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let source = format!( + r#" + ---@type table + local result = {value} + ---@param values table + local function accept(values) return values end + accept({value}) + return result + "# + ); + assert!( + !ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, &source), + "{value}" + ); + assert!( + !ws.has_no_diagnostic(DiagnosticCode::ParamTypeMismatch, &source), + "{value}" + ); + assert!( + ws.has_no_diagnostic(DiagnosticCode::InjectField, &source), + "{value}" + ); + } + } + #[googletest::gtest] fn dictionary_constructor_accepts_keys_with_standard_library() { for declaration in ["local signals", "signals"] { diff --git a/crates/emmylua_code_analysis/src/check/test/misc_checker_test.rs b/crates/emmylua_code_analysis/src/check/test/misc_checker_test.rs index ec0502103..676e432da 100644 --- a/crates/emmylua_code_analysis/src/check/test/misc_checker_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/misc_checker_test.rs @@ -324,13 +324,13 @@ fn test_defined_field_ok() { ); } -/// Anonymous table fields → not reported (lenient). +/// Inferred tables report missing fields. #[test] -fn test_undefined_field_table_skipped() { +fn test_undefined_field_inferred_table() { let diagnostics = check_source("local t = { a = 1 }\nlocal v = t.missing"); assert_eq!( count_by_code(&diagnostics, DiagnosticCode::UndefinedField), - 0 + 1 ); } diff --git a/crates/emmylua_code_analysis/src/check/test/param_type_check_test.rs b/crates/emmylua_code_analysis/src/check/test/param_type_check_test.rs index 97375a75a..0015c7b71 100644 --- a/crates/emmylua_code_analysis/src/check/test/param_type_check_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/param_type_check_test.rs @@ -8,6 +8,79 @@ mod test { use crate::check::test::check_source; use crate::{DiagnosticCode, VirtualWorkspace}; + #[test] + fn test_pcall_preserves_the_declared_callback_type() { + for callback_type in ["fun(x: string): integer", "Callback"] { + let source = format!( + "---@alias Callback fun(x: string): integer\n---@type {callback_type}\n---@return integer\nlocal callback = function(x) return #x end\ncallback('ok')\npcall(callback, 'ok')\ncallback(1)\npcall(callback, 1)" + ); + let diagnostics = check_source(&source); + assert_eq!(diagnostics.len(), 2, "{source}\n{diagnostics:#?}"); + for diagnostic in diagnostics { + assert_eq!(diagnostic.code, DiagnosticCode::ParamTypeMismatch); + assert_eq!(&source[diagnostic.range], "1"); + } + } + } + + #[test] + fn test_table_overloads_use_the_same_candidates_through_pcall() { + for (valid, invalid) in [ + ("accept({count = 1}, true)", "accept({count = 1})"), + ( + "pcall(accept, {count = 1}, true)", + "pcall(accept, {count = 1})", + ), + ] { + let source = format!( + r#" + ---@param entries table + ---@overload fun(entries: table, required: boolean) + local function accept(entries) return entries end + {valid} + {invalid} + "# + ); + let diagnostics = check_source(&source); + assert_eq!(diagnostics.len(), 1, "{source}\n{diagnostics:#?}"); + assert_eq!(diagnostics[0].code, DiagnosticCode::ParamTypeMismatch); + assert_eq!( + usize::from(diagnostics[0].range.start()), + source.rfind("{count = 1}").unwrap(), + "{source}\n{diagnostics:#?}" + ); + } + } + + #[test] + fn test_table_constructor_record_literals_use_the_same_rules() { + for (declaration, valid, invalid) in [ + ( + "---@param value {mode: 'read'}", + "{mode = 'read'}", + "{mode = 'write'}", + ), + ( + "---@class Options\n---@field mode 'read'\n\n---@param value Options", + "{mode = 'read'}", + "{mode = 'write'}", + ), + ( + "---@alias Options {mode: 'read'}\n\n---@param value table", + "{{mode = 'read'}}", + "{{mode = 'write'}}", + ), + ] { + let source = format!( + "{declaration}\nlocal function accept(value) return value end\naccept({valid})\naccept({invalid})" + ); + let diagnostics = check_source(&source); + assert_eq!(diagnostics.len(), 1, "{source}\n{diagnostics:#?}"); + assert_eq!(diagnostics[0].code, DiagnosticCode::ParamTypeMismatch); + assert_eq!(&source[diagnostics[0].range], invalid); + } + } + #[test] fn test_function_argument_accepts_any_parameter() { let mut ws = VirtualWorkspace::new_with_init_std_lib(); diff --git a/crates/emmylua_code_analysis/src/check/test/return_type_mismatch_test.rs b/crates/emmylua_code_analysis/src/check/test/return_type_mismatch_test.rs index 9294c5ed3..00135c68b 100644 --- a/crates/emmylua_code_analysis/src/check/test/return_type_mismatch_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/return_type_mismatch_test.rs @@ -3,6 +3,45 @@ mod tests { use crate::check::test::check_source; use crate::{DiagnosticCode, VirtualWorkspace}; + #[test] + fn test_field_flow_does_not_change_the_returned_table_type() { + let diagnostics = check_source( + r#" + ---@return {text: string} + local function record() + local value = {text = 'ok'} + value.text = 'updated' + return value + end + return record + "#, + ); + assert!(diagnostics.is_empty(), "{diagnostics:#?}"); + } + + #[test] + fn test_returned_tables_use_stable_inferred_field_types() { + let diagnostics = check_source( + r#" + ---@return {text: 'ok'} + local function fresh() + return {text = 'ok'} + end + ---@return {text: 'ok'} + local function inferred() + local value = {text = 'ok'} + return value + end + return fresh, inferred + "#, + ); + assert_eq!( + diagnostics.iter().map(|d| d.code).collect::>(), + [DiagnosticCode::ReturnTypeMismatch], + "{diagnostics:#?}" + ); + } + #[test] fn test_parenthesized_member_guards_narrow_return_value() { for (guard, access) in [ diff --git a/crates/emmylua_code_analysis/src/semantic_db/facade.rs b/crates/emmylua_code_analysis/src/semantic_db/facade.rs index 4a05422a9..23f6b6238 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/facade.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/facade.rs @@ -576,6 +576,22 @@ impl<'db> AnalysisView<'db> { self.project_shell(file_id, shell) } + /// Alias-aware outer alternatives, without semantic expression inference. + pub(crate) fn type_shell_heads( + &self, + file_id: FileId, + shell: &TypeShell, + ) -> Vec { + query::type_shell_heads(self.db, file_id, shell) + } + + pub(crate) fn type_shell_return_heads( + &self, + head: &query::TypeShellHead, + ) -> Vec { + query::type_shell_return_heads(self.db, head) + } + /// Projection (with function generic context: `Generic("T")` → `TplRef`). pub fn type_shell_lua_in( &self, diff --git a/crates/emmylua_code_analysis/src/semantic_db/facts.rs b/crates/emmylua_code_analysis/src/semantic_db/facts.rs index 95a983a5e..752b70b07 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/facts.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/facts.rs @@ -113,7 +113,7 @@ pub struct FileFacts { member_by_value_syntax: HashMap, type_def_by_id: HashMap, members_by_owner: HashMap>, - members_by_owner_name: HashMap<(SemanticId, SmolStr), Vec>, + members_by_owner_key: HashMap<(SemanticId, LuaMemberKey), Vec>, members_by_name: HashMap>, type_def_by_full_name: HashMap, type_def_by_name: HashMap, @@ -235,8 +235,16 @@ impl FileFacts { owner: &SemanticId, name: &str, ) -> impl Iterator { - self.members_by_owner_name - .get(&(owner.clone(), SmolStr::new(name))) + self.members_of_owner_with_key(owner, &LuaMemberKey::Name(name.into())) + } + + pub fn members_of_owner_with_key<'a>( + &'a self, + owner: &SemanticId, + key: &LuaMemberKey, + ) -> impl Iterator + use<'a> { + self.members_by_owner_key + .get(&(owner.clone(), key.clone())) .into_iter() .flat_map(|indices| indices.iter()) .filter_map(move |&index| self.members.get(index)) @@ -855,7 +863,7 @@ impl FactsBuilder { // Post-processing 3: module export from top-level return (needs decls fully collected). self.collect_module_export(chunk); - // Post-processing 4: index `setmetatable(table, meta)` initializer calls once. + // Post-processing 4: index `setmetatable(table, meta)` calls once. self.collect_setmetatable_calls(chunk); let mut name_use_by_start = self @@ -935,22 +943,23 @@ impl FactsBuilder { let mut members_by_owner: HashMap> = HashMap::new(); let mut members_by_name: HashMap> = HashMap::new(); let mut field_member_by_type_name: HashMap<(SemanticId, SmolStr), usize> = HashMap::new(); - let mut members_by_owner_name: HashMap<(SemanticId, SmolStr), Vec> = HashMap::new(); + let mut members_by_owner_key: HashMap<(SemanticId, LuaMemberKey), Vec> = + HashMap::new(); for (index, member) in self.members.iter().enumerate() { members_by_owner .entry(member.owner.clone()) .or_default() .push(index); + members_by_owner_key + .entry((member.owner.clone(), member.key.clone())) + .or_default() + .push(index); if let Some(name) = member.key.name() { let name_smol = SmolStr::new(name); members_by_name .entry(name_smol.clone()) .or_default() .push(index); - members_by_owner_name - .entry((member.owner.clone(), name_smol.clone())) - .or_default() - .push(index); field_member_by_type_name .entry((member.owner.clone(), name_smol)) .or_insert(index); @@ -1092,7 +1101,7 @@ impl FactsBuilder { member_by_value_syntax, type_def_by_id, members_by_owner, - members_by_owner_name, + members_by_owner_key, members_by_name, type_def_by_full_name, type_def_by_name, @@ -2284,25 +2293,12 @@ impl FactsBuilder { // assigning by member id's key_range here is not feasible, so it is set at the collection point). } - /// Top-level `return X` → module export target (the last top-level return wins). - /// Indexes `setmetatable(table, meta)` calls that initialize a declaration or member. + /// Indexes `setmetatable(table, meta)` calls, including arguments and returns. /// /// Table member lookup otherwise has to scan every declaration to find the /// matching `setmetatable` call for a table literal. fn collect_setmetatable_calls(&mut self, chunk: &LuaChunk) { - let root = chunk.get_root(); - let values = self - .decls - .iter() - .filter_map(|decl| decl.value_expr_syntax) - .chain(self.members.iter().filter_map(|member| member.value_syntax)); - for value_syntax in values { - let Some(node) = value_syntax.to_node_from_root(&root) else { - continue; - }; - let Some(call) = LuaCallExpr::cast(node) else { - continue; - }; + for call in chunk.descendants::() { let is_setmetatable = match call.get_prefix_expr() { Some(LuaExpr::NameExpr(name_expr)) => { name_expr.get_name_text().as_deref() == Some("setmetatable") @@ -2445,6 +2441,11 @@ fn member_key_from_index_key(key: LuaIndexKey) -> Option { } LuaIndexKey::Integer(num) => match num.get_number_value() { NumberResult::Int(i) => Some(LuaMemberKey::Integer(i)), + NumberResult::Float(i) + if i.fract() == 0.0 && (i64::MIN as f64..i64::MAX as f64).contains(&i) => + { + Some(LuaMemberKey::Integer(i as i64)) + } _ => None, }, LuaIndexKey::Idx(idx) => Some(LuaMemberKey::Integer(idx as i64)), diff --git a/crates/emmylua_code_analysis/src/semantic_db/flow/engine.rs b/crates/emmylua_code_analysis/src/semantic_db/flow/engine.rs index 60f73da58..bae3ae4dc 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/flow/engine.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/flow/engine.rs @@ -11,7 +11,7 @@ use emmylua_parser::{ BinaryOperator, LuaAssignStat, LuaAst, LuaAstNode, LuaBlock, LuaCallExprStat, LuaElseIfClauseStat, LuaExpr, LuaForRangeStat, LuaForStat, LuaFuncStat, LuaIfStat, - LuaIndexExpr, LuaLocalStat, LuaRepeatStat, LuaVarExpr, LuaWhileStat, UnaryOperator, + LuaIndexExpr, LuaLocalStat, LuaRepeatStat, LuaWhileStat, UnaryOperator, }; use super::comment::bind_comment; @@ -1017,6 +1017,7 @@ impl<'a, 'b> BindEngine<'a, 'b> { key: key.clone(), member: member.clone(), value_syntax: value.get_syntax_id(), + result_slot: (i + 1).saturating_sub(values.len()), }, ); } @@ -1054,10 +1055,27 @@ impl<'a, 'b> BindEngine<'a, 'b> { } }, Continuation::FuncDone { func_stat, current } => match func_stat.get_func_name() { - Some(LuaVarExpr::NameExpr(_)) => { + Some(var) => { let func_kind = FlowNodeKind::ImplFunc(func_stat.to_ptr()); let flow_id = self.binder.create_node(func_kind); self.binder.add_antecedent(flow_id, current); + if let Some(closure) = func_stat.get_closure() { + for (owner, key, member) in get_var_member_ids(self.binder, &[var]) + .into_iter() + .flatten() + { + self.binder.add_effect( + flow_id, + FlowEffect::AssignMember { + owner, + key, + member, + value_syntax: closure.get_syntax_id(), + result_slot: 0, + }, + ); + } + } Step::Done(flow_id) } _ => Step::Done(current), diff --git a/crates/emmylua_code_analysis/src/semantic_db/flow/flow_node.rs b/crates/emmylua_code_analysis/src/semantic_db/flow/flow_node.rs index 474ca8dbc..f497080fd 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/flow/flow_node.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/flow/flow_node.rs @@ -22,6 +22,7 @@ pub enum FlowEffect { key: LuaMemberKey, member: SemanticId, value_syntax: emmylua_parser::LuaSyntaxId, + result_slot: usize, }, /// Condition guard: the true/false branch for cond. Guard { diff --git a/crates/emmylua_code_analysis/src/semantic_db/flow/flow_tree.rs b/crates/emmylua_code_analysis/src/semantic_db/flow/flow_tree.rs index 932d14aab..a9c47866c 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/flow/flow_tree.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/flow/flow_tree.rs @@ -92,6 +92,16 @@ impl FlowTree { None => unpositioned_branch = true, } } + if let Some(position) = flow_node_position(&node.kind) { + for effect in node_effects.get(&flow_id).into_iter().flatten() { + if let FlowEffect::AssignMember { member, .. } = effect { + member_assignments + .entry(member.clone()) + .or_default() + .push((position, flow_id)); + } + } + } match &node.kind { FlowNodeKind::Assignment(assign_ptr) => { let range = assign_ptr.get_syntax_id().get_range(); @@ -102,20 +112,11 @@ impl FlowTree { .map(|v| v.as_slice()) .unwrap_or(&[]) { - match effect { - FlowEffect::AssignDecl { decl, .. } => { - decl_assignments - .entry(decl.clone()) - .or_default() - .push((assign_pos, flow_id)); - } - FlowEffect::AssignMember { member, .. } => { - member_assignments - .entry(member.clone()) - .or_default() - .push((assign_pos, flow_id)); - } - _ => {} + if let FlowEffect::AssignDecl { decl, .. } = effect { + decl_assignments + .entry(decl.clone()) + .or_default() + .push((assign_pos, flow_id)); } } } diff --git a/crates/emmylua_code_analysis/src/semantic_db/phase_tests/p0_tests.rs b/crates/emmylua_code_analysis/src/semantic_db/phase_tests/p0_tests.rs index c272a3e16..a3ee5d245 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/phase_tests/p0_tests.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/phase_tests/p0_tests.rs @@ -219,12 +219,10 @@ fn p0_deep_require_member_chain() { assert!(is_integer_like(&ty), "deep require member type: {ty:?}"); } -/// Correct behavior for a module table mutated from another file. -/// -/// `extra.lua` adds a member to the table returned by `mod.lua`; a consumer must see -/// that member through the module export identity, not through `extra.lua`'s local alias. +/// Returning a module seals its inferred fields. Another file's alias cannot +/// extend that stable shape. #[test] -fn p0_cross_file_module_mutation_visible_through_require() { +fn p0_cross_file_module_writes_do_not_extend_sealed_exports() { let mut ws = VirtualWorkspace::new(); ws.def_file( "mod.lua", @@ -245,8 +243,8 @@ fn p0_cross_file_module_mutation_visible_through_require() { let ty = ws.expr_ty("require('mod').extra()"); assert!( - is_integer_like(&ty), - "cross-file module mutation return type: {ty:?}" + ty.is_unknown(), + "a cross-file write must not add an inferred export: {ty:?}" ); } diff --git a/crates/emmylua_code_analysis/src/semantic_db/phase_tests/p5_tests.rs b/crates/emmylua_code_analysis/src/semantic_db/phase_tests/p5_tests.rs index 5bbb91955..19acca5f7 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/phase_tests/p5_tests.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/phase_tests/p5_tests.rs @@ -69,7 +69,7 @@ fn p5_require_alias_chain_attaches_mutation_to_module_owner() { return M "#, ); - ws.def_file( + let extra_file = ws.def_file( "extra.lua", r#" local M = require("mod") @@ -80,8 +80,19 @@ fn p5_require_alias_chain_attaches_mutation_to_module_owner() { "#, ); + let model = ws.analysis.semantic_model(extra_file); + let mod_file = model.module_file_of("mod").expect("mod module file"); + let exports = ws.analysis.db.file_exports_of(extra_file); + let extra = exports + .members + .iter() + .find(|member| member.key.name() == Some("extra")) + .expect("extra contribution"); + assert_eq!(extra.owner_id, OwnerId::Module(mod_file)); + let ty = ws.expr_ty("require('mod').extra()"); - assert!(integer_like(&ty), "alias chain return type: {ty:?}"); + // Owner indexing retains the contribution, but it cannot extend a sealed export. + assert!(ty.is_unknown(), "sealed module export: {ty:?}"); } #[test] @@ -130,7 +141,10 @@ fn p5_require_alias_contribution_survives_batch_rebuild() { assert_eq!(extra.owner_id, OwnerId::Module(mod_file)); let ty = ws.expr_ty("require('mod').extra()"); - assert!(integer_like(&ty), "batch rebuild alias chain: {ty:?}"); + assert!( + ty.is_unknown(), + "sealed module export after rebuild: {ty:?}" + ); } fn local_type(ws: &VirtualWorkspace, file_id: FileId, name: &str) -> LuaType { diff --git a/crates/emmylua_code_analysis/src/semantic_db/query.rs b/crates/emmylua_code_analysis/src/semantic_db/query.rs index 6cfa5445c..355eea719 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/query.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/query.rs @@ -3492,6 +3492,126 @@ fn member_type_via_owner_method( None } +/// One outer alternative and the scope needed to inspect its result shell. +#[derive(Clone)] +pub(crate) struct TypeShellHead { + pub candidate: TypeCandidate, + context: ShellContext, +} + +#[derive(Clone)] +struct ShellContext { + file: FileId, + arguments: Arc)>>, +} + +/// Inspect outer alternatives without semantic expression or function-body inference. +pub(crate) fn type_shell_heads( + db: &SemanticDatabase, + file: FileId, + shell: &TypeShell, +) -> Vec { + shell_heads_in( + db, + &ShellContext { + file, + arguments: Arc::default(), + }, + shell, + &mut Vec::new(), + ) +} + +/// Inspect a callable's first result in its definition and argument scopes. +pub(crate) fn type_shell_return_heads( + db: &SemanticDatabase, + head: &TypeShellHead, +) -> Vec { + let TypeCandidate::Function(function) = &head.candidate else { + return Vec::new(); + }; + let mut context = head.context.clone(); + // A function's own generic parameters shadow alias parameters with the same name. + context.arguments = Arc::new( + context + .arguments + .iter() + .filter(|(name, _)| !function.generic_params.contains(name)) + .cloned() + .collect(), + ); + let result = function.returns_multi.first().unwrap_or(&function.returns); + shell_heads_in(db, &context, result, &mut Vec::new()) +} + +fn shell_heads_in( + db: &SemanticDatabase, + context: &ShellContext, + shell: &TypeShell, + path: &mut Vec, +) -> Vec { + let shell = shell.first_result(); + let mut out = Vec::new(); + if shell.candidates.is_empty() { + out.push(TypeShellHead { + candidate: TypeCandidate::Recursive, + context: context.clone(), + }); + } + for candidate in shell.candidates { + if let TypeCandidate::Generic(name) = &candidate + && let Some((_, heads)) = context.arguments.iter().find(|(param, _)| param == name) + { + out.extend(heads.iter().cloned()); + continue; + } + let (name, args) = match &candidate { + TypeCandidate::Named(name) => (name, &[][..]), + TypeCandidate::GenericInstance(instance) => (&instance.name, instance.args.as_slice()), + _ => { + out.push(TypeShellHead { + candidate, + context: context.clone(), + }); + continue; + } + }; + let Some((def, alias)) = resolve_type_def(db, context.file, name.clone()) + .and_then(|def| def.alias_type.map(|alias| (def, alias))) + else { + out.push(TypeShellHead { + candidate, + context: context.clone(), + }); + continue; + }; + if path.contains(&def.id) { + out.push(TypeShellHead { + candidate: TypeCandidate::Recursive, + context: context.clone(), + }); + continue; + } + // Resolve arguments in the caller before entering the alias. Retaining + // their contexts avoids rebinding file-local names in the alias's file. + let arguments = def + .generic_params + .iter() + .zip(args) + .map(|(param, arg)| (param.name.clone(), shell_heads_in(db, context, arg, path))) + .collect(); + let target_context = ShellContext { + file: def.file_id, + arguments: Arc::new(arguments), + }; + path.push(def.id.clone()); + let target = lower_doc_type(db, def.file_id, alias, &def.generic_params); + out.extend(shell_heads_in(db, &target_context, &target, path)); + path.pop(); + } + out +} + /// Generic substitution: replace `Generic(param)` candidates in a shell with argument types (recursing into function types). fn substitute_generics( shell: &TypeShell, diff --git a/crates/emmylua_code_analysis/src/semantic_model/access.rs b/crates/emmylua_code_analysis/src/semantic_model/access.rs index 3c4683eb4..0a63908bf 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/access.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/access.rs @@ -598,13 +598,27 @@ impl<'db> SemanticModel<'db> { per_decl.into_iter().flatten().collect() } - /// Public unified candidates for call rendering (signature help / completion / hover). + /// All callable candidates for call checks and rendering, before argument matching. /// /// Combines the diagnostic candidate path (member `CallableCandidateSet`, /// cross-file globals) with the full signature projection (main + `---@overload`) /// so all rendering paths share one candidate list. pub fn callable_candidates_for_expr(&self, callee: &LuaExpr) -> Vec { - let mut out = callable_candidates_uncached(self, callee); + // An explicit callable contract takes priority over implementation docs. + if let LuaExpr::NameExpr(name_expr) = callee + && let Some(SemanticId::Decl(key)) = self.resolve_name(name_expr.get_position()) + && let Some(syntax) = self + .file_facts_of(key.file_id) + .and_then(|facts| facts.decl_by_id(&SemanticId::Decl(key.clone()))) + .and_then(|decl| decl.doc_type_syntax) + { + let declared = self.doc_type_lua_rich_in(key.file_id, syntax); + let candidates = infer::vm::expand_callable_types_in_model(self, &declared); + if !candidates.is_empty() { + return candidates; + } + } + let mut out = self.callable_candidates_cached(callee); // The diagnostic path may only project the callee value type; append the // full signature candidates (main + `---@overload`) for name callees. if let LuaExpr::NameExpr(name_expr) = callee @@ -665,7 +679,7 @@ impl<'db> SemanticModel<'db> { explicit_generics: Vec::new(), }; }; - let candidates = self.callable_candidates_cached(&callee); + let candidates = self.callable_candidates_for_expr(&callee); if candidates.is_empty() { return CallSiteAnalysis { candidates, diff --git a/crates/emmylua_code_analysis/src/semantic_model/cache.rs b/crates/emmylua_code_analysis/src/semantic_model/cache.rs index 4494db54b..4310ec5cc 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/cache.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/cache.rs @@ -21,6 +21,25 @@ use super::infer::unify::TplBindings; use super::member::MemberInfo; use super::{CallSiteAnalysis, ResolvedMember}; +impl super::SemanticModel<'_> { + /// An unfinished semantic dependency cannot prove either outcome. + pub(crate) fn require_complete( + &self, + query: impl FnOnce() -> T, + ) -> Result { + let revision = self.cache.borrow().revision(); + let result = query(); + let cache = self.cache.borrow(); + if cache.evaluation_exhausted { + Err(super::type_check::TypeCheckFailReason::TooComplex) + } else if cache.is_complete(revision) { + Ok(result) + } else { + Err(super::type_check::TypeCheckFailReason::Unresolved) + } + } +} + /// Keep provisional memo entries while dependent queries still need them. /// Discard them at the outer query boundary so the next query can retry. #[must_use = "keep the scope alive until the query finishes"] @@ -225,6 +244,10 @@ pub(crate) struct SemanticLocalCache { pub(crate) call_site_signatures: HashMap<(FileId, LuaSyntaxId), Vec<(LuaFunctionType, TplBindings)>>, pub(crate) member_infos: HashMap<(FileId, LuaType), Vec>, + pub(crate) table_construction: HashMap< + crate::InFiled, + Option>, + >, pub(crate) members_of_owner: HashMap, pub(crate) members_of_owner_named: HashMap<(SemanticId, SmolStr), MemberList>, pub(crate) member_info: HashMap<(FileId, LuaType, LuaMemberKey), Option>, diff --git a/crates/emmylua_code_analysis/src/semantic_model/doc_types.rs b/crates/emmylua_code_analysis/src/semantic_model/doc_types.rs index 60f4ca043..9dd8f01c8 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/doc_types.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/doc_types.rs @@ -248,31 +248,32 @@ impl<'db> SemanticModel<'db> { let Some(value_ty) = field.get_type() else { continue; }; + let mut value_ty = self.doc_type_lua_rich_in(file_id, value_ty.get_syntax_id()); + if field.is_nullable() && !value_ty.is_nullable() { + value_ty = LuaType::from_vec(vec![value_ty, LuaType::Nil]); + } match key { LuaDocObjectFieldKey::Name(name) => { fields.insert( LuaMemberKey::Name(SmolStr::new(name.get_name_text())), - self.doc_type_lua_rich_in(file_id, value_ty.get_syntax_id()), + value_ty, ); } LuaDocObjectFieldKey::String(str) => { fields.insert( LuaMemberKey::Name(SmolStr::new(str.get_value())), - self.doc_type_lua_rich_in(file_id, value_ty.get_syntax_id()), + value_ty, ); } LuaDocObjectFieldKey::Integer(num) => { if let NumberResult::Int(i) = num.get_number_value() { - fields.insert( - LuaMemberKey::Integer(i), - self.doc_type_lua_rich_in(file_id, value_ty.get_syntax_id()), - ); + fields.insert(LuaMemberKey::Integer(i), value_ty); } } LuaDocObjectFieldKey::Type(key_ty) => { index_access.push(( self.doc_type_lua_rich_in(file_id, key_ty.get_syntax_id()), - self.doc_type_lua_rich_in(file_id, value_ty.get_syntax_id()), + value_ty, )); } }; @@ -546,37 +547,27 @@ impl<'db> SemanticModel<'db> { let Some(value_ty) = field.get_type() else { continue; }; + let mut value_ty = + self.doc_type_lua_rich_scoped(file_id, value_ty.get_syntax_id(), state); + if field.is_nullable() && !value_ty.is_nullable() { + value_ty = LuaType::from_vec(vec![value_ty, LuaType::Nil]); + } match key { LuaDocObjectFieldKey::Name(name) => { fields.insert( LuaMemberKey::Name(SmolStr::new(name.get_name_text())), - self.doc_type_lua_rich_scoped( - file_id, - value_ty.get_syntax_id(), - state, - ), + value_ty, ); } LuaDocObjectFieldKey::String(str) => { fields.insert( LuaMemberKey::Name(SmolStr::new(str.get_value())), - self.doc_type_lua_rich_scoped( - file_id, - value_ty.get_syntax_id(), - state, - ), + value_ty, ); } LuaDocObjectFieldKey::Integer(num) => { if let NumberResult::Int(i) = num.get_number_value() { - fields.insert( - LuaMemberKey::Integer(i), - self.doc_type_lua_rich_scoped( - file_id, - value_ty.get_syntax_id(), - state, - ), - ); + fields.insert(LuaMemberKey::Integer(i), value_ty); } } LuaDocObjectFieldKey::Type(key_ty) => { @@ -586,11 +577,7 @@ impl<'db> SemanticModel<'db> { key_ty.get_syntax_id(), state, ), - self.doc_type_lua_rich_scoped( - file_id, - value_ty.get_syntax_id(), - state, - ), + value_ty, )); } }; diff --git a/crates/emmylua_code_analysis/src/semantic_model/flow.rs b/crates/emmylua_code_analysis/src/semantic_model/flow.rs index d9588c2b3..28e7d0e1d 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/flow.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/flow.rs @@ -1,5 +1,9 @@ //! # flow — flow-sensitive type queries (consumes FlowTree) //! +//! Feature contract: `docs/emmylua_code_analysis/flow-typing.md` in the repository root. +//! Stable declared or inferred types govern writes and whole-table checks; +//! field flow refines reads without rebuilding the containing table type. +//! //! M0: flow-aware declaration/member type queries: //! From the CFG node of the statement at `offset`, backtrack along antecedents; //! take the RHS type of the target's most recent assignment, or the declared initial type. @@ -164,8 +168,15 @@ pub fn type_of_member_at_flow_id( }; let mut visited = HashSet::new(); let mut path = PathState::default(); - trace_member(model, member, None, &tree, start, &mut visited, &mut path) - .unwrap_or_else(fallback) + trace_member( + model, + MemberQuery::read(member, None, None), + &tree, + start, + &mut visited, + &mut path, + ) + .unwrap_or_else(fallback) } /// Target type for assignment checks: apply `---@cast x +T` and exclude this assignment, but **do not apply branch narrowing** — @@ -299,33 +310,68 @@ fn apply_field_nullable(model: &SemanticModel, member: &SemanticId, ty: LuaType) /// Flow-sensitive type of the member at `offset` (member assignment flow + `---@cast t.x +T` widening). /// Same as `type_of_decl_at`: exclude this assignment when `offset` lies on `t.x = value`. pub fn type_of_member_at(model: &SemanticModel, member: &SemanticId, offset: TextSize) -> LuaType { - let fallback = || flow_member_value_type(model, member).unwrap_or(LuaType::Unknown); + member_read_at(model, MemberQuery::read(member, None, None), offset) +} + +fn member_read_at(model: &SemanticModel, query: MemberQuery<'_>, offset: TextSize) -> LuaType { + let member = query.member; + let fallback = || query.initial_type(model).unwrap_or(LuaType::Unknown); let Some(tree) = model.flow_tree() else { return fallback(); }; let Some(flow_id) = tree.get_flow_id_at(offset) else { return fallback(); }; - let start = skip_own_member_assign(member, &tree, flow_id, offset); + let start = skip_current_assignment(&tree, flow_id, offset); // Start directly at the latest assignment of this member when no flow event // (condition/cast/merge) sits between it and the query. This turns repeated // `M.x` reads in long straight-line files from O(offset) into O(1). if start == flow_id && !tree.has_complex_control() { - let latest = tree.latest_member_assignment(member, offset); + let mut latest = tree.latest_member_assignment(member, offset); + if let Some(index) = query.access + && let Some(LuaExpr::NameExpr(name)) = index.get_prefix_expr() + && let Some(owner) = model.resolve_name(name.get_position()) + && let Some(facts) = model.file_facts() + { + // Runtime assignments to the same field can have different member IDs. + let key = member_key_from_index_expr(model, index).unwrap_or(LuaMemberKey::None); + for candidate in facts + .members_of_owner_with_key(&owner, &key) + .chain(facts.members_of_owner_with_key(&owner, &LuaMemberKey::None)) + { + if let Some(assignment) = tree.latest_member_assignment(&candidate.id, offset) + && latest.is_none_or(|latest| assignment.0 > latest.0) + { + latest = Some(assignment); + } + } + if tree + .latest_decl_assignment(&owner, offset) + .is_some_and(|(position, _)| latest.is_none_or(|latest| position > latest.0)) + { + return trace_member( + model, + query, + &tree, + start, + &mut HashSet::new(), + &mut PathState::default(), + ) + .unwrap_or_else(fallback); + } + } + // RHS expressions already bind to the preceding flow node, but the + // position index can still find the assignment containing the read. if let Some((assign_pos, assign_flow)) = latest + && skip_current_assignment(&tree, assign_flow, offset) == assign_flow + && assignment_in_same_closure(model, &tree, assign_flow, offset) && !tree.has_flow_event_between(assign_pos, offset, false) { let mut visited = HashSet::new(); let mut path = PathState::default(); - if let Some(ty) = trace_member( - model, - member, - None, - &tree, - assign_flow, - &mut visited, - &mut path, - ) { + if let Some(ty) = + trace_member(model, query, &tree, assign_flow, &mut visited, &mut path) + { #[cfg(test)] flow_metrics::record_fast_member_hit(); return ty; @@ -336,17 +382,44 @@ pub fn type_of_member_at(model: &SemanticModel, member: &SemanticId, offset: Tex } let mut visited = HashSet::new(); let mut path = PathState::default(); - trace_member(model, member, None, &tree, start, &mut visited, &mut path) - .unwrap_or_else(fallback) + trace_member(model, query, &tree, start, &mut visited, &mut path).unwrap_or_else(fallback) } -/// Skip this assignment only when `offset` is inside the `t.x = value` assignment and that node assigns this member. -pub(crate) fn skip_own_member_assign( - member: &SemanticId, +/// Source order only describes execution order within one function body. +fn assignment_in_same_closure( + model: &SemanticModel, tree: &FlowTree, flow_id: FlowId, offset: TextSize, -) -> FlowId { +) -> bool { + let Some(node) = tree.get_flow_node(flow_id) else { + return false; + }; + let syntax = match &node.kind { + FlowNodeKind::Assignment(ptr) => ptr.get_syntax_id(), + FlowNodeKind::ImplFunc(ptr) => ptr.get_syntax_id(), + _ => return false, + }; + let Some(tree) = model.syntax_tree() else { + return false; + }; + let root = tree.get_red_root(); + let Some(assignment) = syntax.to_node_from_root(&root) else { + return false; + }; + let read = root.covering_element(rowan::TextRange::empty(offset)); + let read = match read { + rowan::NodeOrToken::Node(node) => node, + rowan::NodeOrToken::Token(token) => match token.parent() { + Some(node) => node, + None => return false, + }, + }; + LuaClosureId::from_node(&assignment) == LuaClosureId::from_node(&read) +} + +/// Every RHS reads the state before any target in its assignment is written. +fn skip_current_assignment(tree: &FlowTree, flow_id: FlowId, offset: TextSize) -> FlowId { let Some(node) = tree.get_flow_node(flow_id) else { return flow_id; }; @@ -356,10 +429,7 @@ pub(crate) fn skip_own_member_assign( if !assign_ptr.get_syntax_id().get_range().contains(offset) { return flow_id; } - let has_own = tree.get_flow_effects(flow_id).iter().any(|effect| { - matches!(effect, FlowEffect::AssignMember { member: assigned, .. } if assigned == member) - }); - if has_own && let Some(next) = first_antecedent(tree, node.antecedent.as_ref()) { + if let Some(next) = first_antecedent(tree, node.antecedent.as_ref()) { return next; } flow_id @@ -428,8 +498,7 @@ pub fn type_of_member_before_at( let mut path = PathState::default(); walk_member( model, - member, - None, + MemberQuery::read(member, None, None), &tree, node.antecedent.as_ref(), &mut visited, @@ -438,6 +507,178 @@ pub fn type_of_member_before_at( .unwrap_or_else(fallback) } +/// Refine an already evaluated index value. Both expression queries and VM +/// calls use this operation; it never infers the enclosing index expression. +pub(crate) fn index_read_type( + model: &SemanticModel, + index_expr: &emmylua_parser::LuaIndexExpr, + offset: TextSize, + base: LuaType, + construction: Option<&super::table_construction::Access<'_, '_>>, +) -> LuaType { + let apply_casts = |base, mode| apply_expr_casts_to_node(model, index_expr.syntax(), base, mode); + if let Some(ty) = array::last_element_type(model, index_expr, offset) { + return apply_casts(ty, All); + } + let resolved = model.resolve_member(index_expr); + let member_id = if let Some(construction) = construction { + let source = construction.member_id(); + if source.is_some() || !construction.has_exact_key() { + // Trace the accessed member, not the source expression: a multiple + // assignment can supply several fields from the same expression. + resolved + .as_ref() + .and_then(|resolved| resolved.member_id.clone()) + .or(source) + } else { + None + } + } else { + resolved + .as_ref() + .and_then(|resolved| resolved.member_id.clone()) + .or_else(|| { + let prefix = index_expr.get_prefix_expr()?; + let prefix_ty = model.type_of_expr_at(prefix.get_syntax_id(), offset); + if matches!(prefix_ty, LuaType::Union(_)) { + return None; + } + let key = member_key_from_index_expr(model, index_expr)?; + model + .member_infos_with_key(&prefix_ty, &key) + .into_iter() + .find_map(|info| info.id) + }) + }; + if let Some(member_id) = member_id { + let initial = construction.map(|_| &base); + let mut member_ty = if matches!(index_expr.get_prefix_expr(), Some(LuaExpr::NameExpr(_))) { + member_read_at( + model, + MemberQuery::read(&member_id, Some(index_expr), initial), + offset, + ) + } else if let Some(flow_tree) = model.flow_tree() + && (flow_tree.has_condition() || flow_tree.has_complex_control()) + && let Some(flow_id) = flow_tree.get_flow_id_at(offset) + { + trace_member( + model, + MemberQuery::read(&member_id, Some(index_expr), initial), + &flow_tree, + skip_current_assignment(&flow_tree, flow_id, offset), + &mut HashSet::new(), + &mut PathState::default(), + ) + .unwrap_or_else(|| type_of_member_at(model, &member_id, offset)) + } else { + type_of_member_at(model, &member_id, offset) + } + .get_result_slot_type(0) + .unwrap_or(LuaType::Nil); + // Old semantics use `LuaType::Signature` to represent the closure member's definition site; + // flow-sensitive member values also keep that identity, matching the VM's member access path. + if matches!(member_ty, LuaType::Function) + && let Some(member_file) = match &member_id { + SemanticId::Member(key) => Some(key.file_id), + _ => None, + } + && let Some(facts) = model.file_facts_of(member_file) + && let Some(member_def) = facts.member_by_id(&member_id) + && let Some(value_syntax) = member_def.value_syntax + && let Some(tree) = model.syntax_tree_of(member_file) + && let Some(node) = value_syntax.to_node_from_root(&tree.get_red_root()) + && let Some(closure) = emmylua_parser::LuaClosureExpr::cast(node) + { + member_ty = LuaType::Signature(LuaSignatureId::from_closure(member_file, &closure)); + } + let resolved_is_runtime = resolved.as_ref().is_some_and(|resolved| { + model + .file_facts_of(match &member_id { + SemanticId::Member(key) => key.file_id, + _ => model.file_id(), + }) + .is_some_and(|facts| { + facts + .members_of_owner(&resolved.owner) + .any(|m| m.id == member_id) + }) + }); + // Computed-key fields in table literals (`[key] = 1`) keep literal values in type queries: + // when a dynamic index resolves to a table initializer field, return that member type directly (including literals). + if resolved_is_runtime + && let Some(prefix) = index_expr.get_prefix_expr() + && let Some(emmylua_parser::LuaIndexKey::Expr(_)) = index_expr.get_index_key() + && let Some(member_file) = match &member_id { + SemanticId::Member(key) => Some(key.file_id), + _ => None, + } + && let Some(member_facts) = model.file_facts_of(member_file) + && let Some(member_def) = member_facts.member_by_id(&member_id) + && model.is_initializer_table_field(&member_id, member_def) + { + let prefix_ty = model.type_of_expr_at(prefix.get_syntax_id(), offset); + if let Some(value_syntax) = member_def.value_syntax { + let expr_ty = model.type_of_expr(value_syntax); + match expr_ty { + // Dynamic access to a computed key `[key] = 1` can return `integer`; + // named fields still keep `number` through the existing projection. + LuaType::IntegerConst(_) | LuaType::DocIntegerConst(_) => { + return apply_casts(LuaType::Integer, All); + } + LuaType::StringConst(_) | LuaType::DocStringConst(_) => { + return apply_casts(expr_ty, All); + } + _ => {} + } + } + if let Some(ty) = model.member_type(&prefix_ty, &member_def.key) { + return apply_casts(ty, All); + } + } + // If the prefix was narrowed to a more specific instance/object type on the same flow path, + // prefer the member type projected from that type (e.g. after `a = make()`, `a.a` uses the object field). + if let Some(key) = member_key_from_index_expr(model, index_expr) + && let Some(prefix) = index_expr.get_prefix_expr() + { + let prefix_ty = model.type_of_expr_at(prefix.get_syntax_id(), offset); + let projected = if let LuaType::TableConst(table) = &prefix_ty + && let Some(construction) = super::table_construction::of(model, table) + { + let syntax = (model.file_id() == table.file_id).then(|| index_expr.get_syntax_id()); + construction + .members(model, syntax, Some(&key)) + .into_iter() + .find(|info| info.key == key) + .map(|info| info.typ) + } else { + model.member_type(&prefix_ty, &key) + }; + if let Some(prefix_member_ty) = projected.map(|ty| { + if !member_ty.is_nullable() { + remove_type(ty, &LuaType::Nil) + } else { + ty + } + }) && !matches!(prefix_member_ty, LuaType::Unknown | LuaType::Any) + && (member_type_more_specific(model, &prefix_member_ty, &member_ty) + || matches!(&prefix_ty, LuaType::Generic(_))) + { + return apply_casts(prefix_member_ty, All); + } + } + let member_ty = + guarded_index_type(model, index_expr, offset, &member_ty).unwrap_or(member_ty); + return apply_casts(member_ty, AsOnly); + } + // Inside a `#d == n` branch, `d[1]` no longer carries nil when the array length is known. + if let Some(known_ty) = array_index_with_known_len(model, index_expr, offset) { + return apply_casts(known_ty, All); + } + let ty = guarded_index_type(model, index_expr, offset, &base).unwrap_or(base); + apply_casts(ty, All) +} + /// Evaluate one flow expression after its operands and prefix are cached. pub(crate) fn type_of_expr_at_node( model: &SemanticModel, @@ -452,172 +693,22 @@ pub(crate) fn type_of_expr_at_node( } apply_casts(model.type_of_expr_node(&expr), AsOnly) } - LuaExpr::IndexExpr(index_expr) => { - if let Some(ty) = array::last_element_type(model, index_expr, offset) { - return apply_casts(ty, All); - } - if let Some(resolved) = model.resolve_member(index_expr) - && let Some(member_id) = resolved.member_id.clone().or_else(|| { - let prefix = index_expr.get_prefix_expr()?; - let prefix_ty = model.type_of_expr_at(prefix.get_syntax_id(), offset); - if matches!(prefix_ty, LuaType::Union(_)) { - return None; - } - let key = member_key_from_index_expr(model, index_expr)?; - model - .member_infos_with_key(&prefix_ty, &key) - .into_iter() - .find_map(|info| info.id) - }) - { - let mut member_ty = if let Some(flow_tree) = model.flow_tree() - && (flow_tree.has_condition() || flow_tree.has_complex_control()) - && let Some(flow_id) = flow_tree.get_flow_id_at(offset) - { - trace_member( - model, - &member_id, - Some(index_expr), - &flow_tree, - skip_own_member_assign(&member_id, &flow_tree, flow_id, offset), - &mut HashSet::new(), - &mut PathState::default(), - ) - .unwrap_or_else(|| type_of_member_at(model, &member_id, offset)) - } else { - type_of_member_at(model, &member_id, offset) - } - .get_result_slot_type(0) - .unwrap_or(LuaType::Nil); - // Old semantics use `LuaType::Signature` to represent the closure member's definition site; - // flow-sensitive member values also keep that identity, matching the VM's member access path. - if matches!(member_ty, LuaType::Function) - && let Some(member_file) = match &member_id { - SemanticId::Member(key) => Some(key.file_id), - _ => None, - } - && let Some(facts) = model.file_facts_of(member_file) - && let Some(member_def) = facts.member_by_id(&member_id) - && let Some(value_syntax) = member_def.value_syntax - && let Some(tree) = model.syntax_tree_of(member_file) - && let Some(node) = value_syntax.to_node_from_root(&tree.get_red_root()) - && let Some(closure) = emmylua_parser::LuaClosureExpr::cast(node) - { - member_ty = - LuaType::Signature(LuaSignatureId::from_closure(member_file, &closure)); - } - let resolved_is_runtime = model - .file_facts_of(match &member_id { - SemanticId::Member(key) => key.file_id, - _ => model.file_id(), - }) - .is_some_and(|facts| { - facts - .members_of_owner(&resolved.owner) - .any(|m| m.id == member_id) - }); - // Runtime member assignment flow on local variables (including method `self`) takes precedence over class declaration fields; - // if `resolve_member` already selected a specific runtime member (chosen by annotation/position among same-name assignments), - // don't re-select the first same-name member, avoiding an annotated member falling back to an earlier assignment. - // For declared named/generic types (`local bar: foo<{a:string}> = { a = "test" }`), - // even when `resolve_member` lands on a table field, use the declared type's member as authoritative. - if resolved_is_runtime - && let Some(key) = member_key_from_index_expr(model, index_expr) - && let Some(prefix) = index_expr.get_prefix_expr() - && let LuaExpr::NameExpr(_) = &prefix - { - let prefix_ty = model.type_of_expr_at(prefix.get_syntax_id(), offset); - if matches!( - &prefix_ty, - LuaType::Ref(_) | LuaType::Def(_) | LuaType::Generic(_) - ) && let Some(prefix_member_ty) = model.member_type(&prefix_ty, &key) - && !matches!(prefix_member_ty, LuaType::Unknown) - { - let ty = if prefix_member_ty.is_nullable() - && !member_ty.is_nullable() - && member_type_more_specific(model, &member_ty, &prefix_member_ty) - { - remove_type(prefix_member_ty, &LuaType::Nil) - } else { - prefix_member_ty - }; - return apply_casts(ty, All); - } - } - // Computed-key fields in table literals (`[key] = 1`) keep literal values in type queries: - // when a dynamic index resolves to a table initializer field, return that member type directly (including literals). - if resolved_is_runtime - && let Some(prefix) = index_expr.get_prefix_expr() - && let Some(emmylua_parser::LuaIndexKey::Expr(_)) = index_expr.get_index_key() - && let Some(member_file) = match &member_id { - SemanticId::Member(key) => Some(key.file_id), - _ => None, - } - && let Some(member_facts) = model.file_facts_of(member_file) - && let Some(member_def) = member_facts.member_by_id(&member_id) - && model.is_initializer_table_field(&member_id, member_def) - { - let prefix_ty = model.type_of_expr_at(prefix.get_syntax_id(), offset); - if let Some(value_syntax) = member_def.value_syntax { - let expr_ty = model.type_of_expr(value_syntax); - match expr_ty { - // Dynamic access to a computed key `[key] = 1` can return `integer`; - // named fields still keep `number` through the existing projection. - LuaType::IntegerConst(_) | LuaType::DocIntegerConst(_) => { - return apply_casts(LuaType::Integer, All); - } - LuaType::StringConst(_) | LuaType::DocStringConst(_) => { - return apply_casts(expr_ty, All); - } - _ => {} - } - } - if let Some(ty) = model.member_type(&prefix_ty, &member_def.key) { - return apply_casts(ty, All); - } - } - if let SemanticId::Decl(owner_decl) = &resolved.owner - && let Some(facts) = model.file_facts_of(owner_decl.file_id) - { - if !resolved_is_runtime - && let Some(runtime_member) = facts - .members_of_owner(&resolved.owner) - .find(|m| m.key.name() == Some(resolved.name.as_str())) - // Table fields from a declared initializer are not "runtime overrides": `local b: B = { field = 1 }` - // field access is still governed by the class `@field` type, avoiding widening `integer` to `number`. - && !model.is_initializer_table_field(&runtime_member.id, runtime_member) - { - let runtime_ty = type_of_member_at(model, &runtime_member.id, offset); - if !matches!(runtime_ty, LuaType::Unknown) { - return apply_casts(runtime_ty, All); - } - } - } - // If the prefix was narrowed to a more specific instance/object type on the same flow path, - // prefer the member type projected from that type (e.g. after `a = make()`, `a.a` uses the object field). - if let Some(key) = member_key_from_index_expr(model, index_expr) - && let Some(prefix) = index_expr.get_prefix_expr() - { - let prefix_ty = model.type_of_expr_at(prefix.get_syntax_id(), offset); - if let Some(prefix_member_ty) = model.member_type(&prefix_ty, &key) - && !matches!(prefix_member_ty, LuaType::Unknown | LuaType::Any) - && (member_type_more_specific(model, &prefix_member_ty, &member_ty) - || matches!(&prefix_ty, LuaType::Generic(_))) - { - return apply_casts(prefix_member_ty, All); - } - } - let member_ty = - guarded_index_type(model, index_expr, offset, &member_ty).unwrap_or(member_ty); - return apply_casts(member_ty, AsOnly); - } - // Inside a `#d == n` branch, `d[1]` no longer carries nil when the array length is known. - if let Some(known_ty) = array_index_with_known_len(model, &index_expr, offset) { - return apply_casts(known_ty, All); - } - let ty = model.type_of_expr_node(&expr); - let ty = guarded_index_type(model, index_expr, offset, &ty).unwrap_or(ty); - apply_casts(ty, All) + LuaExpr::IndexExpr(index) => { + // Prefixes are already cached by the iterative flow traversal. + let construction = index.get_prefix_expr().and_then(|prefix| { + super::table_construction::access_with_types( + model, + index.get_syntax_id(), + &model.type_of_expr_at(prefix.get_syntax_id(), offset), + index + .get_index_key() + .and_then(|key| super::table_constructor::key_type(model, key).ok()), + ) + }); + let base = construction + .as_ref() + .map_or_else(|| model.type_of_expr_node(&expr), |access| access.read()); + index_read_type(model, index, offset, base, construction.as_ref()) } LuaExpr::CallExpr(_) | LuaExpr::TableExpr(_) | LuaExpr::LiteralExpr(_) => { apply_casts(model.type_of_expr_node(&expr), All) @@ -774,7 +865,7 @@ fn guarded_index_type( Some(apply_narrowing(model, ty, &narrowing)) } -/// Match truthiness and direct nil comparisons without treating a non-nil boolean as a guard. +/// Match truthiness, nil comparisons, and primitive type tests for the same access path. fn narrow_index_condition( model: &SemanticModel, target: &(SemanticId, Vec), @@ -787,17 +878,40 @@ fn narrow_index_condition( let (left, right) = binary.get_exprs()?; let (value, literal, op) = literal_comparison(left, right, binary.get_op_token()?.get_op())?; - if literal.is_nil() - && matches!(op, BinaryOperator::OpEq | BinaryOperator::OpNe) - && branch_is_true == (op == BinaryOperator::OpNe) - && stable_access_path_at(model, &value, access) - .or_else(|| index_access_path(model, &value)) - .as_ref() - == Some(target) - { - return Some(Narrowing::NotNil); + if !matches!(op, BinaryOperator::OpEq | BinaryOperator::OpNe) { + return None; } - return None; + let keep_matching = branch_is_true == (op == BinaryOperator::OpEq); + let value = match literal { + LuaType::Nil if !keep_matching => value, + LuaType::StringConst(name) | LuaType::DocStringConst(name) => { + let primitive = primitive_type_from_name(name.as_str())?; + if keep_matching == primitive.is_nil() { + return None; + } + let LuaExpr::CallExpr(call) = value else { + return None; + }; + let LuaExpr::NameExpr(callee) = call.get_prefix_expr()? else { + return None; + }; + if callee.get_name_text().as_deref() != Some("type") + || matches!( + model.resolve_name(callee.get_position()), + Some(SemanticId::Decl(key)) if !model.is_std_file(key.file_id) + ) + { + return None; + } + call.get_args_list()?.get_args().next()? + } + _ => return None, + }; + return (stable_access_path_at(model, &value, access) + .or_else(|| index_access_path(model, &value)) + .as_ref() + == Some(target)) + .then_some(Narrowing::NotNil); } (branch_is_true && stable_access_path_at(model, &cond, access) @@ -1337,6 +1451,12 @@ fn apply_expr_casts_to_node( } if let Some(doc_type) = as_tag.get_type() { ty = model.doc_type_lua(doc_type.get_syntax_id()); + if matches!(ty, LuaType::Table | LuaType::Unknown) { + let rich = model.doc_type_lua_rich(doc_type.get_syntax_id()); + if !rich.is_unknown() { + ty = rich; + } + } } } _ => {} @@ -1429,14 +1549,24 @@ fn member_write_match( return MemberWrite::Unrelated; }; let key_range = assigned.and_then(SemanticId::member_key_range); - let Some(assign) = flow_assign_stat(model, tree, flow_id) else { + let vars = match tree.get_flow_node(flow_id).map(|node| &node.kind) { + Some(FlowNodeKind::Assignment(_)) => { + flow_assign_stat(model, tree, flow_id).map(|assign| assign.get_var_and_expr_list().0) + } + Some(FlowNodeKind::ImplFunc(ptr)) => model + .chunk() + .and_then(|chunk| ptr.to_node(&chunk)) + .map(|stat| stat.get_func_name().into_iter().collect()), + _ => None, + }; + let Some(vars) = vars else { return MemberWrite::Unrelated; }; let target = LuaExpr::IndexExpr(access.clone()); let Some(target_path) = index_access_path(model, &target) else { return MemberWrite::Unrelated; }; - for var in assign.get_var_and_expr_list().0 { + for var in vars { let Some(written) = LuaExpr::cast(var.syntax().clone()) else { continue; }; @@ -1483,7 +1613,7 @@ fn path_write_affects( /// Expand initializer aliases without inferring types. Each captured member path /// must still identify the same object at this occurrence. -fn stable_access_path( +pub(super) fn stable_access_path( model: &SemanticModel, expr: &LuaExpr, ) -> Option<(SemanticId, Vec)> { @@ -1740,7 +1870,6 @@ fn finish_decl_frames( frame.flow_id, frame.assign_pos, frame.value_syntax, - &narrowed_before, ); let value_ty = coerce_table_assign_to_narrowed( model, @@ -1918,7 +2047,6 @@ fn trace_decl_uncached( current, assign_pos, *value_syntax, - &narrowed_before, ); let value_ty = coerce_table_assign_to_narrowed( model, @@ -2111,16 +2239,96 @@ fn member_annotation_type(model: &SemanticModel, member: &SemanticId) -> Option< Some(model.doc_type_lua_in(member_file, syntax, &[])) } +/// Read flow and stable writes share access-path matching and cast scope. +#[derive(Clone, Copy)] +struct MemberQuery<'a> { + member: &'a SemanticId, + access: Option<&'a emmylua_parser::LuaIndexExpr>, + options: TraceOptions, + base: Option<&'a LuaType>, +} + +impl<'a> MemberQuery<'a> { + fn read( + member: &'a SemanticId, + access: Option<&'a emmylua_parser::LuaIndexExpr>, + base: Option<&'a LuaType>, + ) -> Self { + Self { + member, + access, + options: TraceOptions::FLOW_READ, + base, + } + } + + fn initial_type(self, model: &SemanticModel) -> Option { + self.base + .cloned() + .or_else(|| { + let index = self.access?; + let LuaExpr::NameExpr(name) = index.get_prefix_expr()? else { + return None; + }; + let decl = model.resolve_name(name.get_position())?; + let definition = model.file_facts()?.decl_by_id(&decl)?; + if definition.doc_type_syntax.is_none() + && !matches!(definition.kind, crate::DeclKind::Param) + { + return None; + } + let ty = model.type_of_decl_assign_target_at(&decl, name.get_position()); + model.member_type(&ty, &member_key_from_index_expr(model, index)?) + }) + .or_else(|| flow_member_value_type(model, self.member)) + } +} + +/// Stable field contracts accept named casts, without read guards or assignments. +pub(super) fn index_assignment_type( + model: &SemanticModel, + index: &emmylua_parser::LuaIndexExpr, + base: LuaType, +) -> LuaType { + let Some(tree) = model.flow_tree().filter(|tree| tree.has_tag_cast()) else { + return base; + }; + let Some(start) = tree.get_flow_id_at(index.get_position()) else { + return base; + }; + let member = model + .resolve_member(index) + .and_then(|resolved| resolved.member_id) + .unwrap_or_else(|| SemanticId::member(model.file_id(), index.get_range())); + let query = MemberQuery { + member: &member, + access: Some(index), + options: TraceOptions::ASSIGN_TARGET, + base: Some(&base), + }; + trace_member( + model, + query, + &tree, + start, + &mut HashSet::new(), + &mut PathState::default(), + ) + .unwrap_or(base) +} + /// Backtrack along the CFG for the member's most recent assignment type. fn trace_member( model: &SemanticModel, - member: &SemanticId, - access: Option<&emmylua_parser::LuaIndexExpr>, + query: MemberQuery<'_>, tree: &FlowTree, flow_id: FlowId, visited: &mut HashSet, path: &mut PathState, ) -> Option { + let member = query.member; + let access = query.access; + let fallback = || query.initial_type(model); let mut current = flow_id; loop { #[cfg(test)] @@ -2130,67 +2338,99 @@ fn trace_member( } let node = tree.get_flow_node(current)?; match &node.kind { - FlowNodeKind::Assignment(_) => { - for effect in tree.get_flow_effects(current) { - if let FlowEffect::AssignMember { - member: assigned_member, - value_syntax, - .. - } = effect + FlowNodeKind::Assignment(_) | FlowNodeKind::ImplFunc(_) => { + if !query.options.assignments { + // Replacing a parent resets casts on its old fields. Writes to + // the field itself leave its explicit assignment contract intact. + if let Some(access) = access + && let Some(target) = + index_access_path(model, &LuaExpr::IndexExpr(access.clone())) + && let Some(assign) = flow_assign_stat(model, tree, current) + && assign.get_var_and_expr_list().0.iter().any(|var| { + LuaExpr::cast(var.syntax().clone()) + .and_then(|expr| index_access_path(model, &expr)) + .is_some_and(|written| { + written.1.len() < target.1.len() + && path_write_affects(&written, &target.0, &target.1) + }) + }) { - match member_write_match( - model, - tree, - current, - member, - access, - Some(assigned_member), - ) { - MemberWrite::Unrelated => continue, - MemberWrite::Invalidates => { - // Replacing an owner or writing through a dynamic key removes - // earlier guards, but preserves guards after this assignment. - return flow_member_value_type(model, member) - .map(|ty| finalize(model, ty, path)); + return fallback().map(|ty| finalize(model, ty, path)); + } + } else { + for effect in tree.get_flow_effects(current) { + if let FlowEffect::AssignMember { + member: assigned_member, + value_syntax, + result_slot, + .. + } = effect + { + match member_write_match( + model, + tree, + current, + member, + access, + Some(assigned_member), + ) { + MemberWrite::Unrelated => continue, + MemberWrite::Invalidates => { + // Replacing an owner or writing through a dynamic key removes + // earlier guards, but preserves guards after this assignment. + return fallback().map(|ty| finalize(model, ty, path)); + } + MemberWrite::Exact => {} } - MemberWrite::Exact => {} - } - // Member assignment overrides earlier guard narrowing; cast widening is retained. - let assign_path = PathState { - narrowings: Vec::new(), - casts: path.casts.clone(), - }; - let mut value_ty = type_of_expr_with_cast(model, *value_syntax, AsOnly); - if let Some(annotation) = member_annotation_type(model, member) { - if model.type_check(&value_ty, &annotation) { - value_ty = widen_const_type(value_ty); - } else { + // Member assignment overrides earlier guard narrowing; cast widening is retained. + let assign_path = PathState { + narrowings: Vec::new(), + casts: path.casts.clone(), + }; + let mut value_ty = type_of_expr_with_cast(model, *value_syntax, AsOnly) + .get_result_slot_type(*result_slot) + .unwrap_or(LuaType::Nil); + if matches!(value_ty, LuaType::Function) + && let Some(closure) = model + .syntax_tree() + .and_then(|tree| { + value_syntax.to_node_from_root(&tree.get_red_root()) + }) + .and_then(emmylua_parser::LuaClosureExpr::cast) + { + value_ty = LuaType::Signature(LuaSignatureId::from_closure( + model.file_id(), + &closure, + )); + } + if let Some(annotation) = member_annotation_type(model, member) + && !model.type_check(&value_ty, &annotation) + { value_ty = annotation; } + return Some(finalize(model, value_ty, &assign_path)); } - return Some(finalize(model, value_ty, &assign_path)); } - } - // A dynamic-key write can lack a member ID and still replace - // the guarded value or one of its owners. - if member_write_match(model, tree, current, member, access, None) - == MemberWrite::Invalidates - { - return flow_member_value_type(model, member) - .map(|ty| finalize(model, ty, path)); + // A dynamic-key write can lack a member ID and still replace + // the guarded value or one of its owners. + if member_write_match(model, tree, current, member, access, None) + == MemberWrite::Invalidates + { + return fallback().map(|ty| finalize(model, ty, path)); + } } } - FlowNodeKind::TagCast(cast_ptr) => { - collect_member_cast(model, member, cast_ptr, path); + FlowNodeKind::TagCast(cast_ptr) if query.options.casts => { + collect_member_cast(model, member, access, cast_ptr, path); } - FlowNodeKind::TrueCondition(cond_ptr) => { + FlowNodeKind::TrueCondition(cond_ptr) if query.options.guards => { if let Some(narrowing) = narrow_member_condition(model, member, access, cond_ptr, true) { path.narrowings.push(narrowing); } } - FlowNodeKind::FalseCondition(cond_ptr) => { + FlowNodeKind::FalseCondition(cond_ptr) if query.options.guards => { if let Some(narrowing) = narrow_member_condition(model, member, access, cond_ptr, false) { @@ -2198,14 +2438,14 @@ fn trace_member( } } FlowNodeKind::Start | FlowNodeKind::Unreachable => { - return flow_member_value_type(model, member).map(|ty| finalize(model, ty, path)); + return fallback().map(|ty| finalize(model, ty, path)); } _ => {} } match &node.antecedent { None => { - return flow_member_value_type(model, member).map(|ty| finalize(model, ty, path)); + return fallback().map(|ty| finalize(model, ty, path)); } Some(FlowAntecedent::Single(next)) => { current = *next; @@ -2216,22 +2456,14 @@ fn trace_member( // jump directly to the common predecessor and continue backtracking, avoiding equal-depth recursion from consecutive ifs. if let Some(branches) = tree.get_multi_antecedents(*multi_id) && let Some(common) = local_merge_common(tree, branches) - && branches.iter().all(|branch| { - member_branch_is_pure(model, tree, member, access, *branch, common) - }) + && branches + .iter() + .all(|branch| member_branch_is_pure(model, tree, query, *branch, common)) { current = common; continue; } - return walk_member( - model, - member, - access, - tree, - node.antecedent.as_ref(), - visited, - path, - ); + return walk_member(model, query, tree, node.antecedent.as_ref(), visited, path); } } } @@ -2409,6 +2641,7 @@ fn collect_decl_cast( fn collect_member_cast( model: &SemanticModel, member: &SemanticId, + access: Option<&emmylua_parser::LuaIndexExpr>, cast_ptr: &emmylua_parser::LuaAstPtr, path: &mut PathState, ) { @@ -2421,7 +2654,9 @@ fn collect_member_cast( let Some(key_expr) = cast.get_key_expr() else { return; }; - let matches = match &key_expr { + let matches = access.is_some_and(|access| { + same_member_access(model, &key_expr, &LuaExpr::IndexExpr(access.clone())) + }) || match &key_expr { LuaExpr::IndexExpr(index_expr) => { model .resolve_member(index_expr) @@ -2471,14 +2706,12 @@ fn assigned_value_type( flow_id: FlowId, assign_pos: TextSize, value_syntax: emmylua_parser::LuaSyntaxId, - target_ty: &LuaType, ) -> LuaType { let Some(multi_ref) = tree.get_decl_multi_return_ref_on_flow(decl, assign_pos, flow_id) else { return assigned_value_type_with_self_widening( model, decl, value_syntax, - target_ty, model.type_of_expr_at(value_syntax, assign_pos), ); }; @@ -2496,7 +2729,6 @@ fn assigned_value_type( model, decl, value_syntax, - target_ty, model.type_of_expr_at(value_syntax, assign_pos), ); }; @@ -2505,7 +2737,6 @@ fn assigned_value_type( model, decl, value_syntax, - target_ty, model.type_of_expr(value_syntax), ); }; @@ -2514,7 +2745,6 @@ fn assigned_value_type( model, decl, value_syntax, - target_ty, model.type_of_expr(value_syntax), ); }; @@ -2529,7 +2759,6 @@ fn assigned_value_type( model, decl, value_syntax, - target_ty, model.type_of_expr_at(value_syntax, assign_pos), ) } @@ -2542,10 +2771,8 @@ fn assigned_value_type_with_self_widening( model: &SemanticModel, decl: &SemanticId, value_syntax: emmylua_parser::LuaSyntaxId, - target_ty: &LuaType, value_ty: LuaType, ) -> LuaType { - let value_ty = widen_consts_to_target(model, value_ty, target_ty); if is_self_dependent_expr(model, value_syntax, decl) && is_const_like(&value_ty) { widen_const_type(value_ty) } else { @@ -3320,44 +3547,6 @@ fn type_alias_discriminant_from_condition( None } -/// Widen RHS literal constants to their base type only when the target variable already has a "primitive union" type. -/// For example, `result: integer|string` assigned `1 | "override"` widens to `integer|string`; -/// unannotated variables or enum/literal types keep the original literal. -fn widen_consts_to_target(model: &SemanticModel, ty: LuaType, target: &LuaType) -> LuaType { - if matches!(target, LuaType::Unknown | LuaType::Any) { - return ty; - } - let is_primitive = |t: &LuaType| -> bool { - matches!( - t, - LuaType::Integer | LuaType::Number | LuaType::String | LuaType::Boolean - ) - }; - let target_components = match target { - LuaType::Union(union) => union.into_vec(), - other => vec![other.clone()], - }; - let non_nil_components: Vec<_> = target_components - .iter() - .filter(|component| **component != LuaType::Nil) - .cloned() - .collect(); - let target_is_primitive_union = - !non_nil_components.is_empty() && non_nil_components.iter().all(is_primitive); - if !target_is_primitive_union { - return ty; - } - // In a nullable primitive union (e.g. `string?`), widen literals only when the assignment is compatible with that non-nil primitive; - // incompatible assignments (`string?` variable assigned `1`) keep the exact literal (which would otherwise drop nil). - if target_components.contains(&LuaType::Nil) { - let non_nil_target = LuaType::from_vec(non_nil_components); - if !model.type_check(&ty, &non_nil_target) { - return ty; - } - } - widen_const_type(ty) -} - /// After generic binding substitution, widen literal constants to their base type (`1` → `integer`). fn widen_const_type(ty: LuaType) -> LuaType { match ty { @@ -3734,6 +3923,7 @@ fn narrow_member_condition( ) -> Option { let chunk = model.chunk()?; let cond = LuaExpr::cast(cond_ptr.to_node(&chunk)?.syntax().clone())?; + let (cond, branch_is_true) = normalize_condition(cond, branch_is_true)?; if let LuaExpr::CallExpr(call) = &cond { if let Some((arg, guard_ty)) = type_guard_call_target(model, call) && let LuaExpr::IndexExpr(index_expr) = &arg @@ -5370,11 +5560,12 @@ fn walk_decl( fn member_branch_is_pure( model: &SemanticModel, tree: &FlowTree, - member: &SemanticId, - access: Option<&emmylua_parser::LuaIndexExpr>, + query: MemberQuery<'_>, start: FlowId, stop: FlowId, ) -> bool { + let member = query.member; + let access = query.access; let mut current = start; let mut visited = HashSet::new(); loop { @@ -5388,7 +5579,7 @@ fn member_branch_is_pure( return false; }; match &node.kind { - FlowNodeKind::Assignment(_) => { + FlowNodeKind::Assignment(_) | FlowNodeKind::ImplFunc(_) => { if tree.get_flow_effects(current).iter().any(|effect| { matches!( effect, @@ -5433,8 +5624,7 @@ fn member_branch_is_pure( fn walk_member( model: &SemanticModel, - member: &SemanticId, - access: Option<&emmylua_parser::LuaIndexExpr>, + query: MemberQuery<'_>, tree: &FlowTree, antecedent: Option<&FlowAntecedent>, visited: &mut HashSet, @@ -5442,18 +5632,20 @@ fn walk_member( ) -> Option { match antecedent { // Backtracked to the start: use the member's declaration type as the base and apply cast widening collected on the path. - None => flow_member_value_type(model, member).map(|ty| finalize(model, ty, path)), + None => query + .initial_type(model) + .map(|ty| finalize(model, ty, path)), Some(FlowAntecedent::Single(next)) => { - trace_member(model, member, access, tree, *next, visited, path) + trace_member(model, query, tree, *next, visited, path) } Some(FlowAntecedent::Multiple(multi_id)) => { let branches = tree.get_multi_antecedents(*multi_id)?; if let Some(common) = local_merge_common(tree, branches) - && branches.iter().all(|branch| { - member_branch_is_pure(model, tree, member, access, *branch, common) - }) + && branches + .iter() + .all(|branch| member_branch_is_pure(model, tree, query, *branch, common)) { - return trace_member(model, member, access, tree, common, visited, path); + return trace_member(model, query, tree, common, visited, path); } let mut merged = LuaType::Unknown; for branch in branches { @@ -5461,14 +5653,17 @@ fn walk_member( let mut branch_path = path.clone_path(); if let Some(branch_ty) = trace_member( model, - member, - access, + query, tree, *branch, &mut branch_visited, &mut branch_path, ) { - merged = merge_types(merged, branch_ty); + merged = if query.options.assignments { + merge_types(merged, branch_ty) + } else { + intersect_types(merged, branch_ty) + }; } } Some(merged) diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow.rs index be0ef8985..b4257582f 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod test { use crate::{DiagnosticCode, EmmyrcLuaVersion, FileId, LuaType, VirtualWorkspace}; - use emmylua_parser::{LuaAstNode, LuaAstToken, LuaLocalName, LuaNameExpr}; + use emmylua_parser::{LuaAstNode, LuaAstToken, LuaIndexExpr, LuaLocalName, LuaNameExpr}; use ntest::timeout; const STACKED_TYPE_GUARDS: usize = 180; @@ -388,6 +388,50 @@ mod test { )); } + #[test] + fn test_optional_callback_type_guards() { + let mut ws = VirtualWorkspace::new(); + for (body, safe) in [ + ( + "if type(props.bar) == 'function' then props.bar() end", + true, + ), + ( + "if 'function' == type(props.bar) then props.bar() end", + true, + ), + ("if type(props.bar) ~= 'nil' then props.bar() end", true), + ( + "if type(props.bar) ~= 'function' then return end; props.bar()", + true, + ), + ( + "if type(props.bar) ~= 'function' then props.bar() end", + false, + ), + ("if type(props.bar) == 'nil' then props.bar() end", false), + ( + "local function type() return 'function' end; if type(props.bar) == 'function' then props.bar() end", + false, + ), + ] { + assert_eq!( + ws.has_no_diagnostic( + DiagnosticCode::NeedCheckNil, + &format!( + r#" + ---@type {{bar?: fun():string}} + local props + {body} + "# + ), + ), + safe, + "{body}" + ); + } + } + #[test] fn test_stacked_same_var_truthiness_guards_build_semantic_model() { let mut ws = VirtualWorkspace::new(); @@ -1442,7 +1486,7 @@ print(a.field) } #[test] - fn test_doc_member_assignment_prefers_annotation_source() { + fn test_doc_member_assignment_preserves_literal_flow() { let mut ws = VirtualWorkspace::new(); let code = r#" @@ -1454,7 +1498,7 @@ print(a.field) "#; ws.def(code); - assert_eq!(ws.expr_ty("b"), ws.ty("integer")); + assert_eq!(ws.expr_ty("b"), ws.ty("1")); } #[test] @@ -1881,7 +1925,7 @@ n = n + 1 "#, ); - assert_eq!(ws.expr_ty("after_loop"), ws.ty("string")); + assert_eq!(ws.expr_ty("after_loop"), ws.ty("'loop'")); } #[test] @@ -4514,6 +4558,57 @@ _ = a[2] assert_eq!(ws.humanize_type(after_assign), r#""a""#); } + #[test] + fn test_literal_assignments_keep_precise_variable_and_field_reads() { + for (base, initial, replacement) in [ + ("integer", "1", "2"), + ("number", "1.5", "2.5"), + ("string", "'hello'", "'updated'"), + ("boolean", "true", "false"), + ] { + for annotated in [false, true] { + let mut ws = VirtualWorkspace::new(); + let variable_annotation = if annotated { + format!("---@type {base}") + } else { + String::new() + }; + let field_annotation = if annotated { + format!("---@type {{value: {base}}}") + } else { + String::new() + }; + let file = ws.def(&format!( + r#" + {variable_annotation} + local value = {initial} + value = {replacement} + variable_read = value + + {field_annotation} + local state = {{value = {initial}}} + state.value = {replacement} + field_read = state.value + "#, + )); + let expected = ws.expr_ty(replacement); + assert_eq!(ws.expr_ty("variable_read"), expected, "{base}, {annotated}"); + let model = ws.analysis.semantic_model(file); + let read = model + .chunk() + .unwrap() + .descendants::() + .last() + .unwrap(); + assert_eq!( + model.type_of_expr_at(read.get_syntax_id(), read.get_position()), + expected, + "{base}, {annotated}" + ); + } + } + } + #[test] fn test_assignment_from_broad_string_drops_literal_branch_narrowing() { let mut ws = VirtualWorkspace::new(); diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow_contract.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow_contract.rs new file mode 100644 index 000000000..e17fb03a8 --- /dev/null +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow_contract.rs @@ -0,0 +1,899 @@ +use crate::{DiagnosticCode, test_lib::VirtualWorkspace}; +use lsp_types::NumberOrString; +use tokio_util::sync::CancellationToken; + +/// The specification's examples exercise the public diagnostic path, including +/// the absence of errors on accepted reads, writes, arguments, and returns. +#[test] +fn flow_typing_contract() { + let document = include_str!("../../../../../../docs/emmylua_code_analysis/flow-typing.md"); + let mut failures = Vec::new(); + for (index, section) in document.split("```lua\n").skip(1).enumerate() { + let code = section.split("```").next().unwrap(); + failures.extend( + check_contract(code) + .into_iter() + .map(|failure| format!("example {}: {failure}", index + 1)), + ); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +fn check_contract(code: &str) -> Vec { + check_contract_in(&mut VirtualWorkspace::new_with_init_std_lib(), code) +} + +fn check_contract_in(ws: &mut VirtualWorkspace, code: &str) -> Vec { + let mut failures = Vec::new(); + let file = ws.def(code); + let diagnostics = ws + .analysis + .diagnose_file(file, CancellationToken::new()) + .unwrap_or_default(); + let errors: Vec<_> = diagnostics + .iter() + .filter(|diagnostic| { + [ + DiagnosticCode::AssignTypeMismatch, + DiagnosticCode::ParamTypeMismatch, + DiagnosticCode::ReturnTypeMismatch, + DiagnosticCode::UndefinedField, + DiagnosticCode::MissingFields, + ] + .iter() + .any(|code| diagnostic.code == Some(NumberOrString::String(code.get_name().to_owned()))) + }) + .collect(); + for (line, text) in code.lines().enumerate() { + let expected = text.contains("-- Error:"); + let actual: Vec<_> = errors + .iter() + .filter(|diagnostic| diagnostic.range.start.line as usize == line) + .collect(); + if expected != !actual.is_empty() { + failures.push(format!( + "line {}: {text}\n expected error: {expected}; {actual:?}", + line + 1 + )); + } + } + failures +} + +fn assert_contract(code: &str) { + let failures = check_contract(code); + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn construction_seals_at_uses_of_the_whole_value() { + assert_contract( + r#" + local alias_source = {} + alias_source.text = 'hello' + local alias = alias_source + alias_source.extra = 1 -- Error: aliasing seals the source. + alias.extra = 1 -- Error: an alias cannot add fields. + + local passed = {} + passed.text = 'hello' + local function consume(value) end + consume(passed) + passed.extra = 1 -- Error: passing the table seals it. + + local stored = {} + stored.text = 'hello' + local holder = {value = stored} + stored.extra = 1 -- Error: storage elsewhere seals the table. + + local nested = {child = {text = 'hello'}} + nested.child.text = 'updated' + nested.child.extra = 1 -- Error: nested writes use the existing child's type. + nested.extra = 1 -- Error: a nested write seals its owner. + + local parenthesized = ({}) + parenthesized.text = 'hello' + ---@type {text: string} + local record = parenthesized + parenthesized.extra = 1 -- Error: parentheses preserve construction and sealing. + + local replaced = {text = 'hello'} + replaced = {text = 'updated'} + replaced.extra = 1 -- Error: reassignment does not restart construction. + "#, + ); +} + +#[test] +fn conditional_construction_uses_only_paths_reaching_the_use() { + assert_contract( + r#" + ---@param flag boolean + local function build(flag) + local value = {} + if flag then return end + value.text = 'hello' + ---@type {text: string} + local record = value + return record + end + + ---@param flag boolean + local function established(flag) + local value = {text = 'hello'} + if flag then value.text = 1 end -- Error: the stable field type is string. + return value + end + + ---@param flag boolean + local function loop(flag) + local value = {} + while flag do + value.text = 'hello' + if flag then break end + end + ---@type {text?: string} + local optional = value + ---@type {text: string} + local required = value -- Error: the loop can execute zero times. + return optional, required + end + "#, + ); +} + +#[test] +fn error_paths_do_not_make_constructed_fields_optional() { + assert_contract( + r#" + ---@param stop boolean + local function build(stop) + local value = {} + if stop then error('stop') else value.text = 'hello' end + ---@type {text: string} + local record = value + return record + end + "#, + ); +} + +#[test] +fn declared_never_calls_do_not_reach_construction_joins() { + assert_contract( + r#" + ---@return never + local function stop() error('stop') end + ---@param fail boolean + local function build(fail) + local value = {} + if fail then stop() else value.text = 'hello' end + ---@type {text: string} + local record = value + return record + end + + ---@alias Stop fun(): never + ---@param stop Stop + ---@param fail boolean + local function aliased_stop(stop, fail) + local value = {} + if fail then stop() else value.text = 'hello' end + ---@type {text: string} + local record = value + return record + end + + ---@param stop (fun(value: boolean): never) | (fun(value: string): nil) + ---@param fail boolean + local function may_return(stop, fail) + local value = {} + if fail then stop('continue') else value.text = 'hello' end + ---@type {text: string} + local record = value -- Error: one callable alternative can return. + return record + end + + ---@param stop (fun(value: boolean): never) | (fun(value: string): never) + ---@param fail boolean + local function always_stops(stop, fail) + local value = {} + if fail then stop('stop') else value.text = 'hello' end + ---@type {text: string} + local record = value + return record + end + "#, + ); +} + +#[test] +fn literal_true_loops_use_only_break_exits() { + assert_contract( + r#" + local value = {} + while true do + value.text = 'hello' + break + end + ---@type {text: string} + local record = value + + ---@param done boolean + local function build(done) + local value = {} + while (true) do + if done then + value.text = 'hello' + break + end + end + ---@type {text: string} + local record = value + return record + end + "#, + ); +} + +#[test] +fn field_assertions_do_not_disable_table_construction() { + assert_contract( + r#" + local value = {count = 1 --[[@as integer]]} + ---@type {text: string} + local record = value -- Error: a field assertion does not provide text. + value.text = 'late' -- Error: the whole-table use seals value before this write. + + local built = {text = 'hello' --[[@as string]]} + built.count = 1 + ---@type {text: string, count: integer} + local record = built + built.extra = 1 -- Error: field assertions preserve normal sealing. + "#, + ); +} + +#[test] +fn overlapping_index_writes_respect_existing_contracts() { + assert_contract( + r#" + ---@alias TextOrCount string | integer + ---@alias TextOrFlag string | boolean + ---@param text_key string + ---@param count_key integer + ---@param mixed_key TextOrCount + ---@param other_key TextOrFlag + local function build(text_key, count_key, mixed_key, other_key) + local value = {} + value[text_key] = 'hello' + value[mixed_key] = 1 -- Error: mixed_key can select the string index. + + local partial = {} + partial[mixed_key] = 'hello' + partial[other_key] = 1 -- Error: the key unions overlap on string. + + local disjoint = {} + disjoint[text_key] = 'hello' + disjoint[count_key] = 1 + disjoint[mixed_key] = 'updated' -- Error: the integer index still requires integers. + + local compatible = {} + compatible[mixed_key] = 'hello' + compatible[other_key] = 'updated' + + local sealed = {} + sealed[text_key] = 'hello' + local record = sealed + sealed[mixed_key] = 'updated' -- Error: the sealed string index cannot add integer keys. + return compatible + end + "#, + ); +} + +#[test] +fn sealed_writes_require_coverage_of_the_whole_key() { + assert_contract( + r#" + ---@param key 'text' | 'extra' + local function named(key) + local value = {text = 'hello'} + local sealed = value + value[key] = 'updated' -- Error: extra is outside the sealed shape. + return value + end + + ---@param text_key string + ---@param count_key integer + ---@param key string | integer + local function indexed(text_key, count_key, key) + local value = {} + value[text_key] = 'hello' + value[count_key] = 'hello' + local sealed = value + value[key] = 'updated' + return value + end + "#, + ); +} + +#[test] +fn function_fields_use_the_sealed_structure() { + assert_contract( + r#" + local module = {} + function module.read() + return module.text + end + module.copy = function() return module.text end + function module:get() return self.text end + module.text = 'hello' + ---@type string + local first = module.read() + ---@type string + local second = module.copy() + ---@type string + local third = module:get() + module.extra = 1 -- Error: a call seals the owner. + + local replaced = {} + ---@return string | integer + function replaced.read() return 'hello' end + ---@return integer + function replaced.read() return 1 end + ---@type fun(): integer + local callback = replaced.read + ---@type integer + local number = replaced.read() + + do + local unused = {} + function unused.read() + return unused.missing -- Error: unused bodies are checked at scope end. + end + end + "#, + ); +} + +#[test] +fn computed_function_reads_seal_construction() { + assert_contract( + r#" + local value = {} + function value.read() + return value.text -- Error: the function is retrieved before text exists. + end + local key = 'read' + local read = value[key] + local text = read() + value.text = 'late' -- Error: the computed function read seals value. + + ---@param key string + local function dynamic(key) + local value = {text = 'hello'} + function value.read() return value.text end + local member = value[key] + value.extra = 1 -- Error: the dynamic key can retrieve read. + end + + ---@param key string + local function dictionary(key) + local value = {} + value[key] = function() return 'hello' end + local read = value[key] + value[1] = 'extra' -- Error: a function index entry also seals construction. + end + + local reassigned = {text = 'hello', read = function() return 'hello' end} + local key = 'text' + key = 'read' + local copied_key = key + local read = reassigned[copied_key] + reassigned.extra = 1 -- Error: a reassigned initializer cannot hide a function key. + "#, + ); +} + +#[test] +fn known_computed_data_reads_keep_construction_open() { + assert_contract( + r#" + local value = {text = 'hello'} + function value.read() return value.text end + local first_key = 'text' + local key = (first_key) + local text = value[key] + value.extra = 1 + + ---@param key integer + local function dictionary(key) + local value = {text = 'hello'} + value[key] = function() return 'hello' end + local field_key = 'text' + local text = value[field_key] + value.extra = 1 + end + "#, + ); +} + +#[test] +fn computed_reads_select_only_function_keys_in_their_domain() { + assert_contract( + r#" + ---@alias CountKey integer + ---@param key CountKey + local function numeric_data(key) + local value = {[1] = 'hello'} + function value.read() return 'hello' end + local text = value[key] + value.extra = 1 + return value + end + + ---@param key string + local function string_data(key) + local value = {text = 'hello', [1] = function() return 'hello' end} + local copied_key = key + local text = value[copied_key] + value.extra = 1 + return value + end + + ---@param text_key string + ---@param count_key integer + local function function_index(text_key, count_key) + local value = {text = 'hello'} + value[count_key] = function() return 'hello' end + local text = value[text_key] + value.extra = 1 + return value + end + + ---@param key string | integer + local function possible_function(key) + local value = {text = 'hello', [1] = function() return 'hello' end} + local read = value[key] + value.extra = 1 -- Error: an integer alternative can retrieve the function. + end + "#, + ); +} + +#[test] +fn array_writes_use_the_declared_element_type() { + assert_contract( + r#" + ---@type string[] + local values = {'hello'} + values[1] = 1 -- Error: the stable element type is string. + values[1] = 'updated' + values[1] = nil + + ---@param index integer + local function fill(index) + ---@type string[] + local values = {} + values[index] = 1 -- Error: an empty initializer keeps the declared element type. + values[index] = 'hello' + values[index] = nil + end + "#, + ); +} + +#[test] +fn computed_reads_combine_selected_fields_and_keep_missing_entries_nullable() { + assert_contract( + r#" + ---@alias DataKey 'count' | 'text' + ---@param key DataKey + ---@param missing_key 'count' | 'missing' + ---@param broad_key string + local function read(key, missing_key, broad_key) + local value = {count = 1, text = 'hello'} + ---@type integer | string + local result = value[key] + ---@type {selected: 1 | 'hello'} + local precise = {selected = value[key]} + ---@type boolean + local wrong = value[key] -- Error: both selected fields contain other types. + ---@type integer | nil + local optional = value[missing_key] + ---@type integer + local required = value[missing_key] -- Error: the missing alternative can return nil. + ---@type integer | string | nil + local broad = value[broad_key] + ---@type nil + local empty = value[broad_key] -- Error: broad_key can also select either data field. + ---@type integer | string + local present = value[broad_key] -- Error: a general string can select missing fields. + value.count = 2 + ---@type {selected: 1 | 'hello'} + local stale = {selected = value[key]} -- Error: count's initializer is no longer current. + value.extra = 1 + + local dynamic = {count = 1, text = 2} + local sealed = dynamic + dynamic[key] = 3 + ---@type {selected: 1 | 2} + local stale = {selected = dynamic[key]} -- Error: a computed write can change either field. + return result + end + + ---@param key 1 | 'text' + local function mixed_keys(key) + local value = {[1] = true, text = 'hello'} + ---@type boolean | string + local result = value[key] + return result + end + "#, + ); +} + +#[test] +fn reassigned_computed_keys_keep_only_safe_declared_domains() { + assert_contract( + r#" + ---@param key integer + local function numeric_data(key) + local value = {[1] = 'hello'} + function value.read() return 'hello' end + key = key + 1 + local text = value[key] + value.extra = 1 + return value + end + + ---@param key integer + local function cast_key(key) + local value = {[1] = 'hello'} + function value.read() return 'hello' end + ---@cast key 'read' + local read = value[key] + value.extra = 1 -- Error: the cast key can retrieve the function. + end + + local value = {text = 'hello', read = function() return 'hello' end} + ---@type string + local key = 'text' + key = 'read' + local read = value[key] + value.extra = 1 -- Error: assignment invalidates the exact initializer key. + "#, + ); +} + +#[test] +fn dynamic_construction_keeps_the_first_index_contract() { + assert_contract( + r#" + ---@param key string + local function build(key) + local value = {} + value[key] = 'hello' + value[key] = 'updated' + value[key] = 1 -- Error: the index signature is string. + ---@type table + local record = value + return record + end + "#, + ); +} + +#[test] +fn construction_uses_function_fields_and_existing_index_contracts() { + assert_contract( + r#" + local function read() return 'hello' end + local value = {read = read} + local callback = value.read + value.extra = 1 -- Error: retrieving a function seals the table. + + ---@param callback fun(): string + local function build(callback) + local value = {read = callback} + local read = value.read + value.extra = 1 -- Error: annotated function values also seal. + return value + end + + ---@alias Callback fun(): string + ---@param callback Callback + local function build_alias(callback) + local value = {read = callback} + local read = value.read + value.extra = 1 -- Error: a function alias has the same sealing rule. + return value + end + + local nil_field = {text = nil} + local text = nil_field.text + nil_field.text = 'hello' -- Error: nil is the established stable field type. + + local counted = {} + local count = #counted + counted.extra = 1 -- Error: a whole-table operation seals construction. + + ---@param key string + local function dictionary(key) + local value = {text = 'hello'} + value[key] = 1 -- Error: the key can select the existing string field. + return value + end + "#, + ); +} + +#[test] +fn construction_keeps_generic_alias_arguments_in_the_callers_file() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_file( + "aliases.lua", + r#" + ---@alias (file) Callback string + ---@alias Identity T + "#, + ); + let failures = check_contract_in( + &mut ws, + r#" + ---@alias (file) Callback fun(): string + ---@param callback Identity + local function build(callback) + local value = {read = callback} + local read = value.read + value.extra = 1 -- Error: Callback names the function type in this file. + return value + end + "#, + ); + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn construction_preserves_lua_assignment_results_and_dynamic_presence() { + assert_contract( + r#" + local function pair() return 'hello', 1 end + local value = {} + value.text, value.count = pair() + ---@type string + local text = value.text + ---@type integer + local count = value.count + ---@type {text: string, count: integer} + local record = value + value.count = 'one' -- Error: count's first value was an integer. + + ---@param key string + ---@param flag boolean + local function build(key, flag) + local value = {} + if flag then value[key] = 'hello' else value[key] = 1 end + value[key] = 'updated' + value[key] = 2 + ---@type table + local record = value + ---@type string | integer | nil + local maybe = value.other + ---@type string | integer + local required = value.other -- Error: an index signature does not guarantee presence. + return record + end + + ---@param key string + local function read(key) + local value = {text = 'hello'} + ---@type string | nil + local text = value[key] + value.count = 1 -- Reading a data field keeps construction open. + return text + end + "#, + ); +} + +#[test] +fn unused_early_exits_preserve_the_returned_table_shape() { + assert_contract( + r#" + ---@param stop boolean + local function build(stop) + local value = {} + if stop then + local alias = value + return nil + end + value.text = 'hello' + return value + end + local value = build(false) + if value then + ---@type {text: string} + local record = value + end + "#, + ); +} + +#[test] +fn returning_a_local_alias_preserves_the_returned_table_shape() { + assert_contract( + r#" + ---@param stop boolean + local function build(stop) + local value = {} + if stop then + local unused = value + return nil + end + value.text = 'hello' + local alias = value + local result = (alias) + return result + end + local value = build(false) + if value then + ---@type {text: string} + local record = value + end + "#, + ); +} + +#[test] +fn returned_table_shapes_keep_other_table_returns_and_deferred_bodies() { + assert_contract( + r#" + ---@param stop boolean + local function build(stop) + local value = {} + function value.read() + ---@type string + local text = value.text -- Error: an earlier sealing path has no text. + return text + end + if stop then + local alias = value + return nil + end + value.text = 'hello' + return value + end + local value = build(false) + if value then + ---@type {text: string} + local record = value + end + + ---@param stop boolean + local function maybe_text(stop) + local value = {} + if stop then return value end + value.text = 'hello' + return value + end + ---@type {text?: string} + local optional = maybe_text(false) + ---@type {text: string} + local required = maybe_text(false) -- Error: another path returns the empty table. + "#, + ); +} + +#[test] +fn tuple_writes_check_the_declared_slot_type() { + assert_contract( + r#" + ---@type [integer, string] + local value = {1, 'hello'} + value[1] = 2 + value[2] = 'updated' + value[1] = 'bad' -- Error: the first slot accepts integers. + value[2] = 3 -- Error: the second slot accepts strings. + value[1] = nil + "#, + ); +} + +#[test] +fn field_reads_follow_writes_and_rebinding() { + assert_contract( + r#" + ---@type {text?: string} + local value = {} + value.text = 'hello' + ---@type 'hello' + local first = value.text + value.text = 'updated' + ---@type 'updated' + local second = value.text + value = {} + ---@type string + local stale = value.text -- Error: rebinding discards the old field value. + "#, + ); +} + +#[test] +fn assignment_rhs_reads_fields_before_any_target_is_written() { + assert_contract( + r#" + local value = {a = 1, b = 0} + value.a, value.b = 2, value.a + ---@type 1 + local before = value.b + ---@type 2 + local after = value.a + ---@type 2 + local wrong = value.b -- Error: the RHS reads a before it becomes 2. + + value.a = 3 + value.a, value.b = 4, value.a + ---@type 3 + local previous = value.b + ---@type 4 + local current = value.a + "#, + ); +} + +#[test] +fn uncalled_functions_do_not_change_field_flow() { + assert_contract( + r#" + local value = {text = 'hello'} + value.text = 'current' + function value.change() + value.text = 'updated' + end + ---@type 'current' + local current = value.text + ---@type 'updated' + local wrong = value.text -- Error: change has not run. + value.text = 'after' + ---@type 'after' + local after = value.text + "#, + ); +} + +#[test] +fn field_writes_use_stable_types_and_casts() { + assert_contract( + r#" + ---@class MixedRecord + ---@field value string | integer + ---@type MixedRecord + local record + if record.value == 'ok' then + record.value = 1 + ---@type 1 + local current = record.value + end + + ---@class Box + ---@field value T + ---@type Box + local box + box.value = 'hello' + box.value = 1 -- Error: the stable field contract includes generic substitution. + + ---@type {text?: string} + local casted = {} + ---@cast casted.text 'ok' + casted.text = 'ok' + casted.text = 'bad' -- Error: the cast governs later writes too. + + local constructed = {} + ---@type 'ok' + constructed.text = 'bad' -- Error: the first write must fit its field annotation. + "#, + ); +} diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs index 78b46090d..66583daf0 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs @@ -14,6 +14,7 @@ mod closure_return_test; mod decl_test; mod diagnostic_disable_test; mod flow; +mod flow_contract; mod for_range_var_infer_test; mod generic_infer_test; mod generic_test; diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/return_overload_flow_test.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/return_overload_flow_test.rs index ee788ccb9..c89b438bf 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/return_overload_flow_test.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/return_overload_flow_test.rs @@ -176,7 +176,7 @@ mod test { "#, ); - assert_eq!(ws.expr_ty("f"), ws.ty("integer|string")); + assert_eq!(ws.expr_ty("f"), ws.ty("1|'override'")); } #[test] diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/test.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/test.rs index 739c0711f..e90e55a42 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/test.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/test.rs @@ -275,7 +275,7 @@ fn test_vm_name_binary() { #[test] fn test_vm_member_access() { - // VM:LoadName(t) + IndexMember(a) → Integer。 + // A field read keeps its assigned literal flow type. let env = model_of("local t = {}\nt.a = 5\nlocal y = t.a"); let model = env.model(); let y = model @@ -286,7 +286,7 @@ fn test_vm_member_access() { .expect("y"); let init = y.value_expr_syntax.expect("init"); let ty = infer_expr_vm(&model, init); - assert_eq!(ty, LuaType::Integer); + assert_eq!(ty, LuaType::IntegerConst(5)); } #[test] diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/vm.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/vm.rs index 4d5190b85..c99f85f3a 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/vm.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/vm.rs @@ -74,7 +74,7 @@ pub enum Instr { offset: rowan::TextSize, }, /// Pop owner (and dynamic key, if any), then push the member value. - IndexMember(IndexKey), + IndexMember(IndexKey, LuaSyntaxId), /// Pop callee + n args -> call inference (unify and substitute return). Call { arg_count: usize, @@ -125,7 +125,7 @@ impl Value { enum CTask { Expr(LuaExpr), AdjustToOne, - Index(IndexKey), + Index(IndexKey, LuaSyntaxId), Call { arg_count: usize, colon_call: bool, @@ -156,7 +156,7 @@ pub fn compile(expr: LuaExpr, file_id: FileId, out: &mut Vec) { match task { CTask::Expr(expr) => compile_expr(&expr, file_id, out, &mut stack), CTask::AdjustToOne => out.push(Instr::AdjustToOne), - CTask::Index(key) => out.push(Instr::IndexMember(key)), + CTask::Index(key, syntax) => out.push(Instr::IndexMember(key, syntax)), CTask::Call { arg_count, colon_call, @@ -275,13 +275,19 @@ fn compile_expr(expr: &LuaExpr, file_id: FileId, out: &mut Vec, stack: &m if let Some(prefix) = index_expr.get_prefix_expr() { if let Some(LuaIndexKey::Expr(key_expr)) = index_key { // Dynamic key: prefix, key, IndexMember(dynamic). - stack.push(CTask::Index(IndexKey::Dynamic(key_expr.get_syntax_id()))); + stack.push(CTask::Index( + IndexKey::Dynamic(key_expr.get_syntax_id()), + index_expr.get_syntax_id(), + )); stack.push(CTask::Expr(key_expr)); stack.push(CTask::Expr(prefix)); } else { // Preserve the unknown-member fallback for incomplete Lua syntax. let key = key.unwrap_or_else(|| LuaMemberKey::Name(SmolStr::new(""))); - stack.push(CTask::Index(IndexKey::Static(key))); + stack.push(CTask::Index( + IndexKey::Static(key), + index_expr.get_syntax_id(), + )); stack.push(CTask::Expr(prefix)); } } else { @@ -480,45 +486,34 @@ impl<'a> InferVm<'a> { let value = self.load_name(&name, offset); self.stack.push(value); } - Instr::IndexMember(IndexKey::Dynamic(syntax)) => { - debug_assert!( - self.stack.len() >= 2, - "dynamic index needs an owner and key" - ); - let mut key_value = self.stack.pop().unwrap_or(Value::plain(LuaType::Unknown)); - let owner_value = self.stack.pop().unwrap_or(Value::plain(LuaType::Unknown)); - // Query the key subtree, not the enclosing index. Results are cached - // by syntax/offset; ordinary inference also guards dependency cycles. - let flow_ty = self - .model - .type_of_expr_at(syntax, syntax.get_range().start()); - // Nil and Never can be valid narrowing results, not inference failures. - // Casts can also widen types, so a subtype-only replacement is incorrect. - if !matches!(flow_ty, LuaType::Unknown | LuaType::Any) { - key_value.ty = flow_ty; - } - key_value.ty = key_value.ty.get_result_slot_type(0).unwrap_or(LuaType::Nil); - let value = self.index_member_dynamic(owner_value, key_value); + Instr::IndexMember(key, syntax) => { + let key = match key { + IndexKey::Dynamic(key_syntax) => { + debug_assert!( + self.stack.len() >= 2, + "dynamic index needs an owner and key" + ); + let mut key = self.stack.pop().unwrap_or(Value::plain(LuaType::Unknown)); + // Refine the key subtree, never the enclosing member read. + let flow = self + .model + .type_of_expr_at(key_syntax, key_syntax.get_range().start()); + // Nil and Never are valid results; casts may also widen. + if !matches!(flow, LuaType::Unknown | LuaType::Any) { + key.ty = flow; + } + key.ty = key.ty.get_result_slot_type(0).unwrap_or(LuaType::Nil); + key + } + IndexKey::Static(key) => { + debug_assert!(!self.stack.is_empty(), "static index needs an owner"); + Value::plain(key.to_index_type().unwrap_or(LuaType::Unknown)) + } + }; + let owner = self.stack.pop().unwrap_or(Value::plain(LuaType::Unknown)); + let value = self.read_index(owner, key, syntax); self.stack.push(value); } - Instr::IndexMember(IndexKey::Static(key)) => { - debug_assert!(!self.stack.is_empty(), "static index needs an owner"); - let owner_value = self.stack.pop().unwrap_or(Value::plain(LuaType::Unknown)); - if let LuaMemberKey::Integer(_) = &key - && let LuaType::Array(array) = &owner_value.ty - { - let base = array.get_base().clone(); - let ty = if self.model.strict_array_index() { - LuaType::from_vec(vec![base, LuaType::Nil]) - } else { - base - }; - self.stack.push(Value::plain(ty)); - } else { - let value = self.index_member(owner_value, &key); - self.stack.push(value); - } - } Instr::Call { arg_count, colon_call, @@ -1030,6 +1025,53 @@ impl<'a> InferVm<'a> { None } + fn read_index(&mut self, owner: Value, key: Value, syntax: LuaSyntaxId) -> Value { + let Some(construction) = crate::semantic_model::table_construction::access_with_types( + self.model, + syntax, + &owner.ty, + Some(key.ty.clone()), + ) else { + return self.index_member_dynamic(owner, key); + }; + let mut ty = construction.read(); + if let Some(index) = self + .model + .syntax_tree() + .and_then(|tree| syntax.to_node_from_root(&tree.get_red_root())) + .and_then(emmylua_parser::LuaIndexExpr::cast) + { + ty = crate::semantic_model::flow::index_read_type( + self.model, + &index, + index.get_position(), + ty, + Some(&construction), + ); + } + // Keep the definition identity when flow selects a different function. + let member = if let LuaType::Signature(signature) = &ty { + self.model + .file_facts_of(signature.get_file_id()) + .and_then(|facts| { + facts + .signature_by_position(signature.get_position()) + .and_then(|signature| { + facts.member_by_value_syntax(signature.closure_syntax) + }) + }) + .map(|member| member.id.clone()) + } else { + None + }; + Value { + ty, + owner: member.or_else(|| construction.member_id()), + closure_syntax: None, + receiver: Some(owner.ty), + } + } + fn index_member(&mut self, owner: Value, key: &LuaMemberKey) -> Value { let mut value = self.index_member_impl(owner, key); // Member projection only substitutes arguments. Evaluate the result @@ -2686,6 +2728,11 @@ impl<'a> InferVm<'a> { ); } let generic_tpls = self.generic_tpls_with_metadata(file_id, generic_params); + for (_, ty) in &mut params { + if let Some(ty) = ty { + *ty = bind_signature_generics(ty, &generic_tpls); + } + } ret = bind_signature_generics(&ret, &generic_tpls); let async_state = if docs.is_async { AsyncState::Async @@ -4784,7 +4831,16 @@ pub(crate) fn bind_signature_generics(ty: &LuaType, generics: &[GenericTpl]) -> .collect(); LuaType::Object(Rc::new(LuaObjectType::new_with_fields( fields, - object.get_index_access().to_vec(), + object + .get_index_access() + .iter() + .map(|(key, value)| { + ( + bind_signature_generics(key, generics), + bind_signature_generics(value, generics), + ) + }) + .collect(), ))) } LuaType::Union(union) => LuaType::Union(Rc::new(LuaUnionType::from_vec( diff --git a/crates/emmylua_code_analysis/src/semantic_model/member.rs b/crates/emmylua_code_analysis/src/semantic_model/member.rs index 4b13f12f3..4fa7c4b38 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/member.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/member.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use emmylua_parser::{LuaAstNode, LuaDocFieldKey, LuaDocTagField, LuaTableExpr, LuaTableField}; -use hashbrown::HashMap; +use hashbrown::{HashMap, HashSet}; use crate::semantic_db::def::{DeclKind, MemberRef, SemanticId, TypeDef, TypeDefKind}; use crate::semantic_model::infer::unify::{self, TplBindings}; @@ -42,6 +42,33 @@ pub fn member_infos(model: &SemanticModel, prefix_type: &LuaType) -> Vec Vec { + let mut fields = Vec::new(); + let mut seen = HashSet::new(); + for mut info in member_infos_all(model, target) { + if let Some(id) = info.id.as_ref() { + let Some(member) = info + .file_id + .and_then(|file| model.file_facts_of(file)) + .and_then(|facts| facts.member_by_id(id)) + else { + continue; + }; + if !matches!(member.owner, SemanticId::TypeDef(_)) { + continue; + } + if member.is_nullable && !info.typ.is_nullable() { + info.typ = LuaType::from_vec(vec![info.typ, LuaType::Nil]); + } + } + if seen.insert((info.key.clone(), info.index_key_type.clone())) { + fields.push(info); + } + } + fields +} + /// All inherited and substituted members, before choosing one declaration per key. pub(super) fn member_infos_all(model: &SemanticModel, prefix_type: &LuaType) -> Vec { let mut out = Vec::new(); @@ -263,6 +290,14 @@ fn direct_member_info( ) } LuaType::TableConst(table) => { + if let Some(construction) = super::table_construction::of(model, table) + && construction.has_local_binding() + { + return construction + .members(&model.model_for(table.file_id), None, Some(key)) + .into_iter() + .find(|info| info.index_key_type.is_none()); + } let owner = SemanticId::member(table.file_id, table.value); if let Some(info) = find_member_in_owner(model, &owner, key, None, None) { return Some(info); @@ -697,6 +732,12 @@ fn collect_members( } // Anonymous table literal: field members of the synthesized owner (file, range). LuaType::TableConst(table) => { + if let Some(construction) = super::table_construction::of(model, table) + && construction.has_local_binding() + { + out.extend(construction.members(&model.model_for(table.file_id), None, filter)); + return; + } let owner = SemanticId::member(table.file_id, table.value); for member_ref in owner_member_refs(model, &owner, filter) { if let Some(info) = member_info_of(model, &member_ref, bindings, name_bindings) { @@ -749,14 +790,14 @@ fn collect_members( }, ); } - for (_key, ty) in object.get_index_access() { + for (key, ty) in object.get_index_access() { push_member_info( out, filter, MemberInfo { key: LuaMemberKey::Name("[index]".into()), typ: ty.clone(), - index_key_type: None, + index_key_type: Some(key.clone()), id: None, file_id: None, is_method: false, diff --git a/crates/emmylua_code_analysis/src/semantic_model/mod.rs b/crates/emmylua_code_analysis/src/semantic_model/mod.rs index 090e7542d..869f3e482 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/mod.rs @@ -3,6 +3,9 @@ pub mod flow; pub mod infer; pub mod member; pub mod render; +pub(crate) mod table_construction; +pub(crate) mod table_constructor; +pub(crate) mod table_value; pub mod type_check; pub mod type_eval; diff --git a/crates/emmylua_code_analysis/src/semantic_model/signature.rs b/crates/emmylua_code_analysis/src/semantic_model/signature.rs index 5c7d6b2f7..b165926dd 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/signature.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/signature.rs @@ -392,6 +392,8 @@ impl<'db> SemanticModel<'db> { { ty = expected_param_ty; } + // Rich object projection keeps names; restore the function's generic scope. + ty = infer::vm::bind_signature_generics(&ty, &generic_params); ty = type_eval::expand_alias_generic(self, &ty); // Do not evaluate conditional types here: before function generics are substituted, `T extends ...` would incorrectly take the false branch. // Call sites/diagnostics should call `eval_conditionals` after bindings are substituted. @@ -901,6 +903,8 @@ impl<'db> SemanticModel<'db> { }) }) .unwrap_or(LuaType::Any); + // Rich object projection keeps names; restore the function's generic scope. + ty = infer::vm::bind_signature_generics(&ty, &generic_params); ty = type_eval::expand_alias_generic(self, &ty); // See `type_of_signature`: do not evaluate conditional types before function generics are substituted. if nullable_params.iter().any(|n| n == name) && !ty.is_nullable() { diff --git a/crates/emmylua_code_analysis/src/semantic_model/table_construction.rs b/crates/emmylua_code_analysis/src/semantic_model/table_construction.rs new file mode 100644 index 000000000..cdb64beb5 --- /dev/null +++ b/crates/emmylua_code_analysis/src/semantic_model/table_construction.rs @@ -0,0 +1,1364 @@ +//! Stable shapes of fresh local tables, before and after construction seals. +//! +//! The plan records value sources before resolving their field types. Direct +//! function bodies can then use the sealed shape without making shape discovery +//! depend on inference of those same bodies. + +use std::rc::Rc; + +use emmylua_parser::{ + LuaAstNode, LuaBlock, LuaCallExpr, LuaClosureExpr, LuaComment, LuaDocTagAs, LuaDocTagCast, + LuaExpr, LuaIndexExpr, LuaIndexKey, LuaLiteralToken, LuaNameExpr, LuaStat, LuaSyntaxId, + LuaTableExpr, LuaVarExpr, +}; +use hashbrown::{HashMap, HashSet}; +use rowan::TextRange; + +use super::SemanticModel; +use super::member::MemberInfo; +use super::table_constructor::{TableConstructor, key_type, member_key, table_expr}; +use super::type_check::{check_type_subtype, is_compatible}; +use super::type_eval::expand_alias_branches; +use crate::semantic_db::query::TypeShellHead; +use crate::semantic_db::types::{LiteralShell, PrimitiveType, TypeCandidate, TypeShell}; +use crate::{DeclKind, InFiled, LuaMemberKey, LuaSignatureId, LuaType, SemanticId}; + +#[derive(Clone, Copy)] +enum FieldType { + Read, + Contract, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Source { + value: LuaSyntaxId, + slot: usize, + member: Option, +} + +#[derive(Clone, Debug)] +struct Field { + sources: Vec, + optional: bool, + reassigned: bool, +} + +// Previous key domains distinguish a later write from a first write on an +// independent branch. Resolve their types only after the syntax plan is built. +#[derive(Clone, Debug)] +struct Index { + key: LuaIndexKey, + source: Source, + previous: Vec, +} + +#[derive(Clone, Debug, Default)] +struct Shape { + fields: HashMap, + indexes: Vec, + sealed: bool, +} + +impl Shape { + fn write(&mut self, key: LuaIndexKey, source: Source) { + if let Some(key) = literal_key(&key) { + if let Some(field) = self.fields.get_mut(&key) { + field.reassigned = true; + if !self.sealed { + field.optional = false; + } + } else if !self.sealed { + self.fields.insert( + key, + Field { + sources: vec![source], + optional: false, + reassigned: false, + }, + ); + } + } else { + for field in self.fields.values_mut() { + field.reassigned = true; + } + if !self.sealed && !self.indexes.iter().any(|index| index.source == source) { + self.indexes.push(Index { + key, + source, + previous: self.indexes.iter().map(|index| index.key.clone()).collect(), + }); + } + } + } + + fn join(paths: Vec) -> Option { + let mut paths = paths.into_iter(); + let mut result = paths.next()?; + for path in paths { + for (key, field) in &mut result.fields { + if let Some(other) = path.fields.get(key) { + extend_unique(&mut field.sources, &other.sources); + field.optional |= other.optional; + field.reassigned |= other.reassigned; + } else { + field.optional = true; + } + } + for (key, mut field) in path.fields { + if !result.fields.contains_key(&key) { + field.optional = true; + result.fields.insert(key, field); + } + } + for index in path.indexes { + if !result.indexes.iter().any(|old| old.source == index.source) { + result.indexes.push(index); + } + } + result.sealed |= path.sealed; + } + Some(result) + } +} + +fn extend_unique(into: &mut Vec, from: &[T]) { + for item in from { + if !into.contains(item) { + into.push(item.clone()); + } + } +} + +// Selection keeps sources lazy. Only the final expanding constructor field +// needs its value type to determine which keys it supplies. +struct Entry<'a> { + key: LuaMemberKey, + domain: LuaType, + value: EntryValue<'a>, +} + +enum EntryValue<'a> { + Field(&'a Field), + Index(Vec<&'a Source>), + Expanded(LuaType), +} + +impl Entry<'_> { + fn value_type( + &self, + construction: &Construction, + model: &SemanticModel, + mode: FieldType, + ) -> LuaType { + match &self.value { + EntryValue::Field(field) => { + // Computed reads cannot trace one field path after a write. + let mode = if field.reassigned { + FieldType::Contract + } else { + mode + }; + construction.field_type(model, field, mode) + } + EntryValue::Index(sources) => LuaType::from_vec( + sources + .iter() + .map(|source| construction.source_type(model, source, FieldType::Contract)) + .collect(), + ), + EntryValue::Expanded(typ) => typ.clone(), + } + } + + fn into_info(self, construction: &Construction, model: &SemanticModel) -> MemberInfo { + let id = match &self.value { + EntryValue::Field(field) => field.sources[0].member.clone(), + _ => None, + }; + let is_method = id.as_ref().is_some_and(|id| { + model + .file_facts_of(construction.file_id) + .and_then(|facts| facts.member_by_id(id)) + .is_some_and(|member| member.is_method) + }); + MemberInfo { + typ: self.value_type(construction, model, FieldType::Contract), + index_key_type: matches!(self.key, LuaMemberKey::None).then_some(self.domain), + key: self.key, + id, + file_id: Some(construction.file_id), + is_method, + } + } +} + +pub(crate) struct Construction { + table: LuaTableExpr, + file_id: crate::FileId, + decl: Option, + snapshots: HashMap, + final_shape: Shape, + deferred_shape: Shape, + deferred_bodies: Vec, +} + +impl Construction { + pub(crate) fn has_local_binding(&self) -> bool { + self.decl.is_some() + } + + fn shape_at(&self, syntax: Option) -> &Shape { + syntax + .and_then(|syntax| self.snapshots.get(&syntax)) + .unwrap_or_else(|| { + if syntax.is_some_and(|syntax| { + self.deferred_bodies + .iter() + .any(|body| body.contains_range(syntax.get_range())) + }) { + &self.deferred_shape + } else { + &self.final_shape + } + }) + } + + fn source_type(&self, model: &SemanticModel, source: &Source, mode: FieldType) -> LuaType { + let model = model.model_for(self.file_id); + let model = &model; + let facts = model.file_facts(); + let member = source + .member + .as_ref() + .and_then(|id| facts?.member_by_id(id)); + if matches!(mode, FieldType::Contract) + && let Some(annotation) = member.and_then(|member| member.doc_type_syntax) + { + return model.doc_type_lua(annotation); + } + let node = model + .syntax_tree() + .and_then(|tree| source.value.to_node_from_root(&tree.get_red_root())); + let typ = if let Some(closure) = node.and_then(LuaClosureExpr::cast) + && self.decl.is_some() + { + // Keep the signature lazy until a call or structural check needs it. + LuaType::Signature(LuaSignatureId::from_closure(model.file_id(), &closure)) + } else { + model.type_of_expr_at(source.value, source.value.get_range().start()) + }; + let typ = typ + .get_result_slot_type(source.slot) + .unwrap_or(LuaType::Nil); + if matches!(mode, FieldType::Contract) { + model.member_contract_type(source.member.as_ref(), typ) + } else { + typ + } + } + + fn field_type(&self, model: &SemanticModel, field: &Field, mode: FieldType) -> LuaType { + let mut types: Vec<_> = field + .sources + .iter() + .map(|source| self.source_type(model, source, mode)) + .collect(); + if field.optional { + types.push(LuaType::Nil); + } + LuaType::from_vec(types) + } + + pub(crate) fn members( + &self, + model: &SemanticModel, + syntax: Option, + filter: Option<&LuaMemberKey>, + ) -> Vec { + self.member_sources(model, self.shape_at(syntax), filter) + .into_iter() + .map(|entry| entry.into_info(self, model)) + .collect() + } + + fn member_sources<'a>( + &'a self, + model: &SemanticModel, + shape: &'a Shape, + filter: Option<&LuaMemberKey>, + ) -> Vec> { + let mut entries = Vec::new(); + for (key, field) in &shape.fields { + if filter.is_none_or(|filter| filter == key) + && let Some(domain) = key.to_index_type() + { + entries.push(Entry { + key: key.clone(), + domain, + value: EntryValue::Field(field), + }); + } + } + let source_model = model.model_for(self.file_id); + for index in &shape.indexes { + let Ok(domain) = key_type(&source_model, index.key.clone()) else { + continue; + }; + if index.previous.iter().any(|previous| { + key_type(&source_model, previous.clone()) + .is_ok_and(|previous| is_compatible(model, &domain, &previous)) + }) { + continue; + } + if let Some(Entry { + value: EntryValue::Index(sources), + .. + }) = entries + .iter_mut() + .find(|entry| matches!(entry.value, EntryValue::Index(_)) && entry.domain == domain) + { + sources.push(&index.source); + } else { + entries.push(Entry { + key: LuaMemberKey::None, + domain, + value: EntryValue::Index(vec![&index.source]), + }); + } + } + // Only the final unkeyed field can supply entries beyond the syntax plan. + if filter.is_none_or(|key| matches!(key, LuaMemberKey::Integer(_))) + && self + .table + .get_fields_with_keys() + .last() + .is_some_and(|(_, key)| matches!(key, LuaIndexKey::Idx(_))) + { + let constructor = TableConstructor::new(&source_model, &self.table); + if let Some(Ok(expanded)) = constructor.field_entries().last() { + for (domain, value) in expanded { + let key = member_key(&domain).unwrap_or(LuaMemberKey::None); + if shape.fields.contains_key(&key) + || (!matches!(key, LuaMemberKey::None) + && filter.is_some_and(|filter| filter != &key)) + { + continue; + } + let typ = if matches!(key, LuaMemberKey::None) { + value.typ + } else { + model.member_contract_type(None, value.typ) + }; + entries.push(Entry { + key, + domain, + value: EntryValue::Expanded(typ), + }); + } + } + } + entries + } + + pub(crate) fn entries( + &self, + model: &SemanticModel, + syntax: Option, + ) -> Vec<(LuaType, LuaType)> { + self.member_sources(model, self.shape_at(syntax), None) + .into_iter() + .map(|entry| { + let typ = entry.value_type(self, model, FieldType::Contract); + (entry.domain, typ) + }) + .collect() + } +} + +/// The same key-domain rules govern reads, writes, and field diagnostics. +pub(crate) struct Access<'a, 'db> { + model: &'a SemanticModel<'db>, + construction: Rc, + syntax: Option, + key: Option, +} + +impl Access<'_, '_> { + fn shape(&self) -> &Shape { + self.construction.shape_at(self.syntax) + } + + fn field(&self) -> Option<&Field> { + self.shape().fields.get(&member_key(self.key.as_ref()?)?) + } + + fn constructor_value(&self) -> Option { + let key = member_key(self.key.as_ref()?)?; + let construction = &self.construction; + TableConstructor::new( + &self.model.model_for(construction.file_id), + &construction.table, + ) + .member_value(&key) + .ok() + .flatten() + .map(|value| value.typ) + } + + fn read_mode(&self) -> FieldType { + // Deferred function bodies read the sealed contracts. + if self.construction.decl.is_some() + && self + .syntax + .is_none_or(|syntax| !self.construction.snapshots.contains_key(&syntax)) + { + FieldType::Contract + } else { + FieldType::Read + } + } + + fn selects(&self, entry: &Entry<'_>) -> bool { + self.key + .as_ref() + .is_some_and(|key| key_domains_overlap(self.model, key, &entry.domain)) + } + + pub(crate) fn read(&self) -> LuaType { + if let Some(field) = self.field() { + return self + .construction + .field_type(self.model, field, self.read_mode()); + } + if let Some(typ) = self.constructor_value() { + return typ; + } + let Some(key) = &self.key else { + return LuaType::Unknown; + }; + if key.is_unknown() { + return LuaType::Unknown; + } + let mut types = Vec::new(); + let mut present_keys = Vec::new(); + for entry in self + .construction + .member_sources(self.model, self.shape(), None) + .into_iter() + .filter(|entry| self.selects(entry)) + { + let typ = entry.value_type(&self.construction, self.model, self.read_mode()); + if !matches!(entry.key, LuaMemberKey::None) && !typ.is_nullable() { + present_keys.push(entry.domain.clone()); + } + types.push(typ); + } + let covered = !present_keys.is_empty() + && expand_alias_branches(self.model, key).is_some_and(|key| { + !matches!(key, LuaType::Any | LuaType::Unknown) + && check_type_subtype(self.model, &key, &LuaType::from_vec(present_keys)) + .is_ok() + }); + // Required named fields prove presence. Index signatures only describe values. + if !covered { + types.push(LuaType::Nil); + } + LuaType::from_vec(types) + } + + pub(crate) fn has_exact_key(&self) -> bool { + self.key.as_ref().and_then(member_key).is_some() + } + + pub(crate) fn member_id(&self) -> Option { + self.field()?.sources.first()?.member.clone() + } + + pub(crate) fn has_field(&self) -> bool { + self.field().is_some() + || self.key.as_ref().is_none_or(|key| key.is_unknown()) + || self.construction.member_sources(self.model, self.shape(), None) + .iter().any(|entry| self.selects(entry)) + // A final expanded return can supply an explicit nil slot. + || (matches!(self.key.as_ref().and_then(member_key), Some(LuaMemberKey::Integer(_))) + && self.constructor_value().is_some()) + } + + /// None rejects the write. Accepted writes resolve contracts only when consumed. + pub(crate) fn write_targets(&self) -> Option + '_> { + let entries = self + .construction + .member_sources(self.model, self.shape(), None); + // Overlap selects contracts; coverage decides whether a sealed write is allowed. + if self.shape().sealed + && self.key.as_ref().is_some_and(|key| { + let domains = entries + .iter() + .map(|entry| entry.domain.clone()) + .collect::>(); + domains.is_empty() || !is_compatible(self.model, key, &LuaType::from_vec(domains)) + }) + { + return None; + } + Some( + entries + .into_iter() + .filter(|entry| self.selects(entry)) + .map(|entry| entry.value_type(&self.construction, self.model, FieldType::Contract)), + ) + } +} + +pub(crate) fn of(model: &SemanticModel, table: &InFiled) -> Option> { + let key = table.clone(); + if let Some(result) = model.cache.borrow().table_construction.get(&key) { + return result.clone(); + } + let model = model.model_for(table.file_id); + let result = build(&model, table.value).map(Rc::new); + model + .cache + .borrow_mut() + .table_construction + .insert(key, result.clone()); + result +} + +pub(crate) fn access<'a, 'db>( + model: &'a SemanticModel<'db>, + index: &LuaIndexExpr, +) -> Option> { + let mut prefix = index.get_prefix_expr()?; + while let LuaExpr::ParenExpr(paren) = &prefix { + prefix = paren.get_expr()?; + } + let typ = if let LuaExpr::NameExpr(name) = &prefix { + let decl = model.resolve_name(name.get_position())?; + model.type_of_decl_assign_target_at(&decl, name.get_position()) + } else { + model.type_of_expr_at(prefix.get_syntax_id(), prefix.get_position()) + }; + access_with_types( + model, + index.get_syntax_id(), + &typ, + index + .get_index_key() + .and_then(|key| key_type(model, key).ok()), + ) +} + +pub(crate) fn access_with_types<'a, 'db>( + model: &'a SemanticModel<'db>, + syntax: LuaSyntaxId, + prefix: &LuaType, + key: Option, +) -> Option> { + let LuaType::TableConst(table) = prefix else { + return None; + }; + let construction = of(model, table)?; + Some(Access { + syntax: (model.file_id() == construction.file_id).then_some(syntax), + key: key.map(super::table_constructor::normalize_key_type), + model, + construction, + }) +} + +fn build(model: &SemanticModel, range: TextRange) -> Option { + let facts = model.file_facts()?; + if facts.is_meta { + return None; + } + let tree = model.syntax_tree()?; + let table = tree + .get_red_root() + .covering_element(range) + .into_node() + .and_then(LuaTableExpr::cast)?; + let mut value = LuaExpr::TableExpr(table.clone()); + while let Some(parent) = value + .syntax() + .parent() + .and_then(emmylua_parser::LuaParenExpr::cast) + { + value = LuaExpr::ParenExpr(parent); + } + let decl = facts.decl_by_value_range(value.get_range()); + if decl.is_some_and(|decl| { + !matches!(decl.kind, DeclKind::Local { .. }) + || decl.doc_type_syntax.is_some() + || decl + .owner_syntax + .and_then(|owner| facts.type_def_by_owner_syntax(owner)) + .is_some() + }) { + return None; + } + // A nested constructor within an inferred local keeps its own initial + // structure. It does not start another construction phase through a field. + if decl.is_none() { + let parent = table + .syntax() + .parent()? + .ancestors() + .find_map(LuaTableExpr::cast)?; + of(model, &InFiled::new(model.file_id(), parent.get_range()))?; + } + let sources: HashMap<_, _> = facts + .members + .iter() + .filter_map(|member| Some((member.value_syntax?, member.id.clone()))) + .collect(); + let reassigned = tree + .get_red_root() + .descendants() + .filter_map(LuaStat::cast) + .flat_map(|stat| match stat { + LuaStat::AssignStat(stat) => stat.get_var_and_expr_list().0, + LuaStat::FuncStat(stat) => stat.get_func_name().into_iter().collect(), + _ => Vec::new(), + }) + .filter_map(|var| match var { + LuaVarExpr::NameExpr(name) => model.resolve_name(name.get_position()), + _ => None, + }) + .collect(); + let casted = tree + .get_red_root() + .descendants() + .filter_map(LuaDocTagCast::cast) + .filter_map(|cast| match cast.get_key_expr()? { + LuaExpr::NameExpr(name) => model.resolve_name(name.get_position()), + _ => None, + }) + .collect(); + let mut shape = Shape::default(); + for (field, key) in table.get_fields_with_keys() { + if let Some(value) = field.get_value_expr() { + shape.write( + key, + Source { + value: value.get_syntax_id(), + slot: 0, + member: sources.get(&value.get_syntax_id()).cloned(), + }, + ); + } + } + let mut construction = Construction { + table, + file_id: model.file_id(), + decl: decl.map(|decl| decl.id.clone()), + snapshots: HashMap::new(), + final_shape: shape.clone(), + deferred_shape: shape.clone(), + deferred_bodies: Vec::new(), + }; + construction.final_shape.sealed = true; + // Only a direct local initializer can grow fields. A nested constructor + // already has its stable constructor shape. + let Some(_) = decl else { + return Some(construction); + }; + let stat = construction + .table + .ancestors::() + .next()?; + if stat.descendants::().any(|tag| { + tag.get_parent::() + .and_then(|comment| comment.get_owner()) + .and_then(|owner| { + LuaExpr::cast(owner.syntax().clone()).or_else(|| owner.children::().last()) + }) + .and_then(|expr| table_expr(&expr)) + .is_some_and(|table| table == construction.table) + }) { + return None; + } + let block = stat.ancestors::().next()?; + let mut planner = Planner { + model, + construction, + sources, + reassigned, + casted, + exits: Vec::new(), + seals: Vec::new(), + returns: Vec::new(), + breaks: Vec::new(), + continues: Vec::new(), + }; + let statements = block + .children::() + .filter(|node| node.get_position() > stat.get_position()) + .collect(); + if let Some(shape) = planner.statements(statements, shape) { + planner.exits.push(shape); + } + // Deferred bodies can run at any sealing point. A caller receives only the + // shapes on paths that return this table, not unrelated aliases or uses. + let sealed_shape = Shape::join(planner.seals) + .or_else(|| Shape::join(planner.exits)) + .unwrap_or_default(); + planner.construction.deferred_shape = sealed_shape.clone(); + planner.construction.deferred_shape.sealed = true; + planner.construction.deferred_bodies = block + .descendants::() + .map(|closure| closure.get_range()) + .collect(); + planner.construction.final_shape = Shape::join(planner.returns).unwrap_or(sealed_shape); + planner.construction.final_shape.sealed = true; + Some(planner.construction) +} + +struct Planner<'a, 'db> { + model: &'a SemanticModel<'db>, + construction: Construction, + sources: HashMap, + reassigned: HashSet, + casted: HashSet, + exits: Vec, + seals: Vec, + returns: Vec, + breaks: Vec, + continues: Vec, +} + +impl Planner<'_, '_> { + fn seal(&mut self, shape: &mut Shape) { + if !shape.sealed { + shape.sealed = true; + self.seals.push(shape.clone()); + } + } + + fn source(&self, value: &LuaExpr) -> Source { + Source { + value: value.get_syntax_id(), + slot: 0, + member: self.sources.get(&value.get_syntax_id()).cloned(), + } + } + + fn is_function(&self, source: &Source) -> bool { + if self + .model + .syntax_tree() + .and_then(|tree| source.value.to_node_from_root(&tree.get_red_root())) + .and_then(LuaClosureExpr::cast) + .is_some() + { + return true; + } + // A copied or annotated function has no closure at the write. The shell + // query identifies its callable category without resolving bodies through + // this construction plan. + source + .member + .as_ref() + .and_then(|member| { + self.model + .analysis() + .member_type(self.model.file_id(), member.clone()) + }) + .is_some_and(|shell| { + self.model + .analysis() + .type_shell_heads(self.model.file_id(), &shell) + .iter() + .any(|head| { + matches!( + head.candidate, + TypeCandidate::Function(_) + | TypeCandidate::Primitive(PrimitiveType::Function) + ) + }) + }) + } + + fn is_owner(&self, expr: &LuaExpr) -> bool { + match expr { + LuaExpr::ParenExpr(paren) => paren.get_expr().is_some_and(|expr| self.is_owner(&expr)), + LuaExpr::NameExpr(name) => { + self.model.resolve_name(name.get_position()).as_ref() + == self.construction.decl.as_ref() + } + _ => false, + } + } + + /// Use literal initializer chains and declared primitive key domains only. + /// Full expression inference could re-enter this unfinished construction. + fn read_key(&self, key: &LuaIndexKey) -> Option { + let LuaIndexKey::Expr(mut expr) = key.clone() else { + return key_type(self.model, key.clone()).ok(); + }; + let tree = self.model.syntax_tree()?; + let facts = self.model.file_facts()?; + let mut visited = HashSet::new(); + let mut declared = None; + loop { + expr = match expr { + LuaExpr::ParenExpr(paren) => paren.get_expr()?, + LuaExpr::NameExpr(name) => { + let id = self.model.resolve_name(name.get_position())?; + if self.casted.contains(&id) || !visited.insert(id.clone()) { + return None; + } + let decl = facts.decl_by_id(&id)?; + if declared.is_none() + && (decl.doc_type_syntax.is_some() || matches!(decl.kind, DeclKind::Param)) + { + declared = self + .model + .analysis() + .decl_type(self.model.file_id(), id.clone()) + .and_then(|shell| shallow_key_type(self.model, &shell)); + } + if self.reassigned.contains(&id) + || !matches!(decl.kind, DeclKind::Local { is_iter: false, .. }) + || decl.multi_return_index.is_some_and(|slot| slot != 0) + { + return declared; + } + let Some(initializer) = decl.value_expr_syntax else { + return declared; + }; + LuaExpr::cast(initializer.to_node_from_root(&tree.get_red_root())?)? + } + LuaExpr::LiteralExpr(literal) => { + return match literal.get_literal()? { + LuaLiteralToken::String(value) => Some(LuaType::StringConst( + smol_str::SmolStr::new(value.get_value()).into(), + )), + LuaLiteralToken::Number(value) => { + key_type(self.model, LuaIndexKey::Integer(value)).ok() + } + _ => declared, + }; + } + _ => return declared, + }; + } + } + + fn never_returns(&self, call: &LuaCallExpr) -> bool { + let Some(mut prefix) = call.get_prefix_expr() else { + return false; + }; + while let LuaExpr::ParenExpr(paren) = prefix { + let Some(inner) = paren.get_expr() else { + return false; + }; + prefix = inner; + } + // Shells have no assignment flow. Only a stable named callee can prove + // that this statement cannot reach a later construction use. + let LuaExpr::NameExpr(name) = prefix else { + return false; + }; + let Some(id) = self.model.resolve_name(name.get_position()) else { + return false; + }; + if self.reassigned.contains(&id) || self.casted.contains(&id) { + return false; + } + let Some(shell) = self + .model + .analysis() + .decl_type(self.model.file_id(), id.clone()) + else { + return false; + }; + let never = |heads: Vec| { + !heads.is_empty() + && heads.iter().all( + |head| matches!(&head.candidate, TypeCandidate::Named(name) if name == "never"), + ) + }; + let heads = self + .model + .analysis() + .type_shell_heads(self.model.file_id(), &shell); + match heads.as_slice() { + [ + TypeShellHead { + candidate: TypeCandidate::Primitive(PrimitiveType::Function), + .. + }, + ] => { + // An exact closure has one signature. Read its shallow result + // slots directly, without selecting a call-query candidate. + let Some(tree) = self.model.syntax_tree() else { + return false; + }; + let closure = self + .model + .file_facts() + .and_then(|facts| facts.decl_by_id(&id)) + .and_then(|decl| decl.value_expr_syntax) + .and_then(|syntax| syntax.to_node_from_root(&tree.get_red_root())) + .and_then(LuaClosureExpr::cast); + closure + .and_then(|closure| { + self.model + .analysis() + .signature_returns(self.model.file_id(), closure.get_syntax_id()) + }) + .and_then(|slots| slots.into_iter().next()) + .is_some_and(|slot| { + never( + self.model + .analysis() + .type_shell_heads(self.model.file_id(), &slot), + ) + }) + } + candidates => { + !candidates.is_empty() + && candidates + .iter() + .all(|head| never(self.model.analysis().type_shell_return_heads(head))) + } + } + } + + fn snapshot(&mut self, syntax: LuaSyntaxId, shape: &Shape) { + self.construction.snapshots.insert(syntax, shape.clone()); + } + + fn snapshot_index(&mut self, index: &LuaIndexExpr, shape: &Shape) { + let snapshot = if let Some(key) = index.get_index_key().as_ref().and_then(literal_key) { + Shape { + fields: shape + .fields + .get(&key) + .map(|field| (key, field.clone())) + .into_iter() + .collect(), + indexes: shape.indexes.clone(), + sealed: shape.sealed, + } + } else { + shape.clone() + }; + self.construction + .snapshots + .insert(index.get_syntax_id(), snapshot); + } + + fn expressions(&mut self, expressions: impl IntoIterator, shape: &mut Shape) { + for expr in expressions { + self.expression(expr, shape, false); + } + } + + fn expression(&mut self, expr: LuaExpr, shape: &mut Shape, own_function: bool) { + let mut pending = vec![(expr, own_function)]; + while let Some((expr, exempt)) = pending.pop() { + match &expr { + LuaExpr::ClosureExpr(closure) => { + if !exempt + && closure + .descendants::() + .any(|name| self.is_owner(&LuaExpr::NameExpr(name))) + { + self.seal(shape); + } + } + LuaExpr::IndexExpr(index) + if index + .get_prefix_expr() + .is_some_and(|expr| self.is_owner(&expr)) => + { + self.snapshot_index(index, shape); + if let Some(key) = index.get_index_key() { + let read_key = self.read_key(&key); + let selects = + |domain: Option| { + read_key.as_ref().zip(domain.as_ref()).is_none_or( + |(key, domain)| key_domains_overlap(self.model, key, domain), + ) + }; + // An established field has its own contract. Dynamic + // writes must respect it, including when it is data. + let function_index = read_key + .as_ref() + .and_then(member_key) + .is_none_or(|key| !shape.fields.contains_key(&key)) + && shape.indexes.iter().any(|index| { + self.is_function(&index.source) + && selects(self.read_key(&index.key)) + }); + let reads_function = shape.fields.iter().any(|(key, field)| { + selects(key.to_index_type()) + && field.sources.iter().any(|source| self.is_function(source)) + }) || function_index; + if reads_function { + self.seal(shape); + } + if let LuaIndexKey::Expr(key) = key { + pending.push((key, false)); + } + } + } + LuaExpr::NameExpr(_) if self.is_owner(&expr) => { + self.seal(shape); + self.snapshot(expr.get_syntax_id(), shape); + } + _ => pending.extend( + expression_children(&expr) + .into_iter() + .map(|expr| (expr, false)) + .collect::>() + .into_iter() + .rev(), + ), + } + } + } + + fn write(&mut self, index: LuaIndexExpr, value: Option<(LuaExpr, usize)>, shape: &mut Shape) { + let direct = index + .get_prefix_expr() + .is_some_and(|expr| self.is_owner(&expr)); + if !direct { + if index + .descendants::() + .any(|name| self.is_owner(&LuaExpr::NameExpr(name))) + { + self.seal(shape); + } + self.snapshot_index(&index, shape); + return; + } + if let Some(LuaIndexKey::Expr(key)) = index.get_index_key() { + self.expression(key, shape, false); + } + self.snapshot_index(&index, shape); + if let (Some(key), Some((value, slot))) = (index.get_index_key(), value) { + let mut source = self.source(&value); + source.slot = slot; + let member = SemanticId::member(self.model.file_id(), index.get_range()); + if self + .model + .file_facts() + .is_some_and(|facts| facts.member_by_id(&member).is_some()) + { + source.member = Some(member); + } + shape.write(key, source); + } + } + + fn statements(&mut self, statements: Vec, mut shape: Shape) -> Option { + for stat in statements { + // Named casts preceding a statement end construction before the cast. + for cast in stat + .syntax() + .prev_sibling() + .into_iter() + .flat_map(|node| node.descendants().filter_map(LuaDocTagCast::cast)) + { + if cast.get_key_expr().is_some_and(|expr| self.is_owner(&expr)) { + self.seal(&mut shape); + } + } + match stat { + LuaStat::IfStat(stat) => { + self.expressions(stat.get_condition_expr(), &mut shape); + let mut paths = Vec::new(); + if let Some(block) = stat.get_block() + && let Some(path) = self.block(block, shape.clone()) + { + paths.push(path); + } + for clause in stat.get_else_if_clause_list() { + self.expressions(clause.get_condition_expr(), &mut shape); + if let Some(block) = clause.get_block() + && let Some(path) = self.block(block, shape.clone()) + { + paths.push(path); + } + } + if let Some(block) = + stat.get_else_clause().and_then(|clause| clause.get_block()) + { + if let Some(path) = self.block(block, shape) { + paths.push(path); + } + } else { + paths.push(shape); + } + shape = Shape::join(paths)?; + } + LuaStat::DoStat(stat) => { + if let Some(block) = stat.get_block() { + shape = self.block(block, shape)?; + } + } + LuaStat::WhileStat(stat) => { + let condition = stat.get_condition_expr(); + self.expressions(condition.clone(), &mut shape); + match condition.and_then(literal_truthiness) { + Some(false) => {} + Some(true) => { + shape = self.loop_block(stat.get_block(), shape, true, false)?; + } + None => { + shape = self.loop_block(stat.get_block(), shape, false, true)?; + } + } + } + LuaStat::ForStat(stat) => { + self.expressions(stat.get_iter_expr(), &mut shape); + shape = self.loop_block(stat.get_block(), shape, false, true)?; + } + LuaStat::ForRangeStat(stat) => { + self.expressions(stat.get_expr_list(), &mut shape); + shape = self.loop_block(stat.get_block(), shape, false, true)?; + } + LuaStat::RepeatStat(stat) => { + shape = self.loop_block(stat.get_block(), shape, true, true)?; + self.expressions(stat.get_condition_expr(), &mut shape); + } + LuaStat::CallExprStat(stat) => { + self.expressions(stat.children::(), &mut shape); + if stat + .get_call_expr() + .is_some_and(|call| call.is_error() || self.never_returns(&call)) + { + self.exits.push(shape); + return None; + } + } + LuaStat::AssignStat(stat) => { + let (vars, values) = stat.get_var_and_expr_list(); + for (i, value) in values.iter().enumerate() { + let own_function = matches!(vars.get(i), Some(LuaVarExpr::IndexExpr(index)) if index.get_prefix_expr().is_some_and(|prefix| self.is_owner(&prefix))); + self.expression(value.clone(), &mut shape, own_function); + } + for (i, var) in vars.into_iter().enumerate() { + match var { + LuaVarExpr::IndexExpr(index) => { + let value = + values.get(i).cloned().map(|value| (value, 0)).or_else(|| { + values + .last() + .cloned() + .map(|value| (value, i + 1 - values.len())) + }); + self.write(index, value, &mut shape) + } + LuaVarExpr::NameExpr(name) => { + if self.is_owner(&LuaExpr::NameExpr(name)) { + self.seal(&mut shape); + } + } + } + } + } + LuaStat::FuncStat(stat) => { + if let Some(LuaVarExpr::IndexExpr(index)) = stat.get_func_name() { + let own = index + .get_prefix_expr() + .is_some_and(|prefix| self.is_owner(&prefix)); + if let Some(closure) = stat.get_closure() { + self.expression(LuaExpr::ClosureExpr(closure.clone()), &mut shape, own); + self.write(index, Some((LuaExpr::ClosureExpr(closure), 0)), &mut shape); + } + } + } + LuaStat::ReturnStat(stat) => { + let expressions: Vec<_> = stat.get_expr_list().collect(); + let returns_table = expressions.iter().any(|expr| { + self.is_owner(expr) + || super::flow::stable_access_path(self.model, expr).is_some_and( + |(owner, keys)| { + Some(&owner) == self.construction.decl.as_ref() + && keys.is_empty() + }, + ) + }); + self.expressions(expressions, &mut shape); + if returns_table { + self.returns.push(shape.clone()); + } + self.exits.push(shape); + return None; + } + LuaStat::BreakStat(_) => { + self.breaks.push(shape); + return None; + } + LuaStat::ContinueStat(_) => { + self.continues.push(shape); + return None; + } + LuaStat::GotoStat(_) | LuaStat::LabelStat(_) => { + self.seal(&mut shape); + } + _ => self.expressions(stat.children::(), &mut shape), + } + } + Some(shape) + } + + fn block(&mut self, block: LuaBlock, shape: Shape) -> Option { + self.statements(block.children::().collect(), shape) + } + + fn loop_block( + &mut self, + block: Option, + shape: Shape, + entered: bool, + condition_can_exit: bool, + ) -> Option { + let old_breaks = std::mem::take(&mut self.breaks); + let old_continues = std::mem::take(&mut self.continues); + let mut paths = Vec::new(); + if !entered { + paths.push(shape.clone()); + } + if let Some(block) = block { + if let Some(shape) = self.block(block, shape) { + if condition_can_exit { + paths.push(shape); + } else { + self.exits.push(shape); + } + } + } else if condition_can_exit { + paths.push(shape); + } else { + self.exits.push(shape); + } + paths.append(&mut self.breaks); + if condition_can_exit { + paths.append(&mut self.continues); + } else { + self.exits.append(&mut self.continues); + } + self.breaks = old_breaks; + self.continues = old_continues; + Shape::join(paths) + } +} + +// Project primitive outer alternatives only, without resolving tables or functions. +fn shallow_key_type(model: &SemanticModel, shell: &TypeShell) -> Option { + let types: Option> = model + .analysis() + .type_shell_heads(model.file_id(), shell) + .into_iter() + .map(|head| { + Some(match head.candidate { + TypeCandidate::Primitive(PrimitiveType::String) => LuaType::String, + TypeCandidate::Primitive(PrimitiveType::Integer) => LuaType::Integer, + TypeCandidate::Primitive(PrimitiveType::Number) => LuaType::Number, + TypeCandidate::Primitive(PrimitiveType::Boolean) => LuaType::Boolean, + TypeCandidate::Primitive(PrimitiveType::Nil) => LuaType::Nil, + TypeCandidate::Literal(LiteralShell::String(value)) => { + LuaType::StringConst(value.into()) + } + TypeCandidate::Literal(LiteralShell::Integer(value)) => { + LuaType::IntegerConst(value) + } + TypeCandidate::Literal(LiteralShell::Float(bits)) => { + let value = f64::from_bits(bits); + crate::semantic_db::types::exact_integer(value) + .map(LuaType::IntegerConst) + .unwrap_or(LuaType::FloatConst(value)) + } + TypeCandidate::Literal(LiteralShell::Boolean(value)) => { + LuaType::BooleanConst(value) + } + TypeCandidate::Literal(LiteralShell::Nil) => LuaType::Nil, + _ => return None, + }) + }) + .collect(); + let types = types?; + (!types.is_empty()).then(|| LuaType::from_vec(types)) +} + +fn key_domains_overlap(model: &SemanticModel, left: &LuaType, right: &LuaType) -> bool { + if is_compatible(model, left, right) || is_compatible(model, right, left) { + return true; + } + let (Some(left), Some(right)) = ( + expand_alias_branches(model, left), + expand_alias_branches(model, right), + ) else { + return true; + }; + let alternatives = |typ| match typ { + LuaType::Union(union) => union.into_vec(), + typ => vec![typ], + }; + let left = alternatives(left); + let right = alternatives(right); + left.iter().any(|left| { + right + .iter() + .any(|right| is_compatible(model, left, right) || is_compatible(model, right, left)) + }) +} + +// Only literal conditions can prove that the loop enters or cannot exit. +fn literal_truthiness(mut expr: LuaExpr) -> Option { + loop { + expr = match expr { + LuaExpr::ParenExpr(paren) => paren.get_expr()?, + LuaExpr::LiteralExpr(literal) => { + return match literal.get_literal()? { + LuaLiteralToken::Bool(value) => Some(value.is_true()), + LuaLiteralToken::Nil(_) => Some(false), + LuaLiteralToken::String(_) | LuaLiteralToken::Number(_) => Some(true), + _ => None, + }; + } + _ => return None, + }; + } +} + +fn literal_key(key: &LuaIndexKey) -> Option { + match key { + LuaIndexKey::Expr(_) => None, + key => Some(match key { + LuaIndexKey::Idx(index) => LuaMemberKey::Integer(*index as i64), + LuaIndexKey::Integer(number) => match number.get_number_value() { + emmylua_parser::NumberResult::Int(value) => LuaMemberKey::Integer(value), + _ => return None, + }, + _ => LuaMemberKey::Name(key.get_path_part().into()), + }), + } +} + +// Calls and constructors contain expression lists and field wrappers between +// the expression and its operands. Stop at each operand; closures are handled +// by the caller without walking their bodies as construction statements. +fn expression_children(expr: &LuaExpr) -> Vec { + let mut pending: Vec<_> = expr + .syntax() + .children() + .collect::>() + .into_iter() + .rev() + .collect(); + let mut result = Vec::new(); + while let Some(node) = pending.pop() { + if let Some(expr) = LuaExpr::cast(node.clone()) { + result.push(expr); + } else { + pending.extend(node.children().collect::>().into_iter().rev()); + } + } + result +} diff --git a/crates/emmylua_code_analysis/src/semantic_model/table_constructor.rs b/crates/emmylua_code_analysis/src/semantic_model/table_constructor.rs new file mode 100644 index 000000000..e7966ce82 --- /dev/null +++ b/crates/emmylua_code_analysis/src/semantic_model/table_constructor.rs @@ -0,0 +1,256 @@ +//! Keys and values supplied by a table constructor, before later assignments. + +use emmylua_parser::{LuaAstNode, LuaExpr, LuaIndexKey, LuaTableExpr, NumberResult}; +use hashbrown::HashMap; + +use super::SemanticModel; +use super::type_check::TypeCheckFailReason; +use crate::{LuaMemberKey, LuaType, VariadicType}; + +pub(crate) struct TableConstructor { + fields: Vec, + members: Result, TypeCheckFailReason>, +} + +struct ConstructorField { + key: Result, + value: Result, + expr: Option, + expands: bool, +} + +/// A supplied value and the expression that produced it. +pub(crate) struct ConstructorValue<'a> { + pub typ: LuaType, + pub expr: Option<&'a LuaExpr>, +} + +impl TableConstructor { + pub(crate) fn new(model: &SemanticModel, table: &LuaTableExpr) -> Self { + let last_field = table.get_fields().last(); + let fields: Vec<_> = table + .get_fields_with_keys() + .into_iter() + .map(|(field, key)| ConstructorField { + expands: matches!(key, LuaIndexKey::Idx(_)) && last_field.as_ref() == Some(&field), + key: key_type(model, key), + value: model.require_complete(|| { + field.get_value_expr().map_or(LuaType::Nil, |value| { + model.type_of_expr_at(value.get_syntax_id(), value.get_position()) + }) + }), + expr: field.get_value_expr(), + }) + .collect(); + let members = + fields + .iter() + .enumerate() + .try_fold(HashMap::new(), |mut members, (index, field)| { + let key = field.key.as_ref().map_err(Clone::clone)?; + if !field.expands { + if let Some(key) = member_key(key) { + members.insert(key, index); + } + } + Ok(members) + }); + Self { fields, members } + } + + /// None means no field supplies this key. An explicit nil stays a value so + /// diagnostics can distinguish an absent field from an invalid field value. + /// An unfinished key or matching value remains an error, not proof of absence. + pub(crate) fn member_value( + &self, + key: &LuaMemberKey, + ) -> Result>, TypeCheckFailReason> { + let members = self.members.as_ref().map_err(Clone::clone)?; + if let Some(field) = self.fields.last().filter(|field| field.expands) + && let (Ok(LuaType::IntegerConst(start)), LuaMemberKey::Integer(index)) = + (&field.key, key) + && let Some(offset) = index + .checked_sub(*start) + .and_then(|offset| usize::try_from(offset).ok()) + { + let value = field.value.as_ref().map_err(Clone::clone)?; + if let Some(value) = expanded_slot_type(value, offset) { + return Ok(Some(ConstructorValue { + typ: value, + expr: field.expr.as_ref(), + })); + } + } + let Some(&index) = members.get(key) else { + return Ok(None); + }; + let value = self.fields[index].value.as_ref().map_err(Clone::clone)?; + Ok(Some(ConstructorValue { + typ: value.get_result_slot_type(0).unwrap_or(LuaType::Nil), + expr: self.fields[index].expr.as_ref(), + })) + } + + pub(crate) fn field_entries( + &self, + ) -> impl Iterator)>, TypeCheckFailReason>> + '_ + { + self.fields.iter().map(ConstructorField::entries) + } +} + +/// Keep absent slots out of the member set, and include nil when only some +/// return alternatives supply the requested slot. +fn expanded_slot_type(ty: &LuaType, offset: usize) -> Option { + if let LuaType::Union(union) = ty { + let mut types = Vec::new(); + let mut has_slot = false; + for ty in union.iter() { + let slot = expanded_slot_type(ty, offset); + has_slot |= slot.is_some(); + types.push(slot.unwrap_or(LuaType::Nil)); + } + return has_slot.then(|| LuaType::from_vec(types)); + } + if let LuaType::Variadic(variadic) = ty + && variadic.get_max_len().is_some_and(|len| offset >= len) + { + return None; + } + ty.get_result_slot_type(offset) +} + +/// Parentheses do not change the fields supplied by a constructor. +pub(crate) fn table_expr(expr: &LuaExpr) -> Option { + let mut expr = expr.clone(); + while let LuaExpr::ParenExpr(paren) = expr { + expr = paren.get_expr()?; + } + if let LuaExpr::TableExpr(table) = expr { + Some(table) + } else { + None + } +} + +pub(crate) fn static_member_key(model: &SemanticModel, key: &LuaIndexKey) -> Option { + member_key(&key_type(model, key.clone()).ok()?) +} + +pub(crate) fn member_key(key: &LuaType) -> Option { + match key { + LuaType::StringConst(name) | LuaType::DocStringConst(name) => { + Some(LuaMemberKey::Name(name.as_ref().clone())) + } + LuaType::IntegerConst(index) | LuaType::DocIntegerConst(index) => { + Some(LuaMemberKey::Integer(*index)) + } + _ => None, + } +} + +pub(crate) fn key_type( + model: &SemanticModel, + key: LuaIndexKey, +) -> Result { + let key = match key { + LuaIndexKey::Name(name) => { + LuaType::StringConst(smol_str::SmolStr::new(name.get_name_text()).into()) + } + LuaIndexKey::String(string) => { + LuaType::StringConst(smol_str::SmolStr::new(string.get_value()).into()) + } + LuaIndexKey::Idx(index) => LuaType::IntegerConst(index as i64), + LuaIndexKey::Integer(number) => match number.get_number_value() { + NumberResult::Int(value) => LuaType::IntegerConst(value), + NumberResult::Float(value) => LuaType::FloatConst(value), + _ => LuaType::Number, + }, + LuaIndexKey::Expr(expr) => model.require_complete(|| { + model.type_of_expr_at(expr.get_syntax_id(), expr.get_position()) + })?, + }; + Ok(normalize_key_type(key)) +} + +/// A key takes one result. Lua stores integral floating-point keys as integers. +pub(crate) fn normalize_key_type(key: LuaType) -> LuaType { + let key = key.get_result_slot_type(0).unwrap_or(LuaType::Nil); + match key { + LuaType::FloatConst(value) => { + crate::semantic_db::types::exact_integer(value).map_or(key, LuaType::IntegerConst) + } + key => key, + } +} + +impl ConstructorField { + /// A final unkeyed field expands its result sequence. Other fields take one value. + fn entries(&self) -> Result)>, TypeCheckFailReason> { + let actual = self.value.as_ref().map_err(Clone::clone)?; + if *actual == LuaType::Nil { + return Ok(Vec::new()); + } + let key = self.key.as_ref().map_err(Clone::clone)?; + let mut entries = Vec::new(); + let mut pending = vec![(0, actual, self.expands)]; + while let Some((offset, actual, expands)) = pending.pop() { + if expands { + match actual { + LuaType::Union(union) => { + pending.extend(union.iter().map(|ty| (offset, ty, true))); + continue; + } + LuaType::Variadic(variadic) => { + match variadic.as_ref() { + VariadicType::Multi(types) => { + pending.extend(types.iter().enumerate().map(|(index, ty)| { + (offset + index, ty, index + 1 == types.len()) + })); + } + VariadicType::Base(LuaType::Tuple(tuple)) => { + pending.extend( + tuple + .get_types() + .iter() + .enumerate() + .map(|(index, ty)| (offset + index, ty, false)), + ); + } + VariadicType::Base(ty) | VariadicType::Repeat(ty) => { + if *ty != LuaType::Nil { + entries.push(( + LuaType::Integer, + ConstructorValue { + typ: ty.clone(), + expr: self.expr.as_ref(), + }, + )); + } + } + } + continue; + } + _ => {} + } + } + let actual = actual.get_result_slot_type(0).unwrap_or(LuaType::Nil); + if actual == LuaType::Nil { + continue; + } + let key = if let LuaType::IntegerConst(start) = key { + LuaType::IntegerConst(start + offset as i64) + } else { + key.clone() + }; + entries.push(( + key, + ConstructorValue { + typ: actual, + expr: self.expr.as_ref(), + }, + )); + } + Ok(entries) + } +} diff --git a/crates/emmylua_code_analysis/src/semantic_model/table_value.rs b/crates/emmylua_code_analysis/src/semantic_model/table_value.rs new file mode 100644 index 000000000..fcb5a6ab8 --- /dev/null +++ b/crates/emmylua_code_analysis/src/semantic_model/table_value.rs @@ -0,0 +1,277 @@ +//! Supplied constructor values, or stable inferred fields of a table reference. +//! +//! Field flow affects field reads, not the structural type of the whole table. + +use emmylua_parser::{LuaAstNode, LuaCallExpr, LuaTableExpr}; +use hashbrown::HashSet; +use rowan::TextRange; + +use super::SemanticModel; +use super::member::{member_infos_all, member_infos_with_key_all, table_metatable_index_info}; +use super::table_constructor::{ConstructorValue, TableConstructor, member_key}; +use super::type_check::TypeCheckFailReason; +use crate::{InFiled, LuaMemberKey, LuaType, SemanticId}; + +pub(crate) struct TableValue<'db> { + model: SemanticModel<'db>, + identity: InFiled, + constructor: TableConstructor, + is_constructor: bool, + expression: Option, +} + +impl<'db> TableValue<'db> { + pub(crate) fn new( + model: &SemanticModel<'db>, + identity: &InFiled, + expression: Option<&InFiled>, + ) -> Result { + let model = model.model_for(identity.file_id); + let table = model + .syntax_tree() + .and_then(|tree| { + tree.get_red_root() + .covering_element(identity.value) + .into_node() + }) + .and_then(LuaTableExpr::cast) + .ok_or(TypeCheckFailReason::Unresolved)?; + // A literal inside a call contributes to the call's result. + let is_constructor = expression.is_some_and(|expr| { + expr.file_id == identity.file_id + && expr.value.contains_range(identity.value) + && !table + .ancestors::() + .any(|call| expr.value.contains_range(call.get_range())) + }); + let constructor = TableConstructor::new(&model, &table); + let expression = expression + .filter(|expr| expr.file_id == identity.file_id) + .and_then(|expr| { + let element = model + .syntax_tree()? + .get_red_root() + .covering_element(expr.value); + let node = match element { + rowan::NodeOrToken::Node(node) => node, + rowan::NodeOrToken::Token(token) => token.parent()?, + }; + Some(emmylua_parser::LuaSyntaxId::from_node(&node)) + }); + Ok(Self { + model, + identity: identity.clone(), + constructor, + is_constructor, + expression, + }) + } + + fn own_member_value( + &self, + key: &LuaMemberKey, + ) -> Result>, TypeCheckFailReason> { + if !self.is_constructor + && let Some(construction) = super::table_construction::of(&self.model, &self.identity) + { + return self.model.require_complete(|| { + construction + .members(&self.model, self.expression, Some(key)) + .into_iter() + .find(|info| info.index_key_type.is_none()) + .map(|info| ConstructorValue { + typ: info.typ, + expr: None, + }) + }); + } + let initial = self.constructor.member_value(key)?; + if self.is_constructor { + return Ok(initial); + } + let has_initial = initial.as_ref().is_some_and(|value| !value.typ.is_nil()); + let owner = SemanticId::member(self.identity.file_id, self.identity.value); + // Keep the constructor field's contract when it supplies a value. + // Otherwise, use the assignment that adds the field to this table. + let member = self.model.require_complete(|| { + member_infos_with_key_all( + &self.model, + &LuaType::TableConst(self.identity.clone()), + key, + ) + .into_iter() + .find(|info| { + info.id.as_ref().is_some_and(|id| { + self.model + .file_facts_of(self.identity.file_id) + .and_then(|facts| facts.member_by_id(id)) + .is_some_and(|member| { + if has_initial { + member.owner == owner + } else { + member.owner != owner + } + }) + }) + }) + })?; + let value = if has_initial { + initial + } else { + member + .as_ref() + .map(|info| ConstructorValue { + typ: info.typ.clone(), + expr: None, + }) + .or(initial) + }; + Ok(value.map(|value| ConstructorValue { + typ: self + .model + .member_contract_type(member.as_ref().and_then(|info| info.id.as_ref()), value.typ), + expr: None, + })) + } + + pub(crate) fn member_value( + &self, + key: &LuaMemberKey, + ) -> Result>, TypeCheckFailReason> { + let actual = self.own_member_value(key)?; + if self.is_constructor + || actual + .as_ref() + .is_some_and(|value| !value.typ.is_nullable()) + { + return Ok(actual); + } + // __index supplies reads of absent fields; it does not add dictionary entries. + let mut types = Vec::new(); + if let Some(value) = &actual { + push_present_types(&mut types, &value.typ); + } + let mut identity = self.identity.clone(); + let mut visited = HashSet::new(); + loop { + if !visited.insert(identity.clone()) { + return Err(TypeCheckFailReason::Unresolved); + } + let model = self.model.model_for(identity.file_id); + let index = model.require_complete(|| table_metatable_index_info(&model, &identity))?; + match index.map(|info| info.typ) { + Some(LuaType::TableConst(table)) => { + let prototype = Self::new(&self.model, &table, None)?; + let value = prototype.own_member_value(key)?; + if let Some(value) = value { + push_present_types(&mut types, &value.typ); + if !value.typ.is_nullable() { + break; + } + } + identity = table; + } + Some(LuaType::DocFunction(function)) => { + types.push( + function + .get_ret() + .get_result_slot_type(0) + .unwrap_or(LuaType::Nil), + ); + break; + } + Some(index) => { + types.push( + model + .require_complete(|| model.member_type(&index, key))? + .unwrap_or(LuaType::Nil), + ); + break; + } + None => { + if types.is_empty() { + return Ok(actual); + } + types.push(LuaType::Nil); + break; + } + } + } + Ok(Some(ConstructorValue { + typ: LuaType::from_vec(types), + expr: None, + })) + } + + pub(crate) fn field_entries( + &self, + ) -> impl Iterator), TypeCheckFailReason>> { + if !self.is_constructor + && let Some(construction) = super::table_construction::of(&self.model, &self.identity) + { + let entries = self + .model + .require_complete(|| construction.entries(&self.model, self.expression)); + return match entries { + Ok(entries) => entries + .into_iter() + .map(|(key, typ)| Ok((key, ConstructorValue { typ, expr: None }))) + .collect::>(), + Err(err) => vec![Err(err)], + } + .into_iter(); + } + let entries = self + .constructor + .field_entries() + .flat_map(|entries| match entries { + Ok(entries) => entries.into_iter().map(Ok).collect(), + Err(err) => vec![Err(err)], + }); + if self.is_constructor { + return entries.collect::>().into_iter(); + } + let mut keys = HashSet::new(); + let mut values = Vec::new(); + for entry in entries { + match entry { + Ok((key, value)) => { + if let Some(member) = member_key(&key) { + keys.insert(member); + } else { + values.push(Ok((key, value))); + } + } + Err(err) => values.push(Err(err)), + } + } + match self.model.require_complete(|| { + member_infos_all(&self.model, &LuaType::TableConst(self.identity.clone())) + .into_iter() + .map(|info| info.key) + }) { + Ok(members) => keys.extend(members), + Err(err) => values.push(Err(err)), + } + for key in keys { + match self.own_member_value(&key) { + Ok(Some(value)) if value.typ != LuaType::Nil => { + if let Some(key) = key.to_index_type() { + values.push(Ok((key, value))); + } + } + Ok(_) => {} + Err(err) => values.push(Err(err)), + } + } + values.into_iter() + } +} + +fn push_present_types(types: &mut Vec, typ: &LuaType) { + match typ { + LuaType::Union(union) => types.extend(union.iter().filter(|ty| !ty.is_nil()).cloned()), + LuaType::Nil => {} + typ => types.push(typ.clone()), + } +} diff --git a/crates/emmylua_code_analysis/src/semantic_model/type_check/comparison.rs b/crates/emmylua_code_analysis/src/semantic_model/type_check/comparison.rs index 8a60ea0b8..eca46e6b0 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/type_check/comparison.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/type_check/comparison.rs @@ -15,6 +15,7 @@ struct Comparison { source: LuaType, target: LuaType, strict_subtype: bool, + strict_literals: bool, } struct Node { @@ -23,6 +24,7 @@ struct Node { parents: HashSet, dependencies: Vec, queued: bool, + structural: bool, } #[derive(Default)] @@ -49,6 +51,7 @@ impl Comparisons { parents: HashSet::new(), dependencies: Vec::new(), queued: true, + structural: false, }); self.pending.push_back(id); id @@ -74,6 +77,13 @@ impl Comparisons { result } + /// A table constructor is being checked through its fields or entries. + pub(super) fn mark_structural(&mut self) { + if let Some(current) = self.current { + self.nodes[current].structural = true; + } + } + /// Only a cycle through data constructors can justify a recursive match. /// Alias forwarding and union alternatives alone provide no evidence. fn guarded_cycle(&self, key: usize) -> bool { @@ -91,9 +101,11 @@ impl Comparisons { let mut seen = HashSet::new(); let mut guarded_cycle = false; while let Some((previous, guarded)) = pending.pop() { - let comparison = &self.nodes[previous].key; - let guarded = - guarded || (is_structure(&comparison.source) && is_structure(&comparison.target)); + let node = &self.nodes[previous]; + let comparison = &node.key; + let guarded = guarded + || node.structural + || (is_structure(&comparison.source) && is_structure(&comparison.target)); if !seen.insert((previous, guarded)) { continue; } @@ -171,6 +183,7 @@ pub(super) fn check( source: source.clone(), target: target.clone(), strict_subtype: context.strict_subtype, + strict_literals: context.strict_literals, }; let comparisons = context.comparisons.clone(); if comparisons.borrow().current.is_some() { @@ -214,6 +227,7 @@ fn evaluate(context: &TypeCheckContext, id: usize, key: &Comparison) -> TypeChec } let mut current = TypeCheckContext { strict_subtype: key.strict_subtype, + strict_literals: key.strict_literals, ..context.clone() }; let result = super::check_comparison(&mut current, &key.source, &key.target); diff --git a/crates/emmylua_code_analysis/src/semantic_model/type_check/complex_type.rs b/crates/emmylua_code_analysis/src/semantic_model/type_check/complex_type.rs index e10bae24a..f5c78829e 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/type_check/complex_type.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/type_check/complex_type.rs @@ -82,7 +82,7 @@ pub fn check_complex_type_compact( LuaType::Table | LuaType::TableGeneric(_) | LuaType::Ref(_) | LuaType::Def(_) => Ok(()), _ => Err(context.mismatch(source, compact_type)), }, - LuaType::Object(source_object) if context.strict_subtype => match compact_type { + LuaType::Object(source_object) => match compact_type { LuaType::Object(compact_object) => { let mut checks = RequiredChecks::default(); for (key, expected) in compact_object.get_fields() { @@ -91,7 +91,7 @@ pub fn check_complex_type_compact( } checks.finish() } - LuaType::Tuple(tuple) => { + LuaType::Tuple(tuple) if context.strict_subtype => { let mut checks = RequiredChecks::default(); for (index, element_ty) in tuple.get_types().iter().enumerate() { let key = LuaMemberKey::Integer(index as i64 + 1); @@ -102,7 +102,7 @@ pub fn check_complex_type_compact( } checks.finish() } - LuaType::TableConst(_) => { + LuaType::TableConst(_) if context.strict_subtype => { let table_infos = context.require_complete(|| context.model.member_infos(compact_type))?; let mut checks = RequiredChecks::default(); @@ -120,14 +120,6 @@ pub fn check_complex_type_compact( LuaType::Table | LuaType::Ref(_) | LuaType::Def(_) | LuaType::Generic(_) => Ok(()), _ => Err(context.mismatch(source, compact_type)), }, - LuaType::Object(_) => match compact_type { - LuaType::Object(_) - | LuaType::Table - | LuaType::Ref(_) - | LuaType::Def(_) - | LuaType::Generic(_) => Ok(()), - _ => Err(context.mismatch(source, compact_type)), - }, LuaType::Call(source_call) => { // M0: call types such as keyof are not expanded (semantic has no member-key union). if let LuaType::Call(compact_call) = compact_type diff --git a/crates/emmylua_code_analysis/src/semantic_model/type_check/context.rs b/crates/emmylua_code_analysis/src/semantic_model/type_check/context.rs index 3549312a3..542b55f10 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/type_check/context.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/type_check/context.rs @@ -18,6 +18,10 @@ pub struct TypeCheckContext<'db> { pub model: &'db SemanticModel<'db>, /// Subtype proofs also check object fields and generic inheritance arguments. pub strict_subtype: bool, + /// Constructor entries must prove literal key and value types. + pub(super) strict_literals: bool, + /// Constructors inside the checked expression exclude later writes. + pub(super) source_range: Option>, pub(super) comparisons: std::rc::Rc>, } @@ -27,6 +31,8 @@ impl<'db> TypeCheckContext<'db> { detail, model, strict_subtype: false, + strict_literals: false, + source_range: None, comparisons: Default::default(), } } @@ -36,16 +42,7 @@ impl<'db> TypeCheckContext<'db> { &self, query: impl FnOnce() -> T, ) -> Result { - let revision = self.model.cache.borrow().revision(); - let result = query(); - let cache = self.model.cache.borrow(); - if cache.evaluation_exhausted { - Err(TypeCheckFailReason::TooComplex) - } else if cache.is_complete(revision) { - Ok(result) - } else { - Err(TypeCheckFailReason::Unresolved) - } + self.model.require_complete(query) } /// Build the failure reason for the current context: when `detail` is enabled, return human-readable type mismatch info. diff --git a/crates/emmylua_code_analysis/src/semantic_model/type_check/fail_reason.rs b/crates/emmylua_code_analysis/src/semantic_model/type_check/fail_reason.rs index 64946ba6b..c6448c7d4 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/type_check/fail_reason.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/type_check/fail_reason.rs @@ -1,5 +1,16 @@ //! Type-check failure reasons (detail layer beyond the boolean interface). +use crate::{InFiled, LuaType}; + +/// The constructor value that failed compatibility, without changing the check result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldMismatch { + pub location: InFiled, + pub source: LuaType, + pub target: LuaType, + pub reason: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum TypeCheckFailReason { TypeNotMatch, @@ -8,6 +19,8 @@ pub enum TypeCheckFailReason { /// Evaluation exceeded the shared resource budget. TooComplex, TypeNotMatchWithReason(String), + /// Keep the innermost field failure so recursive comparisons have stable details. + TypeNotMatchAtField(Box), } impl TypeCheckFailReason { @@ -17,7 +30,17 @@ impl TypeCheckFailReason { pub fn is_type_not_match(&self) -> bool { matches!( self, - TypeCheckFailReason::TypeNotMatch | TypeCheckFailReason::TypeNotMatchWithReason(_) + TypeCheckFailReason::TypeNotMatch + | TypeCheckFailReason::TypeNotMatchWithReason(_) + | TypeCheckFailReason::TypeNotMatchAtField(_) ) } + + pub fn reason(&self) -> Option<&str> { + match self { + Self::TypeNotMatchWithReason(reason) => Some(reason), + Self::TypeNotMatchAtField(field) => Some(&field.reason), + _ => None, + } + } } diff --git a/crates/emmylua_code_analysis/src/semantic_model/type_check/mod.rs b/crates/emmylua_code_analysis/src/semantic_model/type_check/mod.rs index a3865c6d5..6cb2d7cc8 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/type_check/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/type_check/mod.rs @@ -26,6 +26,7 @@ pub(crate) use complex_type::integer_index_applies; pub use context::TypeCheckContext; pub use fail_reason::TypeCheckFailReason; pub(crate) use func_type::function_param_pairs; +pub(crate) use simple_type::table_index_accepts; use crate::semantic_model::SemanticModel; use crate::semantic_model::type_eval::expand_alias_head; @@ -92,7 +93,13 @@ fn check_alternatives( for result in checks { match result { Ok(()) => return Ok(()), - Err(err) if err.is_type_not_match() => {} + Err(err) if err.is_type_not_match() => { + if matches!(err, TypeCheckFailReason::TypeNotMatchAtField(_)) + && matches!(failure, TypeCheckFailReason::TypeNotMatchWithReason(_)) + { + failure = err; + } + } Err(err) if err.is_unresolved() => failure = err, Err(err) => return Err(err), } @@ -110,6 +117,19 @@ pub fn check_type_detail( check_general_type_compact(&mut context, source, target) } +/// Check an expression without using later writes to validate its constructors. +pub(crate) fn check_type_at( + model: &SemanticModel, + source: &LuaType, + target: &LuaType, + range: rowan::TextRange, + detail: bool, +) -> TypeCheckResult { + let mut context = TypeCheckContext::new(model, detail); + context.source_range = Some(InFiled::new(model.file_id(), range)); + check_general_type_compact(&mut context, source, target) +} + /// Strict subtype check: for cases requiring precise union/object subtype relationships. pub fn check_type_subtype( model: &SemanticModel, @@ -271,6 +291,14 @@ fn check_general_type_compact_inner( // `compact` is an intersection: source satisfies all components. if let LuaType::Intersection(compact_intersection) = compact_type { + if matches!(source, LuaType::TableConst(_)) { + return check_all( + compact_intersection + .get_types() + .iter() + .map(|component| check_general_type_compact(context, source, component)), + ); + } if !matches!(source, LuaType::Intersection(_)) { return check_alternatives( context.mismatch(source, compact_type), diff --git a/crates/emmylua_code_analysis/src/semantic_model/type_check/simple_type.rs b/crates/emmylua_code_analysis/src/semantic_model/type_check/simple_type.rs index 661c5a2bc..fa583b139 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/type_check/simple_type.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/type_check/simple_type.rs @@ -1,6 +1,10 @@ //! Compatibility of base types and constants (fully ported from the old //! `semantic::type_check::simple_type`, with db access going through the semantic context). +use emmylua_parser::{LuaAstNode, LuaIndexKey}; + +use crate::semantic_model::table_constructor::{ConstructorValue, key_type}; +use crate::semantic_model::table_value::TableValue; use crate::{LuaMemberKey, LuaType, LuaTypeDeclId}; use super::context::TypeCheckContext; @@ -15,7 +19,67 @@ pub fn check_simple_type_compact( match source { LuaType::Unknown | LuaType::Any => return Ok(()), LuaType::Table | LuaType::TableConst(_) => { - if let (LuaType::TableConst(_), LuaType::Tuple(tuple)) = (source, compact_type) { + if let (LuaType::TableConst(table), LuaType::Array(array)) = (source, compact_type) { + return check_table_entries(context, table, &LuaType::Integer, array.get_base()); + } + if let (LuaType::TableConst(table), LuaType::Generic(generic)) = (source, compact_type) + && generic.get_base_type_id() == LuaTypeDeclId::global("table") + && context + .type_def_of(&generic.get_base_type_id()) + .is_none_or(|def| context.model.is_std_file(def.file_id)) + && let [key_type, value_type] = generic.get_params().as_slice() + { + return check_table_entries(context, table, key_type, value_type); + } + if let LuaType::TableConst(table) = source { + match compact_type { + LuaType::Object(object) => { + return check_table_record( + context, + table, + object + .get_fields() + .iter() + .map(|(key, ty)| (key.clone(), ty.clone())) + .collect(), + object.get_index_access(), + ); + } + LuaType::Ref(_) | LuaType::Def(_) | LuaType::Generic(_) => { + let id = match compact_type { + LuaType::Ref(id) | LuaType::Def(id) => id.clone(), + LuaType::Generic(generic) => generic.get_base_type_id(), + _ => unreachable!(), + }; + if context + .type_def_of(&id) + .is_some_and(|def| def.kind == crate::TypeDefKind::Class) + { + let members = context.require_complete(|| { + crate::semantic_model::member::class_field_infos( + context.model, + compact_type, + ) + })?; + let mut fields = Vec::new(); + let mut indexes = Vec::new(); + for info in members { + if let Some(key) = info.index_key_type { + indexes.push((key, info.typ)); + } else { + fields.push((info.key, info.typ)); + } + } + return check_table_record(context, table, fields, &indexes); + } + } + _ => {} + } + } + if let (LuaType::TableConst(table), LuaType::Tuple(tuple)) = (source, compact_type) { + context.comparisons.borrow_mut().mark_structural(); + let table_file = table.file_id; + let table = TableValue::new(context.model, table, context.source_range.as_ref())?; return check_all( tuple .get_types() @@ -23,23 +87,15 @@ pub fn check_simple_type_compact( .enumerate() .map(|(index, expected)| { let key = LuaMemberKey::Integer(index as i64 + 1); - let actual = context - .require_complete(|| context.model.member_type(source, &key))? - .unwrap_or(LuaType::Nil); - super::check_general_type_compact(context, &actual, expected) + check_constructor_value( + context, + table_file, + table.member_value(&key)?, + expected, + ) }), ); } - if let (LuaType::TableConst(_), LuaType::Object(object)) = (source, compact_type) - && context.strict_subtype - { - return check_all(object.get_fields().iter().map(|(key, expected)| { - let actual = context - .require_complete(|| context.model.member_type(source, key))? - .unwrap_or(LuaType::Nil); - super::check_general_type_compact(context, &actual, expected) - })); - } if matches!( compact_type, LuaType::Table @@ -96,6 +152,7 @@ pub fn check_simple_type_compact( LuaType::String | LuaType::StrTplRef(_) | LuaType::Language(_) => return Ok(()), LuaType::StringConst(_) | LuaType::DocStringConst(_) if !context.strict_subtype + && !context.strict_literals && context .model .db() @@ -141,6 +198,7 @@ pub fn check_simple_type_compact( LuaType::Integer => return Ok(()), LuaType::IntegerConst(_) | LuaType::DocIntegerConst(_) if !context.strict_subtype + && !context.strict_literals && context .model .db() @@ -203,6 +261,116 @@ pub fn check_simple_type_compact( Err(context.mismatch(source, compact_type)) } +fn check_table_record( + context: &mut TypeCheckContext, + table: &crate::InFiled, + fields: Vec<(LuaMemberKey, LuaType)>, + indexes: &[(LuaType, LuaType)], +) -> TypeCheckResult { + context.comparisons.borrow_mut().mark_structural(); + let mut context = TypeCheckContext { + strict_literals: true, + ..context.clone() + }; + let table_file = table.file_id; + let table = TableValue::new(context.model, table, context.source_range.as_ref())?; + let field_checks = check_all(fields.iter().map(|(key, expected)| { + let actual = table.member_value(key)?; + check_constructor_value(&mut context, table_file, actual, expected) + })); + if indexes.is_empty() { + return field_checks; + } + let index_checks = check_all(table.field_entries().map(|entry| { + let (key, actual) = entry?; + if fields + .iter() + .any(|(field, _)| field.to_index_type().as_ref() == Some(&key)) + { + return Ok(()); + } + let mut checks = super::RequiredChecks::default(); + for (index, expected) in indexes { + match super::check_general_type_compact(&mut context, &key, index) { + Ok(()) => { + let expected = LuaType::from_vec(vec![expected.clone(), LuaType::Nil]); + return check_constructor_value( + &mut context, + table_file, + Some(actual), + &expected, + ); + } + Err(err) if err.is_type_not_match() => {} + Err(err) => checks.require(Err(err))?, + } + } + checks.finish() + })); + check_all([field_checks, index_checks]) +} + +fn check_constructor_value( + context: &mut TypeCheckContext, + file_id: crate::FileId, + actual: Option>, + expected: &LuaType, +) -> TypeCheckResult { + let source = actual.as_ref().map_or(&LuaType::Nil, |value| &value.typ); + let result = super::check_general_type_compact(context, source, expected); + if context.detail + && let Some(expr) = actual.as_ref().and_then(|value| value.expr) + && let Err(super::TypeCheckFailReason::TypeNotMatchWithReason(reason)) = &result + { + return Err(super::TypeCheckFailReason::TypeNotMatchAtField(Box::new( + super::fail_reason::FieldMismatch { + location: crate::InFiled::new(file_id, expr.get_range()), + source: source.clone(), + target: expected.clone(), + reason: reason.clone(), + }, + ))); + } + result +} + +/// Use the constructor's key rules for field diagnostics as well as compatibility. +pub(crate) fn table_index_accepts( + model: &crate::semantic_model::SemanticModel, + index: &LuaType, + key: &LuaIndexKey, +) -> bool { + let mut context = TypeCheckContext::new(model, false); + context.strict_literals = true; + let result = key_type(model, key.clone()) + .and_then(|key| super::check_general_type_compact(&mut context, &key, index)); + !result.is_err_and(|err| err.is_type_not_match()) +} + +/// Check table entries through the shared compatibility relation. +/// The shared comparison queue handles aliases, unions, and recursive values. +fn check_table_entries( + context: &mut TypeCheckContext, + table: &crate::InFiled, + key_type: &LuaType, + value_type: &LuaType, +) -> TypeCheckResult { + let mut context = TypeCheckContext { + strict_literals: true, + ..context.clone() + }; + let table_file = table.file_id; + let table = TableValue::new(context.model, table, context.source_range.as_ref())?; + context.comparisons.borrow_mut().mark_structural(); + // A nil value removes the entry, including a nil alternative of a union. + let value_type = LuaType::from_vec(vec![value_type.clone(), LuaType::Nil]); + check_all(table.field_entries().map(|entry| { + let (key, actual) = entry?; + super::check_general_type_compact(&mut context, &key, key_type)?; + check_constructor_value(&mut context, table_file, Some(actual), &value_type) + })) +} + /// Match primitive values against named base types and their descendants. fn check_base_type_for_ref_compact( context: &mut TypeCheckContext, diff --git a/crates/emmylua_code_analysis/src/semantic_model/type_eval.rs b/crates/emmylua_code_analysis/src/semantic_model/type_eval.rs index a27ced9c9..bb1729cce 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/type_eval.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/type_eval.rs @@ -350,13 +350,16 @@ pub fn expand_mapped(model: &SemanticModel, mapped: &LuaMappedType) -> Option Option { .and_then(|id| model.type_def_of(&id)) .filter(|def| def.kind == TypeDefKind::Alias); if let Some(def) = def { - let target = model.alias_target(&def)?; + let target = + simplify_recursive_arguments(model, &def, model.alias_target(&def)?); let (bindings, names) = match ¤t { LuaType::Generic(generic) => ( generic @@ -281,6 +282,103 @@ pub(super) fn expand(model: &SemanticModel, ty: &LuaType) -> Option { value } +/// Stabilize unused recursive arguments without changing arguments that affect data. +fn simplify_recursive_arguments(model: &SemanticModel, def: &TypeDef, target: LuaType) -> LuaType { + if def.generic_params.is_empty() { + return target; + } + let id = def.type_decl_id(); + let parameter_index = |ty: &LuaType| { + let name = match ty { + LuaType::TplRef(tpl) => tpl.get_name(), + LuaType::StrTplRef(tpl) => tpl.get_name(), + LuaType::Ref(id) | LuaType::Def(id) => id.get_name(), + _ => return None, + }; + def.generic_params + .iter() + .position(|param| param.name == name) + }; + let mut used = vec![false; def.generic_params.len()]; + loop { + let previous = used.clone(); + let mut pending = vec![(&target, false)]; + let mut recursive = false; + while let Some((ty, guarded)) = pending.pop() { + if let LuaType::Generic(generic) = ty + && generic.get_base_type_id() == id + { + // Forwarding cycles and incomplete argument lists retain their + // original evaluation and default-argument behavior. + if !guarded || generic.get_params().len() != used.len() { + return target; + } + recursive = true; + for (argument, used) in generic.get_params().iter().zip(&used) { + if *used { + pending.push((argument, guarded)); + } + } + continue; + } + if let Some(index) = parameter_index(ty) { + used[index] = true; + } + let guarded = guarded + || matches!( + ty, + LuaType::Object(_) + | LuaType::Array(_) + | LuaType::Tuple(_) + | LuaType::TableGeneric(_) + | LuaType::DocFunction(_) + ) + || matches!(ty, LuaType::Generic(generic) + if generic.get_base_type_id() == crate::LuaTypeDeclId::global("table") + && model.type_def_of(&generic.get_base_type_id()) + .is_none_or(|def| model.is_std_file(def.file_id))); + let mut children = Vec::new(); + ty.push_direct_children(&mut children); + // Function constraints can also refer to the alias parameters. + if let LuaType::DocFunction(function) = ty { + for param in function.get_generic_params() { + children.extend(param.get_constraint()); + children.extend(param.get_default_type()); + } + } + pending.extend(children.into_iter().map(|child| (child, guarded))); + } + if !recursive || used.iter().all(|used| *used) { + return target; + } + if used == previous { + break; + } + // A parameter used by a field can depend on another parameter through + // a recursive argument, such as Tree -> Tree. + } + map_type(&target, &mut |ty| match ty { + LuaType::Generic(generic) if generic.get_base_type_id() == id => { + Some(LuaType::Generic(Rc::new(LuaGenericType::new( + id.clone(), + generic + .get_params() + .iter() + .zip(&used) + .map(|(argument, used)| { + if *used { + argument.clone() + } else { + LuaType::Unknown + } + }) + .collect(), + )))) + } + _ => None, + }) +} + pub(super) fn needs_reduction(model: &SemanticModel, ty: &LuaType) -> bool { let id = match ty { LuaType::Ref(id) | LuaType::Def(id) => id.clone(), diff --git a/crates/emmylua_code_analysis/src/semantic_model/types.rs b/crates/emmylua_code_analysis/src/semantic_model/types.rs index 9db132287..160b18af2 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/types.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/types.rs @@ -1336,14 +1336,89 @@ impl<'db> SemanticModel<'db> { target } } - /// Member assignment contract, keeping explicit field annotations exact. - pub(crate) fn member_assignment_type(&self, member: &SemanticId, target: LuaType) -> LuaType { + /// Stable contracts for a field write. Read guards and previous field values + /// do not change these contracts; named casts are applied after widening. + pub(crate) fn index_assignment_targets(&self, index: &LuaIndexExpr) -> Option> { + let mut targets = if let Some(construction) = super::table_construction::access(self, index) + { + construction + .write_targets() + .map(|targets| targets.collect()) + .unwrap_or_default() + } else { + let prefix = index.get_prefix_expr()?; + let prefix_type = if let LuaExpr::NameExpr(name) = &prefix { + let decl = self.resolve_name(name.get_position())?; + self.type_of_decl_assign_target_at(&decl, name.get_position()) + } else { + self.type_of_expr_at(prefix.get_syntax_id(), prefix.get_position()) + }; + if let LuaType::Array(array) = &prefix_type { + // Elements can be deleted, but other writes must match the array's base type. + vec![LuaType::from_vec(vec![ + array.get_base().clone(), + LuaType::Nil, + ])] + } else { + let key = super::table_constructor::member_key( + &super::table_constructor::key_type(self, index.get_index_key()?).ok()?, + )?; + let target = self + .member_infos_with_key(&prefix_type, &key) + .into_iter() + .next() + .map(|info| match info.id { + Some(id) => self.member_contract_type(Some(&id), info.typ), + None => info.typ, + }) + .or_else(|| { + let member = self.resolve_member(index)?.member_id?; + Some( + self.member_contract_type(Some(&member), self.type_of_member(&member)?), + ) + })?; + // Class/module nil placeholders do not establish a value contract. + if target.is_nil() { + return Some(Vec::new()); + } + // Tuple elements can be deleted, but other writes must match their slot. + vec![if matches!(prefix_type, LuaType::Tuple(_)) { + LuaType::from_vec(vec![target, LuaType::Nil]) + } else { + target + }] + } + }; + if let Some(annotation) = self + .resolve_member(index) + .and_then(|resolved| resolved.member_id) + .and_then(|id| self.file_facts()?.member_by_id(&id)) + .filter(|member| !matches!(member.owner, SemanticId::TypeDef(_))) + .and_then(|member| member.doc_type_syntax) + { + targets.push(self.doc_type_lua(annotation)); + } + Some( + targets + .into_iter() + .map(|target| flow::index_assignment_type(self, index, target)) + .collect(), + ) + } + + /// Stable field type for writes and whole-table checks. Inferred literals + /// widen; explicit field annotations keep their declared types. + pub(crate) fn member_contract_type( + &self, + member: Option<&SemanticId>, + target: LuaType, + ) -> LuaType { let inferred = match member { - SemanticId::Member(key) => self.file_facts_of(key.file_id), + Some(SemanticId::Member(key)) => self.file_facts_of(key.file_id), _ => None, } - .and_then(|facts| facts.member_by_id(member)) - .is_some_and(|member| { + .and_then(|facts| facts.member_by_id(member?)) + .is_none_or(|member| { member.doc_type_syntax.is_none() && !matches!(member.owner, SemanticId::TypeDef(_)) }); if inferred { @@ -1593,6 +1668,9 @@ fn project_base_type(model: &SemanticModel<'_>, def: &TypeDef, doc_type: LuaDocT _ => {} } } + if matches!(doc_type, LuaDocType::Object(_)) { + return model.doc_type_lua_rich_in(def.file_id, doc_type.get_syntax_id()); + } model.doc_type_lua_in(def.file_id, doc_type.get_syntax_id(), &def.generic_params) } diff --git a/docs/emmylua_code_analysis/flow-typing.md b/docs/emmylua_code_analysis/flow-typing.md new file mode 100644 index 000000000..dd5c267b4 --- /dev/null +++ b/docs/emmylua_code_analysis/flow-typing.md @@ -0,0 +1,544 @@ +# Flow typing + +Flow typing uses assignments and conditions to determine the type of a value at +a particular read. For example, a value declared as `string | nil` can be read as +`string` after a nil check. + +EmmyLua uses +[TypeScript-like control-flow analysis](https://www.typescriptlang.org/docs/handbook/2/narrowing.html). +Assignments and guards refine types at reads; stable types govern assignments. + +## Type model + +The analyzer distinguishes two types for a variable or field: + +- **Stable type**: The type that determines which values can be assigned. + It comes from an annotation or, without one, from inference over the code. +- **Flow type**: The type used for a read at a specific point in the code. + It reflects assignments and guards on the paths that can reach that read. + +```lua +---@type {text?: string} +local value = {} +value.text = 'hello' + +---@type string +local text = value.text -- Accepted: the field's flow type is 'hello'. + +---@type {text: string} +local record = value -- Error: value's stable type is {text?: string}. +``` + +The write narrows the flow type of `value.text`, but `value` keeps its +stable type `{text?: string}`. When checking `value` as a whole table, +`text` is therefore still optional. + +For a fresh inferred table, [construction](#table-construction) establishes its +stable fields and index signatures. Sealing the table fixes that structure. + +## Variables and control flow + +### Assignments + +Assigning a new value to a variable updates its flow type. Its stable type +stays the same and determines which assignments are allowed: + +```lua +---@type string | integer +local value = 1 -- Stable type: string | integer. +value = 'hello' -- Allowed; flow type is now 'hello'. +value = 2 -- Allowed; flow type is now 2. +value = true -- Error: boolean is outside the stable type. +``` + +Without an annotation, the stable type widens primitive literals to their base +types. This lets a mutable variable or field accept other values of the same type: + +```lua +local count = 1 -- Stable type: integer; flow type: 1. +count = 2 -- Stable type: integer; flow type: 2. +count = 'two' -- Error: string is outside the stable type. +``` + +| Inferred literal | Stable type | +| --- | --- | +| Integer, such as `1` | `integer` | +| Float, such as `1.5` | `number` | +| String, such as `'hello'` | `string` | +| `true` or `false` | `boolean` | +| `nil` | `nil` (unchanged) | + +An explicit annotation keeps its declared type; for example, `---@type 1` permits +only `1`. + +Assignment flow keeps the assigned type, including literals and unions of +literals. Self-dependent arithmetic such as `count = count + 1` uses a base flow +type, so loop analysis does not keep producing new literal types. + +### Guards + +A guard narrows a type based on a condition: + +| Guard | Examples | +| --- | --- | +| Truthiness | `if value then`, `if not value then`, `assert(value)` | +| Nil and literal comparisons | `value ~= nil`, `value == 'ok'` | +| Runtime type checks | `type(value) == 'string'` | +| Boolean combinations | Conditions joined with `and`, `or`, and `not` | + +```lua +---@param value string | integer | nil +local function describe(value) + if value == nil then + return 'missing' + end + if type(value) == 'string' then + return value:upper() -- value is string here. + end + return tostring(value) -- value is integer here. +end +``` + +A read after a branch or loop combines types from the paths that can reach it. +Paths that return early do not contribute. + +A guard saved in a local boolean can still narrow a value. Copying that boolean +to another variable does not preserve the narrowing. + +## Tables + +### Constructors + +A fresh constructor is checked from its supplied contents, keeping literal +values precise: + +```lua +---@type {text: 'ok'} +local valid = {text = 'ok'} -- Accepted: the supplied value is 'ok'. +---@type {text: 'ok'} +local invalid = {text = 'bad'} -- Error: 'bad' does not match 'ok'. +``` + +Later writes cannot make an invalid constructor pass its original check. + +### Inferred tables + +An inferred table keeps its field names and a separate type for each field. +This structure lets the analyzer check reads and writes of individual fields. +Inferred mutable fields widen primitive literals using the same rule as variables: + +```lua +local value = {text = 'ok'} -- Stable field type: string. + +---@type {text: 'ok'} +local exact = value -- Error: the stable field type is wider than 'ok'. +value.text = 'updated' -- Allowed: the stable field type accepts any string. +``` + +The stable field information is therefore `{text: string}`. An explicit +annotation such as `{text: 'ok'}` keeps its declared literal field type. + +### Table construction + +Lua code often builds records and modules in several statements, starting with +an empty table and then adding fields and functions: + +```lua +local module = {} +module.name = 'greeter' +function module.greet() + return 'hello' +end +return module +``` + +For a scalar such as `local count = 1`, the initializer establishes the stable +type `integer`, and later assignments must match it. Applying that rule to the +table above would fix its field set at the empty constructor. Adding `name` or +`greet` would then require an explicit table annotation. + +Table construction lets these initial field writes establish the stable table +type. A fresh table literal assigned directly to a new local variable without a +declared type is **under construction**. Its constructor supplies the initial +fields; an empty constructor supplies none. Direct field assignments can add +fields until the table is **sealed**, which fixes its stable table type: + +```lua +local value = {} +value.text = 'hello' + +---@type {text: string} +local record = value -- Seals value with the stable type {text: string}. +value.text = 'updated' -- Allowed: the existing field accepts strings. +value.count = 1 -- Error: the sealed type has no count field. +``` + +Literal string or integer keys, such as `value.text`, `value['text']`, and +`value[1]`, add individual fields. A field's stable type comes from its +annotation or, without one, from the first assigned value with primitive +literal widening. Later writes must match the type established on that path. + +A construction write checks its right-hand side before adding its field. +Reads of existing data fields use the fields established so far and keep +construction open: + +```lua +local value = {} +value.a = 1 +value.b = value.a + 1 -- Allowed: reading a does not seal value. +``` + +A write through a dynamic key adds an index signature using the key type and +the widened value type: + +```lua +---@param key string +---@return table +local function build(key) + local value = {} + value[key] = 'hello' -- Infers [string]: string; construction stays open. + return value -- Seals the inferred dictionary type. +end +``` + +Later writes must match the inferred index signature. Existing fields and new +fields in its key domain must also fit the signature, and dynamic writes must +respect any narrower field types their keys could select. An index signature +does not establish any required named fields. + +### Conditional construction + +Entering a branch, loop, or `do` block does not seal the table. Where paths +rejoin, a field is required if it is present on every path reaching that point. +If it is absent on any of those paths, its inferred stable type includes nil: + +```lua +---@param include_text boolean +local function build(include_text) + local value = {} + if include_text then + value.text = 'hello' + end + + ---@type {text?: string} + local record = value -- Accepted: stable type is {text?: string}. + ---@type {text: string} + local required = value -- Error: text is not present on every path. + return value +end +``` + +Assigning the field on every branch makes it required. If those branches +introduce it with different types, its stable type at the join is their union: + +```lua +---@param numeric boolean +---@return {result: integer | string} +local function build(numeric) + local value = {} + if numeric then + value.result = 1 + else + value.result = 'one' + end + return value -- Accepted: stable type is {result: integer | string}. +end +``` + +A field established before the branch keeps its stable type; assignments on +each path must match it. + +Fields introduced only in a loop that may execute zero times are optional. +Paths that return or otherwise cannot reach a join do not contribute to it. + +Sealing inside a branch uses the fields established on paths reaching that +operation. Construction can continue after a join only if it is still open on +every path reaching the join. + +### Sealing boundaries + +A table is sealed in the following cases. Sealing happens before the operation +is checked: + +- **Declare or assert the table type.** A `@type` annotation on the local or an + `@as` assertion on its initializer gives it a sealed type from initialization. + Field assignments use its declared fields or index signature. + + ```lua + ---@type {text?: string} + local declared = {} + declared.text = 'hello' -- Allowed: text is a declared optional field. + declared.count = 1 -- Error: the declared type has no count field. + + local asserted = {} --[[@as {text?: string}]] + asserted.text = 'hello' -- Allowed: text is in the asserted type. + asserted.count = 1 -- Error: the asserted type has no count field. + ``` + + + +- **Use the table as a whole.** Aliasing, arguments, returns, storage elsewhere, + and `#value` seal the table. Later writes cannot satisfy an earlier use: + + ```lua + local value = {} + ---@type {text: string} + local record = value -- Error: value seals with no text field. + value.text = 'hello' -- Error: the sealed type has no text field. + ``` + + + +- **Read a function field.** Retrieving a function, calling it, or making a + method call seals its table. Defining the function field keeps construction + open, even when its body captures the table. + + ```lua + local value = {} + function value.read() + return value.text + end -- Definition keeps construction open. + value.text = 'hello' -- Allowed: construction is still open. + local read = value.read -- Seals value before read can be called. + value.count = 1 -- Error: the sealed type has no count field. + ``` + + + +- **Reach unstructured control flow.** A `goto` or label seals the table. + + ```lua + local value = {} + value.text = 'hello' + goto done -- Seals value before the jump. + ::done:: -- A label is also a sealing boundary. + value.count = 1 -- Error: the sealed type has no count field. + ``` + + + +- **Write through a nested path.** The write seals the table, then uses its + existing field types or index signatures. + + ```lua + local value = {child = {text = 'hello'}} + value.child.text = 'updated' -- Seals value; the existing field accepts strings. + value.count = 1 -- Error: the sealed type has no count field. + ``` + + + +- **Capture the table in a closure stored elsewhere.** Direct function fields + are exempt, as shown above. + + ```lua + local value = {} + value.text = 'hello' + local function read() -- Capturing value seals it. + return value.text + end + value.count = 1 -- Error: the sealed type has no count field. + ``` + + + +- **Replace the local binding or apply a named `@cast`.** Sealing happens before + the assignment or cast. Reassignment does not start a new construction phase. + + ```lua + local replaced = {text = 'hello'} + replaced = {text = 'updated'} -- Seals replaced; the new table matches its type. + + local casted = {} + casted.text = 'hello' + ---@cast casted {text: string} + -- The cast seals casted before applying the explicit type. + casted.text = 1 -- Error: the field's type is string. + ``` + + + +- **Leave the local's declaring scope.** Returning the table seals it as a + whole-table use. If the scope ends with construction still open, the table + seals there and pending function bodies are checked. + + ```lua + local function build() + local value = {} + value.text = 'hello' + return value -- Returns the sealed type {text: string}. + end + ``` + + + +### Function fields + +A direct function field may have an unresolved return type during construction. +Sealing fixes the table's fields and index signatures before its function +signatures are resolved. Function bodies are checked against that fixed structure. + +This applies to `value.read = function() ... end`, +`function value.read() ... end`, and `function value:read() ... end`. The body +can therefore refer to fields added between its definition and sealing: + +```lua +local module = {} +function module.read() + return module.text -- Checked against text: string in the sealed type. +end +module.text = 'hello' +return module -- Seals with both read and text fields. +``` + +### Field reads + +Guards and direct assignments narrow a field's flow type while its stable type +stays the same: + +```lua +---@type {text?: string} +local value +if value.text then + ---@type string + local text = value.text -- Accepted: the field's flow type is string. +end + +value.text = 'hello' +---@type 'hello' +local text = value.text -- Accepted: the field's flow type is 'hello'. +``` + +A later write replaces the field's previous flow type. Reassigning the table +variable discards narrowing of its old fields. + +### Whole-table checks + +Whole-table checks use the table's flow type and the stable type of each field. +Narrowing a field read therefore leaves an optional field optional in the table +type. This applies to assignments, arguments, returns, and `table` checks. + +A new constructor can use the narrowed read to supply a required field: + +```lua +---@type {text?: string} +local value = {} +value.text = 'hello' + +---@type {text: string} +local record = value -- Error: text is still optional in the table type. + +---@type {text: string} +local copy = {text = value.text} -- Accepted: the constructor supplies text. +``` + +### Unions of table types + +A field guard can narrow the table's flow type to matching alternatives in a +union: + +```lua +---@alias Text {kind: 'text', text: string} +---@type Text | {kind: 'count', count: integer} +local value +if value.kind == 'text' then + ---@type Text + local record = value -- Accepted: value's flow type is Text. +else + ---@type integer + local count = value.count -- Accepted: only the count alternative remains. +end +``` + +## Annotations + +`@as` asserts an expression's type without changing the type of the variable it +reads. A named [`@cast`](../emmylua_doc/annotations_EN/cast.md) changes the type +used for later reads and assignments of the named variable or field: + +```lua +---@type string | nil +local value +local asserted = value --[[@as string]] -- asserted is string. + +---@type string +local before = value -- Error: @as did not narrow value. + +---@cast value string +---@type string +local after = value -- Accepted: @cast applies to later reads. +``` + +A named `@cast` can also add types to a union with `+T` or remove them with `-T`. + +Function annotations describe how testing a call's result narrows other values: + +| Feature | Role | +| --- | --- | +| `TypeGuard` | Narrows a call argument when the returned boolean is tested. | +| `@return_cast` | Narrows a specified argument or `self` according to the boolean result. | +| [`@return_overload`](../emmylua_doc/annotations_EN/return.md#return-overload-rows-return_overload) | A check on one return value narrows related values from the same call. | + +## Array element reads + +Flow typing tracks whether individual array elements are nil and whether an +array is empty. For a nonempty array, `values[#values]` uses the array's element +type: + +```lua +---@type string[] +local values = {} +if values[1] then + ---@type string + local first = values[1] -- Accepted: this element was checked. +end + +if #values > 0 then + ---@type string + local last = values[#values] -- Accepted: the array is nonempty. +end +``` + +## Scope limits + +Flow typing follows assignments and guards for variables and field paths. +The following limits apply when those paths refer to mutable tables: + +- **Field narrowing and table structure.** Narrowing `value.text` changes + reads of that field. [Whole-table checks](#whole-table-checks) still use its + stable field type: assigning a string to an optional field does not make + that field required in the table type. + +- **Construction through aliases or helpers.** Creating an alias or passing + the table to a helper such as `fill(value)` seals it first. Writes through + those references must fit the sealed stable type; they cannot add inferred + fields to it. + +- **Mutations through other references.** The analyzer does not trace every + alias or determine every field that a call or callback might change. + For example, `clear(value)` may change `value.text` at runtime while its + earlier flow narrowing remains in place. + +- **Metatable changes.** Static metatable information can supply field types. + Flow typing does not follow successive `setmetatable` calls or writes to + `__index` to infer a different field set at each point in execution. + +## Related language documentation + +- [TypeScript: Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) +- [Flow: Type Refinements](https://flow.org/en/docs/lang/refinements/) +- [Luau: Type Refinements](https://luau.org/types/type-refinements/) +- [Luau: Table Types](https://luau.org/types/tables/)