From d1fc6e7cc3019a0b7c69721d2ccc784f554e672d Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Mon, 19 Oct 2026 18:40:37 +0100 Subject: [PATCH] fix(infer): infer integer lengths and guarded last elements Built-in length expressions can keep the operand type. Infer integer for strings and tables, including aliases and unions. Preserve custom length operators and the existing fallback for invalid or unresolved operands. Narrow a[#a] to the element type after a nonempty guard on every path: ---@type string[] local a = {'first'} if not a[1] then return end local last = a[#a] -- string Limit the proof to direct local tables with no writes, aliases, captures or escapes anywhere in the function. Check all references once and reuse the existing flow graph to keep the proof independent of call effects. Correct constant-index inference so upper bounds keep nil: if #a <= 1 then local first = a[1] end -- string|nil Keep equality as a lower-bound proof and handle reversed comparisons. --- .../src/check/test/length_operator_test.rs | 486 ++++++++++++++++++ .../src/check/test/mod.rs | 1 + .../src/semantic_model/cache.rs | 2 + .../src/semantic_model/flow.rs | 168 +++--- .../src/semantic_model/flow/array.rs | 207 ++++++++ .../src/semantic_model/flow/guards.rs | 104 ++++ .../src/semantic_model/infer/vm.rs | 32 +- 7 files changed, 883 insertions(+), 117 deletions(-) create mode 100644 crates/emmylua_code_analysis/src/check/test/length_operator_test.rs create mode 100644 crates/emmylua_code_analysis/src/semantic_model/flow/array.rs create mode 100644 crates/emmylua_code_analysis/src/semantic_model/flow/guards.rs diff --git a/crates/emmylua_code_analysis/src/check/test/length_operator_test.rs b/crates/emmylua_code_analysis/src/check/test/length_operator_test.rs new file mode 100644 index 000000000..b8ca384b8 --- /dev/null +++ b/crates/emmylua_code_analysis/src/check/test/length_operator_test.rs @@ -0,0 +1,486 @@ +#[cfg(test)] +mod tests { + use crate::check::test::{check_source, count_by_code}; + use crate::{DiagnosticCode, LuaType, VirtualWorkspace}; + + fn assert_diagnostic_count(source: &str, code: DiagnosticCode, expected: usize) { + let diagnostics = check_source(source); + assert_eq!( + count_by_code(&diagnostics, DiagnosticCode::SyntaxError), + 0, + "{source}\n{diagnostics:?}" + ); + assert_eq!( + count_by_code(&diagnostics, code), + expected, + "{source}\n{diagnostics:?}" + ); + } + + #[test] + fn test_builtin_and_aliased_lengths_are_integer() { + for operand in [ + "string", + "string[]", + "{name:string}", + "string|string[]", + "Builtin", + "List", + "Identity>", + "Identity>", + "Identity|Identity", + "TextOnly", + "TextOrList", + "TextOrList", + "TextOrList", + "TextOrList", + "TextOrList>", + "Select", + ] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + r#" + ---@alias Builtin string|string[] + ---@alias Input string|integer + ---@alias List T[] + ---@alias Identity T + ---@alias TextOnly T extends string and T or never + ---@alias TextOrList T extends string and T or T[] + ---@alias Select T extends string and T or U + ---@type {operand} + local value + length = #value + "# + )); + assert_eq!(ws.expr_ty("length"), LuaType::Integer, "{operand}"); + } + let mut ws = VirtualWorkspace::new(); + assert_eq!(ws.expr_ty("#'abc'"), LuaType::Integer); + assert_eq!(ws.expr_ty("#{'a', 'b'}"), LuaType::Integer); + } + + #[test] + fn test_array_length_works_in_returns_arguments_and_indexes() { + let diagnostics = check_source( + r#" + ---@param n integer + local function consume(n) return n end + ---@param values string[] + ---@return integer + local function count(values) + consume(#values) + local last = values[#values] + return #values + end + count({'a'}) + "#, + ); + for code in [ + DiagnosticCode::ReturnTypeMismatch, + DiagnosticCode::ParamTypeMismatch, + DiagnosticCode::UndefinedField, + ] { + assert_eq!(count_by_code(&diagnostics, code), 0, "{diagnostics:?}"); + } + } + + #[test] + fn test_conditional_alias_length_returns_integer() { + assert_diagnostic_count( + r#" + ---@alias Input string|integer + ---@alias TextOrList T extends string and T or T[] + ---@param value TextOrList + ---@return integer + local function length(value) + return #value -- The alias resolves to string|integer[]. + end + return length + "#, + DiagnosticCode::ReturnTypeMismatch, + 0, + ); + } + + #[test] + fn test_length_preserves_custom_operator_results() { + for (operand, expected) in [ + ("Sized", "string"), + ("Sized|string[]", "string|integer"), + ("LengthInput", "string|integer"), + ("Identity|Identity", "string|integer"), + ("Select", "string|integer"), + ] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + r#" + ---@class Sized + ---@operator len: string + local Sized = {{}} + ---@alias LengthInput Sized|string[] + ---@alias Identity T + ---@alias Select T extends string and T or U + ---@type {operand} + local value + length = #value + "# + )); + let expected = ws.ty(expected); + assert_eq!(ws.expr_ty("length"), expected, "{operand}"); + } + } + + #[test] + fn test_length_preserves_metatable_class_operator() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def( + r#" + ---@class Sized + ---@operator len: string + local Sized = {} + local value = setmetatable({}, { __index = Sized }) + length = #value + "#, + ); + assert_eq!(ws.expr_ty("length"), LuaType::String); + } + + #[test] + fn test_length_reports_invalid_operands_and_return_types() { + for (operand, result) in [ + ("boolean", "integer"), + ("string|boolean", "integer"), + ("string[]", "string"), + ] { + let source = format!( + r#" + ---@param value {operand} + ---@return {result} + local function count(value) return #value end + return count + "# + ); + assert_diagnostic_count(&source, DiagnosticCode::ReturnTypeMismatch, 1); + } + } + + #[test] + #[ntest::timeout(5000)] + fn test_recursive_alias_lengths_keep_operand_type() { + for (aliases, operand) in [ + ("---@alias Input Input", "Input"), + ("---@alias Input Other\n---@alias Other Input", "Input"), + ("---@alias Input Input", "Input"), + ("---@alias Input string|Input", "Input"), + ("---@alias Input Input|string", "Input"), + ( + "---@alias Input T extends string and Input or Input", + "Input", + ), + ( + "---@alias Input Other\n---@alias Other Input", + "Input", + ), + ( + "---@alias Input Input<{left:T,right:T}>", + "Input", + ), + ( + "---@alias Input Filter>\n---@alias Filter T extends string and T or T[]", + "Input", + ), + ( + "---@alias Input Filter>\n---@alias Filter T extends string and T or T[]", + "Input", + ), + ( + "---@alias Input Match<{outer:Input}>\n---@alias Match T extends {outer:{inner:infer U}} and U or boolean", + "Input", + ), + ] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + r#" + {aliases} + ---@type {operand} + local value + length = #value + "# + )); + let expected = ws.ty(operand); + assert_eq!(ws.expr_ty("length"), expected, "{aliases}"); + } + } + + #[test] + #[ntest::timeout(5000)] + fn test_length_resolves_long_finite_alias_chains() { + let mut source = (0..512) + .map(|index| format!("---@alias Alias{index} Alias{}\n", index + 1)) + .collect::(); + source.push_str("---@alias Alias512 string\n---@type Alias0\nlocal value\nlength = #value"); + let mut ws = VirtualWorkspace::new(); + ws.def(&source); + assert_eq!(ws.expr_ty("length"), LuaType::Integer); + } + + #[test] + fn test_stable_local_array_has_a_last_element_after_a_nonempty_guard() { + for guard in [ + "if not a[1] then return end", + "if a[1] == nil then return end", + "if nil == (a)[1] then return end", + "if not (a[1] ~= nil) then return end", + "if #a <= 0 then return end", + "if #a < 1 then return end", + "if #a ~= 1 then return end", + "if #a == 0 then return end", + "if 0 >= #a then return end", + "if not (#(a) > 0) then return end", + "if not a[1] then return end; if #a > 10 then return end", + "if #a > 10 then return end; if not a[1] then return end", + "if check then if not a[1] then return end else if #a < 1 then return end end", + ] { + let source = format!( + r#" + ---@param check boolean + local function last(check) + ---@type string[] + local a = {{'first'}} + {guard} + ---@type string + local value = ((a))[(#(a))] + return value + end + return last + "# + ); + assert_diagnostic_count(&source, DiagnosticCode::AssignTypeMismatch, 0); + } + } + + #[test] + fn test_stable_last_element_preserves_the_element_type() { + for (array, element) in [ + ("(string|false)[]", "string|false"), + ("string[]|integer[]", "string|integer"), + ] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + r#" + ---@type {array} + local a = {{'first'}} + if a[1] == nil then return end + last = a[#a] + "#, + )); + assert_eq!(ws.expr_ty("last"), ws.ty(element)); + } + } + + #[test] + fn test_last_element_proof_preserves_existing_cast_types() { + assert_diagnostic_count( + r#" + ---@type string[] + local a = {'first'} + if not a[1] then return end + ---@cast a integer[] + ---@type string + local value = a[#a] + "#, + DiagnosticCode::AssignTypeMismatch, + 1, + ); + } + + #[test] + fn test_last_element_requires_a_nonempty_guard_on_every_path() { + for guard in [ + "", + "if #a <= 1 then else return end", // An upper bound allows zero. + "if a[1] ~= nil then return end", // The remaining path has no first element. + "if not a[2] then return end", // A later element does not exclude a hole at 1. + "if not other[1] then return end", + "if check then if not a[1] then return end end", + ] { + let source = format!( + r#" + ---@param check boolean + local function last(check) + ---@type string[] + local a = {{'first'}} + ---@type string[] + local other = {{'other'}} + {guard} + ---@type string + local value = a[#a] + return value + end + return last + "# + ); + assert_diagnostic_count(&source, DiagnosticCode::AssignTypeMismatch, 1); + } + } + + #[test] + fn test_any_write_alias_capture_or_escape_disables_the_last_element_proof() { + for use_array in [ + "a = {}", + "a[1] = nil", + "(a)[2] = 'changed'", + "a[i] = nil", + "mutate(a)", + "a:clear()", + "a.clear(a)", + "local alias = a; alias[1] = nil", + "local stored = {values = a}", + "local edit = function() a[1] = nil end", + "local equal = a == peer", + ] { + for before_read in [true, false] { + let read = "---@type string\nlocal value = a[#a];"; + let body = if before_read { + format!("{use_array}\n{read}") + } else { + format!("{read}\n{use_array}") + }; + let source = format!( + r#" + ---@param mutate fun(values:string[]) + ---@param peer table + ---@param i integer + local function last(mutate, peer, i) + ---@type string[] + local a = {{'first'}} + if not a[1] then return end + {body} + return value + end + return last + "# + ); + assert_diagnostic_count(&source, DiagnosticCode::AssignTypeMismatch, 1); + } + } + } + + #[test] + fn test_unrelated_operations_do_not_affect_a_stable_local_array() { + assert_diagnostic_count( + r#" + ---@param notify fun() + ---@param n integer + local function last(notify, n) + ---@type string[] + local a = {'first'} + if not a[1] then return end + notify() + local next = n + 1 + local first = a[1] + if a == nil then return end + ---@type string + local value = a[#a] + return value, first, next + end + return last + "#, + DiagnosticCode::AssignTypeMismatch, + 0, + ); + } + + #[test] + fn test_external_tables_and_metatables_keep_nullable_last_elements() { + for declaration in [ + "", // The parameter has no ownership or metatable guarantee. + "---@type string[]\nlocal original = {'first'}; local a = original", + "---@type string[]\nlocal a = setmetatable({'first'}, {__len = function() return 0 end})", + ] { + let source = format!( + r#" + ---@param a string[] + local function last(a) + {declaration} + if not a[1] then return end + ---@type string + local value = a[#a] + return value + end + return last + "# + ); + assert_diagnostic_count(&source, DiagnosticCode::AssignTypeMismatch, 1); + } + } + + #[test] + fn test_last_element_proof_does_not_cross_a_function_boundary() { + assert_diagnostic_count( + r#" + ---@type string[] + local a = {'first'} + if not a[1] then return end + return function() + ---@type string + local value = a[#a] + return value + end + "#, + DiagnosticCode::AssignTypeMismatch, + 1, + ); + } + + #[test] + fn test_last_element_proof_requires_the_same_array_and_exact_length() { + for index in ["#other", "#a + 1"] { + let source = format!( + r#" + ---@type string[] + local a = {{'first'}} + ---@type string[] + local other = {{'other'}} + if not a[1] then return end + ---@type string + local value = a[{index}] + "# + ); + assert_diagnostic_count(&source, DiagnosticCode::AssignTypeMismatch, 1); + } + } + + #[test] + fn test_upper_bounds_do_not_prove_constant_indexes_exist() { + for (condition, expected) in [ + ("#a <= 1", 1), + ("1 >= #a", 1), + ("#a < 2", 1), + ("2 > #a", 1), + ("not (#a > 1)", 1), + ("#a == 1", 0), + ("1 == #a", 0), + ("#a >= 1", 0), + ("1 <= #a", 0), + ("#a > 0", 0), + ("0 < #a", 0), + ("not (#a < 1)", 0), + ] { + let source = format!( + r#" + ---@param a string[] + local function first(a) + if {condition} then + ---@type string + local value = a[1] + return value + end + end + return first + "# + ); + assert_diagnostic_count(&source, DiagnosticCode::AssignTypeMismatch, expected); + } + } +} diff --git a/crates/emmylua_code_analysis/src/check/test/mod.rs b/crates/emmylua_code_analysis/src/check/test/mod.rs index 3020a1014..d45b3b5b3 100644 --- a/crates/emmylua_code_analysis/src/check/test/mod.rs +++ b/crates/emmylua_code_analysis/src/check/test/mod.rs @@ -24,6 +24,7 @@ mod generic_constraint_mismatch_test; mod global_in_non_module_test; mod incomplete_signature_doc_test; mod inject_field_test; +mod length_operator_test; mod local_const_reassign_test; mod misc_checker_test; mod missing_fields_test; diff --git a/crates/emmylua_code_analysis/src/semantic_model/cache.rs b/crates/emmylua_code_analysis/src/semantic_model/cache.rs index abd117409..4494db54b 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/cache.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/cache.rs @@ -210,6 +210,8 @@ pub(crate) struct SemanticLocalCache { pub(crate) resolve_owner_set: HashMap>, pub(crate) member_type_at: HashMap<(FileId, SemanticId, TextSize), LuaType>, pub(crate) flow_decl: HashMap<(FileId, SemanticId, FlowId), LuaType>, + /// Whole-function, syntax-only check for the local-table last-element proof. + pub(crate) array_length_stable: HashMap<(FileId, SemanticId), bool>, /// Memoized declaration-trace states for loop-free flow graphs. See /// [`TraceStateKey`] for why the path may be omitted from the key. pub(crate) trace_state: HashMap>>, diff --git a/crates/emmylua_code_analysis/src/semantic_model/flow.rs b/crates/emmylua_code_analysis/src/semantic_model/flow.rs index b83959e59..d9588c2b3 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/flow.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/flow.rs @@ -12,6 +12,9 @@ //! - `---@cast x +string` adds string to x's path type; //! - Flow type for member assignment `t.x = v` / `self.x = v` (FlowEffect::AssignMember). +mod array; +mod guards; + use std::collections::HashSet; use std::rc::Rc; @@ -39,6 +42,7 @@ use crate::semantic_model::infer::infer_call_with_bindings; use crate::semantic_model::infer::unify::substitute; use crate::semantic_model::infer::vm::{binary_type, widen_const}; use crate::signature::LuaSignatureId; +use guards::{guarded_on_all_paths, literal_comparison, normalize_condition}; /// Backtracking options: independently collect condition guards, cast widening, and assignment hits. #[derive(Clone, Copy)] @@ -449,6 +453,9 @@ 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()?; @@ -733,64 +740,37 @@ fn guarded_index_type( let access = LuaExpr::IndexExpr(index.clone()); let target = stable_access_path(model, &access).or_else(|| index_access_path(model, &access))?; - let closure = LuaClosureId::from_node(index.syntax()); if target.1.iter().any(LuaMemberKey::is_none) { return None; } - let tree = model.flow_tree()?; - if !tree.has_condition() { - return None; - } - let mut pending = vec![tree.get_flow_id_at(offset)?]; - let mut visited = HashSet::new(); let mut narrowing = Narrowing::Truthy; - while let Some(current) = pending.pop() { - if !visited.insert(current) { - continue; - } - let node = tree.get_flow_node(current)?; - match &node.kind { - FlowNodeKind::Assignment(ptr) if !ptr.get_syntax_id().get_range().contains(offset) => { - let assign = flow_assign_stat(model, &tree, current)?; - for var in assign.get_var_and_expr_list().0 { - if let Some(written) = LuaExpr::cast(var.syntax().clone()).and_then(|expr| { - stable_access_path(model, &expr).or_else(|| index_access_path(model, &expr)) - }) && path_write_affects(&written, &target.0, &target.1) - { - return None; - } + guarded_on_all_paths( + model, + index, + offset, + |cond, truthy| { + if let Some(guard) = narrow_index_condition(model, &target, &access, cond, truthy) { + if matches!(guard, Narrowing::NotNil) { + narrowing = guard; } + true + } else { + false } - FlowNodeKind::TrueCondition(ptr) | FlowNodeKind::FalseCondition(ptr) => { - let cond = flow_condition_expr(model, ptr)?; - // A nested function can run after the guarded value changes. - if LuaClosureId::from_node(cond.syntax()) != closure { + }, + |tree, current| { + let assign = flow_assign_stat(model, tree, current)?; + for var in assign.get_var_and_expr_list().0 { + if let Some(written) = LuaExpr::cast(var.syntax().clone()).and_then(|expr| { + stable_access_path(model, &expr).or_else(|| index_access_path(model, &expr)) + }) && path_write_affects(&written, &target.0, &target.1) + { return None; } - if let Some(guard) = narrow_index_condition( - model, - &target, - &access, - cond, - matches!(node.kind, FlowNodeKind::TrueCondition(_)), - ) { - if matches!(guard, Narrowing::NotNil) { - narrowing = guard; - } - continue; - } } - FlowNodeKind::Start | FlowNodeKind::LoopLabel | FlowNodeKind::NamedLabel(_) => { - return None; - } - FlowNodeKind::Unreachable => continue, - _ => {} - } - match node.antecedent.as_ref()? { - FlowAntecedent::Single(next) => pending.push(*next), - FlowAntecedent::Multiple(id) => pending.extend(tree.get_multi_antecedents(*id)?), - } - } + Some(()) + }, + )?; Some(apply_narrowing(model, ty, &narrowing)) } @@ -799,48 +779,32 @@ fn narrow_index_condition( model: &SemanticModel, target: &(SemanticId, Vec), access: &LuaExpr, - mut cond: LuaExpr, - mut branch_is_true: bool, + cond: LuaExpr, + branch_is_true: bool, ) -> Option { - loop { - match &cond { - LuaExpr::ParenExpr(paren) => cond = paren.get_expr()?, - LuaExpr::UnaryExpr(unary) if unary.get_op_token()?.get_op() == UnaryOperator::OpNot => { - branch_is_true = !branch_is_true; - cond = unary.get_expr()?; - } - LuaExpr::BinaryExpr(binary) => { - let op = binary.get_op_token()?.get_op(); - if !matches!(op, BinaryOperator::OpEq | BinaryOperator::OpNe) - || branch_is_true != (op == BinaryOperator::OpNe) - { - return None; - } - let (left, right) = binary.get_exprs()?; - let value = match (left, right) { - (value, LuaExpr::LiteralExpr(lit)) | (LuaExpr::LiteralExpr(lit), value) - if literal_type(&lit) == Some(LuaType::Nil) => - { - value - } - _ => 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); - } - _ => { - return (branch_is_true - && stable_access_path_at(model, &cond, access) - .or_else(|| index_access_path(model, &cond)) - .as_ref() - == Some(target)) - .then_some(Narrowing::Truthy); - } + let (cond, branch_is_true) = normalize_condition(cond, branch_is_true)?; + if let LuaExpr::BinaryExpr(binary) = &cond { + 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); } + return None; } + (branch_is_true + && stable_access_path_at(model, &cond, access) + .or_else(|| index_access_path(model, &cond)) + .as_ref() + == Some(target)) + .then_some(Narrowing::Truthy) } /// Infer the VM type, then apply the selected inline casts. @@ -973,9 +937,9 @@ fn logical_truthy_components(ty: &LuaType) -> Vec { fn array_index_in_known_range(index: i64, len: &LuaArrayLen) -> bool { match len { - LuaArrayLen::Max(max) => index > 0 && index <= *max, LuaArrayLen::Min(min) => index > 0 && index <= *min, - LuaArrayLen::None => false, + // An upper bound permits an empty array. + LuaArrayLen::Max(_) | LuaArrayLen::None => false, } } @@ -3605,26 +3569,15 @@ fn array_len_condition( cond: &LuaExpr, branch_is_true: bool, ) -> Option { - if let LuaExpr::ParenExpr(paren) = cond - && let Some(inner) = paren.get_expr() - { - return array_len_condition(model, decl, &inner, branch_is_true); - } - if let LuaExpr::UnaryExpr(unary) = cond - && unary.get_op_token()?.get_op() == UnaryOperator::OpNot - && let Some(inner) = unary.get_expr() - { - return array_len_condition(model, decl, &inner, !branch_is_true); - } + let (cond, branch_is_true) = normalize_condition(cond.clone(), branch_is_true)?; let LuaExpr::BinaryExpr(binary) = cond else { return None; }; let op = binary.get_op_token()?.get_op(); let (left, right) = binary.get_exprs()?; - let (unary_expr, lit_expr) = match (left, right) { - (LuaExpr::UnaryExpr(unary), LuaExpr::LiteralExpr(lit)) => (unary, lit), - (LuaExpr::LiteralExpr(lit), LuaExpr::UnaryExpr(unary)) => (unary, lit), - _ => return None, + let (value, literal, op) = literal_comparison(left, right, op)?; + let LuaExpr::UnaryExpr(unary_expr) = value else { + return None; }; if unary_expr.get_op_token()?.get_op() != UnaryOperator::OpLen { return None; @@ -3635,14 +3588,13 @@ fn array_len_condition( if model.resolve_name(name_expr.get_position()).as_ref() != Some(decl) { return None; } - let lit_ty = literal_type(&lit_expr)?; - let bound = match lit_ty { + let bound = match literal { LuaType::IntegerConst(i) | LuaType::DocIntegerConst(i) => i, _ => return None, }; match op { - BinaryOperator::OpEq if branch_is_true => Some(Narrowing::ArrayLen(bound)), + BinaryOperator::OpEq if branch_is_true => Some(Narrowing::ArrayMinLen(bound)), BinaryOperator::OpLe if branch_is_true => Some(Narrowing::ArrayLen(bound)), BinaryOperator::OpLe => match bound.checked_add(1) { Some(next) => Some(Narrowing::ArrayMinLen(next)), diff --git a/crates/emmylua_code_analysis/src/semantic_model/flow/array.rs b/crates/emmylua_code_analysis/src/semantic_model/flow/array.rs new file mode 100644 index 000000000..ab40edb4f --- /dev/null +++ b/crates/emmylua_code_analysis/src/semantic_model/flow/array.rs @@ -0,0 +1,207 @@ +//! Prove `a[#a]` exists only for an unchanged local table that never escapes. +//! This adds no numeric bounds or effects to the general flow model. + +use emmylua_parser::{BinaryOperator, LuaAssignStat, LuaAstNode, LuaExpr, UnaryOperator}; +use rowan::TextSize; + +use crate::LuaType; +use crate::semantic_db::def::{DeclKind, SemanticId}; +use crate::semantic_db::flow::LuaClosureId; + +use super::guards::{ + guarded_on_all_paths, literal_comparison, normalize_condition, unparenthesized, +}; +use super::{SemanticModel, literal_type}; + +pub(super) fn last_element_type( + model: &SemanticModel, + index: &emmylua_parser::LuaIndexExpr, + offset: TextSize, +) -> Option { + let emmylua_parser::LuaIndexKey::Expr(key) = index.get_index_key()? else { + return None; + }; + let LuaExpr::UnaryExpr(length) = unparenthesized(key)? else { + return None; + }; + if length.get_op_token()?.get_op() != UnaryOperator::OpLen { + return None; + } + let prefix = index.get_prefix_expr()?; + let decl = name_decl(model, prefix.clone())?; + if name_decl(model, length.get_expr()?)? != decl || !length_is_stable(model, &decl) { + return None; + } + guarded_on_all_paths( + model, + index, + offset, + |cond, truthy| nonempty_condition(model, &decl, cond, truthy).unwrap_or(false), + |_, _| Some(()), // The whole-function stability check already excludes writes. + )?; + // Keep the current element type, including existing casts on the prefix. + let components = match model.type_of_expr_at(prefix.get_syntax_id(), offset) { + LuaType::Union(union) => union.into_vec(), + ty => vec![ty], + }; + let bases = components + .into_iter() + .map(|ty| match ty { + LuaType::Array(array) => Some(array.get_base().clone()), + _ => None, + }) + .collect::>>()?; + (!bases.is_empty()).then(|| LuaType::from_vec(bases)) +} + +fn name_decl(model: &SemanticModel, expr: LuaExpr) -> Option { + let LuaExpr::NameExpr(name) = unparenthesized(expr)? else { + return None; + }; + model.resolve_name(name.get_position()) +} + +fn length_is_stable(model: &SemanticModel, decl: &SemanticId) -> bool { + let key = (model.file_id(), decl.clone()); + if let Some(stable) = model.cache.borrow().array_length_stable.get(&key) { + return *stable; + } + let stable = (|| { + let info = model.file_facts()?.decl_by_id(decl)?; + if !matches!(info.kind, DeclKind::Local { is_iter: false, .. }) { + return None; + } + let root = model.syntax_tree()?.get_red_root(); + let initializer = LuaExpr::cast(info.value_expr_syntax?.to_node_from_root(&root)?)?; + if !matches!(unparenthesized(initializer)?, LuaExpr::TableExpr(_)) { + return None; + } + let owner = info.owner_syntax?.to_node_from_root(&root)?; + let closure = LuaClosureId::from_node(&owner); + Some(model.decl_references(decl).iter().all(|syntax| { + syntax.to_node_from_root(&root).is_some_and(|node| { + LuaClosureId::from_node(&node) == closure && reference_is_read_only(node) + }) + })) + })() + .unwrap_or(false); + model + .cache + .borrow_mut() + .array_length_stable + .insert(key, stable); + stable +} + +/// Check every reference, including future writes and captures. An alias or +/// escape disqualifies the whole declaration, so calls need no effect tracking. +fn reference_is_read_only(mut node: emmylua_parser::LuaSyntaxNode) -> bool { + while let Some(parent) = node.parent() + && matches!(LuaExpr::cast(parent.clone()), Some(LuaExpr::ParenExpr(_))) + { + node = parent; + } + let Some(parent) = node.parent() else { + return false; + }; + match LuaExpr::cast(parent.clone()) { + Some(LuaExpr::UnaryExpr(unary)) => unary + .get_op_token() + .is_some_and(|op| matches!(op.get_op(), UnaryOperator::OpLen | UnaryOperator::OpNot)), + Some(LuaExpr::BinaryExpr(binary)) => { + let Some((left, right)) = binary.get_exprs() else { + return false; + }; + let other = if left.get_range() == node.text_range() { + right + } else { + left + }; + binary.get_op_token().is_some_and(|op| { + matches!(op.get_op(), BinaryOperator::OpEq | BinaryOperator::OpNe) + }) && matches!(unparenthesized(other), Some(LuaExpr::LiteralExpr(lit)) + if literal_type(&lit).is_some()) + } + Some(LuaExpr::IndexExpr(index)) => { + index + .get_index_token() + .is_some_and(|token| !token.is_colon()) + && index + .get_prefix_expr() + .is_some_and(|prefix| prefix.get_range() == node.text_range()) + && !index.ancestors::().any(|assign| { + assign + .get_var_and_expr_list() + .0 + .iter() + .any(|var| var.get_range().contains_range(index.get_range())) + }) + } + _ => matches!( + parent.kind().into(), + emmylua_parser::LuaSyntaxKind::IfStat + | emmylua_parser::LuaSyntaxKind::ElseIfClauseStat + | emmylua_parser::LuaSyntaxKind::WhileStat + | emmylua_parser::LuaSyntaxKind::RepeatStat + ), + } +} + +fn first_element(model: &SemanticModel, decl: &SemanticId, expr: LuaExpr) -> Option { + let LuaExpr::IndexExpr(index) = unparenthesized(expr)? else { + return Some(false); + }; + let first = match index.get_index_key()? { + emmylua_parser::LuaIndexKey::Integer(number) => matches!( + number.get_number_value(), + emmylua_parser::NumberResult::Int(1) | emmylua_parser::NumberResult::Uint(1) + ), + emmylua_parser::LuaIndexKey::Idx(1) => true, + _ => false, + }; + Some(first && name_decl(model, index.get_prefix_expr()?).as_ref() == Some(decl)) +} + +fn nonempty_condition( + model: &SemanticModel, + decl: &SemanticId, + cond: LuaExpr, + truthy: bool, +) -> Option { + use BinaryOperator::*; + let (cond, truthy) = normalize_condition(cond, truthy)?; + if truthy && first_element(model, decl, cond.clone())? { + return Some(true); + } + let LuaExpr::BinaryExpr(binary) = cond else { + return Some(false); + }; + let (left, right) = binary.get_exprs()?; + let op = binary.get_op_token()?.get_op(); + let (value, literal, op) = + literal_comparison(unparenthesized(left)?, unparenthesized(right)?, op)?; + if literal.is_nil() { + return Some( + matches!(op, OpEq | OpNe) + && truthy == (op == OpNe) + && first_element(model, decl, value)?, + ); + } + let LuaType::IntegerConst(bound) = literal else { + return Some(false); + }; + let LuaExpr::UnaryExpr(length) = value else { + return Some(false); + }; + if length.get_op_token()?.get_op() != UnaryOperator::OpLen + || name_decl(model, length.get_expr()?).as_ref() != Some(decl) + { + return Some(false); + } + Some(match (op, truthy) { + (OpEq, true) | (OpNe, false) | (OpGe, true) | (OpLt, false) => bound > 0, + (OpGt, true) | (OpLe, false) => bound >= 0, + (OpEq, false) | (OpNe, true) => bound == 0, + _ => false, + }) +} diff --git a/crates/emmylua_code_analysis/src/semantic_model/flow/guards.rs b/crates/emmylua_code_analysis/src/semantic_model/flow/guards.rs new file mode 100644 index 000000000..14f7099ba --- /dev/null +++ b/crates/emmylua_code_analysis/src/semantic_model/flow/guards.rs @@ -0,0 +1,104 @@ +//! Shared traversal and syntax matching for guarded array reads. + +use std::collections::HashSet; + +use emmylua_parser::{BinaryOperator, LuaAstNode, LuaExpr, LuaIndexExpr, UnaryOperator}; +use rowan::TextSize; + +use crate::LuaType; +use crate::semantic_db::flow::{FlowAntecedent, FlowId, FlowNodeKind, FlowTree, LuaClosureId}; + +use super::{SemanticModel, flow_condition_expr, literal_type}; + +/// Each incoming path must reach a guard in the same function before a loop, +/// goto boundary, or entry point. Callers decide which assignments are safe. +pub(super) fn guarded_on_all_paths( + model: &SemanticModel, + index: &LuaIndexExpr, + offset: TextSize, + mut is_guard: impl FnMut(LuaExpr, bool) -> bool, + check_assignment: impl Fn(&FlowTree, FlowId) -> Option<()>, +) -> Option<()> { + let tree = model.flow_tree()?; + if !tree.has_condition() { + return None; + } + let closure = LuaClosureId::from_node(index.syntax()); + let mut pending = vec![tree.get_flow_id_at(offset)?]; + let mut visited = HashSet::new(); + while let Some(current) = pending.pop() { + if !visited.insert(current) { + continue; + } + let node = tree.get_flow_node(current)?; + match &node.kind { + FlowNodeKind::Assignment(ptr) if !ptr.get_syntax_id().get_range().contains(offset) => { + check_assignment(&tree, current)?; + } + FlowNodeKind::TrueCondition(ptr) | FlowNodeKind::FalseCondition(ptr) => { + let cond = flow_condition_expr(model, ptr)?; + if LuaClosureId::from_node(cond.syntax()) != closure { + return None; + } + if is_guard(cond, matches!(node.kind, FlowNodeKind::TrueCondition(_))) { + continue; + } + } + FlowNodeKind::Start | FlowNodeKind::LoopLabel | FlowNodeKind::NamedLabel(_) => { + return None; + } + FlowNodeKind::Unreachable => continue, + _ => {} + } + match node.antecedent.as_ref()? { + FlowAntecedent::Single(next) => pending.push(*next), + FlowAntecedent::Multiple(multi) => pending.extend(tree.get_multi_antecedents(*multi)?), + } + } + Some(()) +} + +pub(super) fn unparenthesized(mut expr: LuaExpr) -> Option { + while let LuaExpr::ParenExpr(paren) = expr { + expr = paren.get_expr()?; + } + Some(expr) +} + +/// Remove outer parentheses and negations, preserving the selected branch. +pub(super) fn normalize_condition(mut cond: LuaExpr, mut truthy: bool) -> Option<(LuaExpr, bool)> { + loop { + cond = unparenthesized(cond)?; + if let LuaExpr::UnaryExpr(unary) = &cond + && unary.get_op_token()?.get_op() == UnaryOperator::OpNot + { + cond = unary.get_expr()?; + truthy = !truthy; + } else { + return Some((cond, truthy)); + } + } +} + +/// Put the literal on the right and reverse relational operators as needed. +/// Callers control whether parentheses around operands are accepted. +pub(super) fn literal_comparison( + left: LuaExpr, + right: LuaExpr, + op: BinaryOperator, +) -> Option<(LuaExpr, LuaType, BinaryOperator)> { + match (left, right) { + (value, LuaExpr::LiteralExpr(lit)) => Some((value, literal_type(&lit)?, op)), + (LuaExpr::LiteralExpr(lit), value) => { + let reversed = match op { + BinaryOperator::OpLt => BinaryOperator::OpGt, + BinaryOperator::OpLe => BinaryOperator::OpGe, + BinaryOperator::OpGt => BinaryOperator::OpLt, + BinaryOperator::OpGe => BinaryOperator::OpLe, + other => other, + }; + Some((value, literal_type(&lit)?, reversed)) + } + _ => None, + } +} 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 2506404e1..4d5190b85 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/vm.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/vm.rs @@ -4890,17 +4890,13 @@ fn unary_type( operand: &LuaType, ) -> LuaType { use emmylua_parser::UnaryOperator::*; - let name = match op { - OpUnm => Some("unm"), - OpLen => Some("len"), - _ => None, - }; - if let Some(name) = name - && let Some(ty) = model.operator_type(name, operand) - { - return ty; + if op == OpLen { + return length_type(model, operand).unwrap_or_else(|| operand.clone()); } if op == OpUnm { + if let Some(ty) = model.operator_type("unm", operand) { + return ty; + } match operand { LuaType::IntegerConst(value) | LuaType::DocIntegerConst(value) => { return value @@ -4915,6 +4911,24 @@ fn unary_type( operand.clone() } +/// Resolve only the outer operand shape. Expanding array elements or restarting +/// the recursive alias evaluator here can lose flow bounds or grow aliases forever. +fn length_type(model: &SemanticModel, operand: &LuaType) -> Option { + let operand = crate::semantic_model::type_eval::expand_alias_branches(model, operand)?; + let mut pending = vec![operand]; + let mut results = Vec::new(); + while let Some(ty) = pending.pop() { + if let LuaType::Union(union) = ty { + pending.extend(union.into_vec()); + } else if ty.is_table() || ty.is_object() || ty.is_string() { + results.push(LuaType::Integer); + } else { + results.push(model.operator_type("len", &ty)?); + } + } + (!results.is_empty()).then(|| LuaType::from_vec(results)) +} + /// Binary operator -> `@operator` name. fn binary_operator_name(op: BinaryOperator) -> Option<&'static str> { use BinaryOperator::*;