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::*;