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/)