From 77cc687873b449907ab0840ae6d33e6da42acb5b Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Wed, 3 Jun 2026 16:34:50 +0100 Subject: [PATCH] fix: separate unreachable conditions from never types Truthiness checks for call conditions treated impossible branches as `never` values. That let unreachable branch facts leak into point queries and merges: unrelated symbols inside impossible bodies could look non-callable, while assignment and cast effects from impossible branches could still contribute to joined types. Track unreachable condition edges explicitly in flow-query results. Point queries continue through unreachable condition edges, while merge-branch queries contribute `never` for unreachable predecessors. Preserve merge-branch mode through assignments, tag casts, correlated condition subqueries, and recovered assignment fallbacks. This prevents assignments, annotated assignments, casts, and missing-field RHS fallback values inside impossible branches from contributing to final merge types. Add coverage for plain call conditions, always-false and always-true call-condition branches, reachable `never` assignments, and unreachable assignment, annotated-assignment, cast, and fallback merge contributions. Assisted-by: Codex --- .../src/compilation/test/flow.rs | 174 +++++++++++ .../src/semantic/cache/mod.rs | 28 +- .../infer/narrow/condition_flow/mod.rs | 7 +- .../semantic/infer/narrow/get_type_at_flow.rs | 285 ++++++++++++++---- 4 files changed, 430 insertions(+), 64 deletions(-) diff --git a/crates/emmylua_code_analysis/src/compilation/test/flow.rs b/crates/emmylua_code_analysis/src/compilation/test/flow.rs index 4bcd66dd8..ab89bed39 100644 --- a/crates/emmylua_code_analysis/src/compilation/test/flow.rs +++ b/crates/emmylua_code_analysis/src/compilation/test/flow.rs @@ -304,6 +304,180 @@ mod test { assert_eq!(ws.expr_ty("after_guard"), ws.ty("string")); } + #[test] + fn test_plain_call_condition_keeps_inner_call_prefix_type() { + let mut ws = VirtualWorkspace::new(); + let code = r#" + local function a() end + local function b() end + + b() + if a() then + b() + inner = b + end + "#; + ws.def(code); + + let ty = ws.expr_ty("inner"); + assert!(ty.is_function()); + + let mut diag_ws = VirtualWorkspace::new(); + assert!(diag_ws.has_no_diagnostic(DiagnosticCode::CallNonCallable, code)); + } + + #[test] + fn test_false_call_condition_keeps_inner_unrelated_type() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@return false + local function always_false() + return false + end + + ---@type string + local value = "ok" + if always_false() then + inner = value + end + "#, + ); + + assert_eq!(ws.expr_ty("inner"), ws.ty("string")); + } + + #[test] + fn test_true_call_condition_keeps_else_call_prefix_type() { + let mut ws = VirtualWorkspace::new(); + let code = r#" + ---@return true + local function always_true() + return true + end + + local function b() end + if always_true() then + else + b() + end + "#; + + assert!(ws.has_no_diagnostic(DiagnosticCode::CallNonCallable, code)); + } + + #[test] + fn test_false_call_condition_assignment_does_not_contribute_to_merge() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@return false + local function always_false() + return false + end + + local value = "before" + if always_false() then + value = 1 + end + after = value + "#, + ); + + let after = ws.expr_ty("after"); + assert_eq!(ws.humanize_type(after), "string"); + } + + #[test] + fn test_false_call_condition_missing_field_assignment_does_not_contribute_to_merge() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@return false + local function is_windows() + return false + end + + local command = "ls" + local config = {} + if is_windows() then + command = config.windows_command + end + after = command + "#, + ); + + let after = ws.expr_ty("after"); + assert_eq!(ws.humanize_type(after), "string"); + } + + #[test] + fn test_reachable_assignment_over_never_value_contributes_to_merge() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@class NeverBox + ---@field value never + + ---@return NeverBox + local function make_box() end + + local value = make_box().value + local cond ---@type boolean + if cond then + value = 1 + end + after = value + "#, + ); + + assert_eq!(ws.expr_ty("after"), LuaType::IntegerConst(1)); + } + + #[test] + fn test_false_call_condition_tag_cast_does_not_contribute_to_merge() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@return false + local function always_false() + return false + end + + local value = "before" + if always_false() then + ---@cast value integer + end + after = value + "#, + ); + + let after = ws.expr_ty("after"); + assert_eq!(ws.humanize_type(after), r#""before""#); + } + + #[test] + fn test_false_call_condition_doc_assignment_does_not_contribute_to_merge() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@return false + local function always_false() + return false + end + + local value = "before" + if always_false() then + ---@type integer + value = 1 + end + after = value + "#, + ); + + assert_eq!(ws.expr_ty("after"), ws.ty("string")); + } + #[test] fn test_branch_join_keeps_union_when_only_one_side_narrows() { let mut ws = VirtualWorkspace::new(); diff --git a/crates/emmylua_code_analysis/src/semantic/cache/mod.rs b/crates/emmylua_code_analysis/src/semantic/cache/mod.rs index c6dc256fa..60f92e2ad 100644 --- a/crates/emmylua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/cache/mod.rs @@ -26,19 +26,35 @@ pub(in crate::semantic) struct FlowAssignmentInfo { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(in crate::semantic) enum FlowMode { - WithConditions, - WithoutConditions, + Normal, + // Query one predecessor of a merge label; if that walk reaches an + // unreachable condition edge, it contributes `never` to the merged type. + MergeBranch, + // Re-query assignment antecedents without applying condition narrows. + IgnoreConditions, } -impl FlowMode { - pub fn uses_conditions(self) -> bool { - matches!(self, Self::WithConditions) +#[derive(Debug, Clone)] +pub(in crate::semantic) enum FlowQueryResult { + Type(LuaType), + // A merge contribution crossed an impossible condition edge. Keep this + // separate from `Type(Never)` because reachable code can still have a real + // never-typed value and assign over it before the merge. + Unreachable, +} + +impl FlowQueryResult { + pub(in crate::semantic) fn into_type(self) -> LuaType { + match self { + Self::Type(typ) => typ, + Self::Unreachable => LuaType::Never, + } } } #[derive(Debug, Default)] pub(in crate::semantic) struct FlowVarCache { - pub type_cache: HashMap<(FlowId, FlowMode), CacheEntry>, + pub type_cache: HashMap<(FlowId, FlowMode), CacheEntry>, pub condition_cache: HashMap<(FlowId, InferConditionFlow), CacheEntry>, } diff --git a/crates/emmylua_code_analysis/src/semantic/infer/narrow/condition_flow/mod.rs b/crates/emmylua_code_analysis/src/semantic/infer/narrow/condition_flow/mod.rs index c88d17092..dba17e7fd 100644 --- a/crates/emmylua_code_analysis/src/semantic/infer/narrow/condition_flow/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic/infer/narrow/condition_flow/mod.rs @@ -121,6 +121,7 @@ pub(in crate::semantic) enum CorrelatedDiscriminantNarrow { #[derive(Debug, Clone)] pub(in crate::semantic) enum ConditionFlowAction { Continue, + Unreachable, Result(LuaType), Pending(PendingConditionNarrow), NeedExprType { @@ -712,12 +713,12 @@ pub(in crate::semantic::infer::narrow) fn resolve_expr_type_continuation( condition_flow, ), ExprTypeContinuation::Truthiness { condition_flow } => Ok(match condition_flow { - _ if expr_type.is_never() => ConditionFlowAction::Result(LuaType::Never), + _ if expr_type.is_never() => ConditionFlowAction::Unreachable, InferConditionFlow::TrueCondition if expr_type.is_always_falsy() => { - ConditionFlowAction::Result(LuaType::Never) + ConditionFlowAction::Unreachable } InferConditionFlow::FalseCondition if expr_type.is_always_truthy() => { - ConditionFlowAction::Result(LuaType::Never) + ConditionFlowAction::Unreachable } _ => ConditionFlowAction::Continue, }), diff --git a/crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs b/crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs index a29a511b3..5d9c4c85d 100644 --- a/crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs +++ b/crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs @@ -9,7 +9,7 @@ use crate::{ CacheEntry, DbIndex, FlowId, FlowNode, FlowNodeKind, FlowTree, InferFailReason, LuaDeclId, LuaInferCache, LuaMemberId, LuaSignatureId, LuaType, TypeOps, check_type_compact, semantic::{ - cache::{FlowAssignmentInfo, FlowMode, FlowVarCache}, + cache::{FlowAssignmentInfo, FlowMode, FlowQueryResult, FlowVarCache}, infer::{ InferResult, VarRefId, infer_name::infer_global_type, @@ -36,8 +36,7 @@ use crate::{ }; #[derive(Debug, Clone, PartialEq, Eq, Hash)] -// One cached flow query: one ref at one flow node, optionally without replaying -// pending condition narrows. +// One cached flow query: one ref at one flow node under one flow mode. // Example: "what is `x` at flow 42, with current guards applied?" struct FlowQuery { var_ref_id: VarRefId, @@ -52,7 +51,7 @@ impl FlowQuery { var_ref_id: var_ref_id.clone(), var_cache_idx: get_flow_cache_var_ref_id(cache, var_ref_id), flow_id, - mode: FlowMode::WithConditions, + mode: FlowMode::Normal, } } @@ -97,9 +96,17 @@ enum Continuation { AssignmentAntecedent { walk: QueryWalk, antecedent_flow_id: FlowId, + explicit_var_type: Option, expr_type: LuaType, reuse_antecedent_narrowing: bool, }, + // Resume a recovered RHS fallback after proving the branch can reach the + // assignment. Example: `x = config.missing` recovers `nil`, but an + // impossible branch must not contribute that fallback to a merge. + AssignmentFallback { + walk: QueryWalk, + fallback_type: LuaType, + }, // Resume structural expression replay after resolving the flow-aware refs // it depends on. The replay itself stays no-flow; only this continuation // may schedule the dependency queries. @@ -389,7 +396,7 @@ fn collect_expr_dependency_queries( // `StartQuery` begins one query, optionally saving the current query first. // `ContinueWalk` keeps scanning backward through the current query. // `ResumeNext(result)` pops one suspended query from `stack` and resumes it -// with the result of the dependency query that just finished. +// with the internal result of the dependency query that just finished. enum SchedulerStep { // Start or reuse one `(var_ref, flow_id, mode)` query. // If `continuation` is present, save that suspended query first so this @@ -408,9 +415,11 @@ enum SchedulerStep { // query result. // Example: after querying `shape.kind`, continue narrowing // `if shape.kind == "circle" then`. - ResumeNext(InferResult), + ResumeNext(FlowResult), } +type FlowResult = Result; + // Single owner of flow evaluation. Only this engine is allowed to schedule // follow-up queries, which keeps the flow path iterative. struct FlowTypeEngine<'a> { @@ -456,20 +465,37 @@ impl<'a> FlowTypeEngine<'a> { Some(Continuation::AssignmentAntecedent { walk, antecedent_flow_id, + explicit_var_type, expr_type, reuse_antecedent_narrowing, }) => self.resume_assignment_antecedent( walk, antecedent_flow_id, + explicit_var_type, expr_type, reuse_antecedent_narrowing, query_result, ), + Some(Continuation::AssignmentFallback { + walk, + fallback_type, + }) => match query_result { + Ok(FlowQueryResult::Unreachable) => { + Ok(self.finish_unreachable_branch(walk)) + } + Ok(FlowQueryResult::Type(_)) => Ok(self.finish_walk(walk, fallback_type)), + Err(err) => self.fail_query(&walk.query, err), + }, Some(Continuation::ExprReplay { walk, replay, replay_query, - }) => self.resume_expr_replay(walk, replay, replay_query, query_result), + }) => self.resume_expr_replay( + walk, + replay, + replay_query, + query_result.map(FlowQueryResult::into_type), + ), Some(Continuation::TagCastAntecedent { walk, cast_op_types, @@ -484,7 +510,7 @@ impl<'a> FlowTypeEngine<'a> { flow_id, condition_flow, subquery, - query_result, + query_result.map(FlowQueryResult::into_type), ), Some(Continuation::FieldLiteralSiblingDependency { walk, @@ -496,7 +522,7 @@ impl<'a> FlowTypeEngine<'a> { flow_id, condition_flow, subquery, - query_result, + query_result.map(FlowQueryResult::into_type), ), Some(Continuation::CorrelatedSearchRoot { walk, @@ -510,12 +536,12 @@ impl<'a> FlowTypeEngine<'a> { advance_pending_correlated_condition( self.db, pending_correlated_condition, - query_result, + query_result.map(FlowQueryResult::into_type), ), ), // No suspended query is waiting on this result, so it is the // final answer for the original `run(...)` request. - None => break query_result, + None => break query_result.map(FlowQueryResult::into_type), }, } .unwrap_or_else(|err| SchedulerStep::ResumeNext(Err(err))); @@ -534,7 +560,7 @@ impl<'a> FlowTypeEngine<'a> { .and_then(|var_cache| var_cache.type_cache.get(&type_cache_key)) { Ok(SchedulerStep::ResumeNext(match cache_entry { - CacheEntry::Cache(narrow_type) => Ok(narrow_type.clone()), + CacheEntry::Cache(query_result) => Ok(query_result.clone()), CacheEntry::Ready => Err(InferFailReason::RecursiveInfer), })) } else { @@ -568,10 +594,10 @@ impl<'a> FlowTypeEngine<'a> { branch_flow_ids: Arc<[FlowId]>, next_pending_idx: usize, merged_type: LuaType, - branch_result: InferResult, + branch_result: FlowResult, ) -> Result { let branch_type = match branch_result { - Ok(branch_type) => branch_type, + Ok(branch_result) => branch_result.into_type(), Err(err) => return self.fail_query(&walk.query, err), }; @@ -583,10 +609,15 @@ impl<'a> FlowTypeEngine<'a> { // Branches are resumed from the end because the initial merge setup // schedules the last incoming branch first. let branch_idx = next_pending_idx - 1; + // The remaining predecessor is still a merge contribution. Preserve + // MergeBranch so an unreachable condition edge contributes `never` + // instead of continuing to the type from before the merge. + let branch_mode = match walk.query.mode { + FlowMode::Normal | FlowMode::MergeBranch => FlowMode::MergeBranch, + FlowMode::IgnoreConditions => FlowMode::IgnoreConditions, + }; Ok(SchedulerStep::StartQuery { - query: walk - .query - .at_flow(branch_flow_ids[branch_idx], walk.query.mode), + query: walk.query.at_flow(branch_flow_ids[branch_idx], branch_mode), continuation: Some(Continuation::Merge { walk, branch_flow_ids, @@ -604,26 +635,44 @@ impl<'a> FlowTypeEngine<'a> { &mut self, walk: QueryWalk, antecedent_flow_id: FlowId, + explicit_var_type: Option, expr_type: LuaType, reuse_antecedent_narrowing: bool, - antecedent_result: InferResult, + antecedent_result: FlowResult, ) -> Result { let antecedent_type = match antecedent_result { - Ok(antecedent_type) => antecedent_type, + Ok(FlowQueryResult::Type(antecedent_type)) => antecedent_type, + Ok(FlowQueryResult::Unreachable) => { + return Ok(self.finish_unreachable_branch(walk)); + } Err(err) => return self.fail_query(&walk.query, err), }; + if let Some(explicit_var_type) = explicit_var_type { + let result_type = finish_assignment_result( + self.db, + self.cache, + &explicit_var_type, + &expr_type, + &walk.query.var_ref_id, + true, + Some(explicit_var_type.clone()), + ); + return Ok(self.finish_walk(walk, result_type)); + } + if reuse_antecedent_narrowing && !can_reuse_narrowed_assignment_source(self.db, &antecedent_type, &expr_type) { let next_query = walk .query - .at_flow(antecedent_flow_id, FlowMode::WithoutConditions); + .at_flow(antecedent_flow_id, FlowMode::IgnoreConditions); return Ok(SchedulerStep::StartQuery { query: next_query, continuation: Some(Continuation::AssignmentAntecedent { walk, antecedent_flow_id, + explicit_var_type: None, expr_type, reuse_antecedent_narrowing: false, }), @@ -818,10 +867,13 @@ impl<'a> FlowTypeEngine<'a> { &mut self, walk: QueryWalk, cast_op_types: Vec, - antecedent_result: InferResult, + antecedent_result: FlowResult, ) -> Result { let mut cast_input_type = match antecedent_result { - Ok(resolved_type) => resolved_type, + Ok(FlowQueryResult::Type(resolved_type)) => resolved_type, + Ok(FlowQueryResult::Unreachable) => { + return Ok(self.finish_unreachable_branch(walk)); + } // `---@cast` is an explicit assertion, so unresolved source types // should still be narrowed by applying the cast from `unknown`. Err(_) => LuaType::Unknown, @@ -1100,6 +1152,24 @@ impl<'a> FlowTypeEngine<'a> { expr_type: LuaType, ) -> Result { if let Some(explicit_var_type) = explicit_var_type { + if matches!(walk.query.mode, FlowMode::MergeBranch) { + // The annotation still supplies the assignment source type, but + // this branch must first prove it can reach the assignment. + let subquery = walk + .query + .at_flow(antecedent_flow_id, FlowMode::MergeBranch); + return Ok(SchedulerStep::StartQuery { + query: subquery, + continuation: Some(Continuation::AssignmentAntecedent { + walk, + antecedent_flow_id, + explicit_var_type: Some(explicit_var_type), + expr_type, + reuse_antecedent_narrowing: true, + }), + }); + } + let var_ref_id = walk.query.var_ref_id.clone(); let result_type = finish_assignment_result( self.db, @@ -1116,14 +1186,17 @@ impl<'a> FlowTypeEngine<'a> { // Broad RHS types replace the previous runtime type. The old path still // queried the antecedent and then discarded it in finish_assignment_result. let reuse_antecedent_narrowing = preserves_assignment_expr_type(&expr_type); - if !expr_type.is_unknown() && !reuse_antecedent_narrowing { + if !expr_type.is_unknown() + && !reuse_antecedent_narrowing + && !matches!(walk.query.mode, FlowMode::MergeBranch) + { return Ok(self.finish_walk(walk, expr_type)); } - let mode = if reuse_antecedent_narrowing { - FlowMode::WithConditions - } else { - FlowMode::WithoutConditions + let mode = match (walk.query.mode, reuse_antecedent_narrowing) { + (FlowMode::MergeBranch, _) => FlowMode::MergeBranch, + (_, true) => FlowMode::Normal, + (_, false) => FlowMode::IgnoreConditions, }; let subquery = walk.query.at_flow(antecedent_flow_id, mode); Ok(SchedulerStep::StartQuery { @@ -1131,6 +1204,7 @@ impl<'a> FlowTypeEngine<'a> { continuation: Some(Continuation::AssignmentAntecedent { walk, antecedent_flow_id, + explicit_var_type: None, expr_type, reuse_antecedent_narrowing, }), @@ -1145,26 +1219,45 @@ impl<'a> FlowTypeEngine<'a> { err: InferFailReason, ) -> Result { if let Some(explicit_var_type) = explicit_var_type { - return Ok(self.finish_walk(walk, explicit_var_type)); + return self.finish_assignment_expr_type( + walk, + antecedent_flow_id, + Some(explicit_var_type), + LuaType::Unknown, + ); } let var_ref_id = walk.query.var_ref_id.clone(); - if matches!(var_ref_id, VarRefId::IndexRef(_, _)) + let fallback_type = if matches!(var_ref_id, VarRefId::IndexRef(_, _)) && let Ok(origin_type) = get_var_ref_type(self.db, self.cache, &var_ref_id) { let non_nil_origin = TypeOps::Remove.apply(self.db, &origin_type, &LuaType::Nil); - return Ok(self.finish_walk( - walk, - if non_nil_origin.is_never() { - origin_type - } else { - non_nil_origin - }, - )); - } + Some(if non_nil_origin.is_never() { + origin_type + } else { + non_nil_origin + }) + } else if matches!(err, InferFailReason::FieldNotFound | InferFailReason::None) { + Some(LuaType::Nil) + } else { + None + }; - if matches!(err, InferFailReason::FieldNotFound | InferFailReason::None) { - return Ok(self.finish_walk(walk, LuaType::Nil)); + if let Some(fallback_type) = fallback_type { + if matches!(walk.query.mode, FlowMode::MergeBranch) { + let query = walk + .query + .at_flow(antecedent_flow_id, FlowMode::MergeBranch); + return Ok(SchedulerStep::StartQuery { + query, + continuation: Some(Continuation::AssignmentFallback { + walk, + fallback_type, + }), + }); + } + + return Ok(self.finish_walk(walk, fallback_type)); } walk.antecedent_flow_id = antecedent_flow_id; @@ -1179,7 +1272,7 @@ impl<'a> FlowTypeEngine<'a> { condition_flow: InferConditionFlow, ) -> Result { let antecedent_flow_id = get_single_antecedent(flow_node)?; - if !walk.query.mode.uses_conditions() { + if matches!(walk.query.mode, FlowMode::IgnoreConditions) { walk.antecedent_flow_id = antecedent_flow_id; return Ok(SchedulerStep::ContinueWalk(walk)); } @@ -1233,7 +1326,10 @@ impl<'a> FlowTypeEngine<'a> { if cached_action { return match action { ConditionFlowAction::Continue => Ok(SchedulerStep::ContinueWalk(walk)), - ConditionFlowAction::Result(result_type) => Ok(self.finish_walk(walk, result_type)), + ConditionFlowAction::Unreachable => Ok(self.finish_unreachable_condition(walk)), + ConditionFlowAction::Result(result_type) => { + Ok(self.finish_condition_result(walk, result_type)) + } ConditionFlowAction::Pending(pending_condition_narrow) => { let mut walk = walk; walk.pending_condition_narrows @@ -1275,9 +1371,14 @@ impl<'a> FlowTypeEngine<'a> { } let antecedent_flow_id = get_single_antecedent(flow_node)?; - let subquery = walk - .query - .at_flow(antecedent_flow_id, FlowMode::WithConditions); + let mode = match walk.query.mode { + FlowMode::MergeBranch => FlowMode::MergeBranch, + // IgnoreConditions belongs to the assignment source retry. A tag + // cast dependency is its own point query, so it should use normal + // condition handling unless this is still a merge contribution. + FlowMode::Normal | FlowMode::IgnoreConditions => FlowMode::Normal, + }; + let subquery = walk.query.at_flow(antecedent_flow_id, mode); Ok(SchedulerStep::StartQuery { query: subquery, continuation: Some(Continuation::TagCastAntecedent { @@ -1315,13 +1416,28 @@ impl<'a> FlowTypeEngine<'a> { } else { Arc::<[FlowId]>::from(get_multi_antecedents(self.tree, flow_node)?) }; - let Some(next_pending_idx) = branch_flow_ids.len().checked_sub(1) else { - return Ok(self.finish_walk(walk, LuaType::Never)); - }; + match branch_flow_ids.len() { + 0 => return Ok(self.finish_unreachable_branch(walk)), + // A single predecessor is just a branch-entry label, not a merge. + // Keep the current mode so queries inside an impossible then/else + // body can still resolve unrelated symbols from their declarations. + 1 => { + walk.antecedent_flow_id = branch_flow_ids[0]; + continue; + } + _ => {} + } + let next_pending_idx = branch_flow_ids.len() - 1; let q = &walk.query; - let next_query = q.at_flow(branch_flow_ids[next_pending_idx], q.mode); + // Multiple predecessors make this a merge. Query each + // predecessor as a merge contribution so an unreachable + // condition edge contributes `never` to the final union. + let branch_mode = match q.mode { + FlowMode::Normal | FlowMode::MergeBranch => FlowMode::MergeBranch, + FlowMode::IgnoreConditions => FlowMode::IgnoreConditions, + }; return Ok(SchedulerStep::StartQuery { - query: next_query, + query: q.at_flow(branch_flow_ids[next_pending_idx], branch_mode), continuation: Some(Continuation::Merge { walk, branch_flow_ids, @@ -1453,6 +1569,15 @@ impl<'a> FlowTypeEngine<'a> { ); Ok(SchedulerStep::ContinueWalk(walk)) } + ConditionFlowAction::Unreachable => { + get_flow_var_cache(self.cache, walk.query.var_cache_idx) + .condition_cache + .insert( + (flow_id, condition_flow), + CacheEntry::Cache(ConditionFlowAction::Unreachable), + ); + Ok(self.finish_unreachable_condition(walk)) + } ConditionFlowAction::Result(result_type) => { get_flow_var_cache(self.cache, walk.query.var_cache_idx) .condition_cache @@ -1460,7 +1585,7 @@ impl<'a> FlowTypeEngine<'a> { (flow_id, condition_flow), CacheEntry::Cache(ConditionFlowAction::Result(result_type.clone())), ); - Ok(self.finish_walk(walk, result_type)) + Ok(self.finish_condition_result(walk, result_type)) } ConditionFlowAction::Pending(pending_condition_narrow) => self.start_pending_condition( walk, @@ -1498,9 +1623,16 @@ impl<'a> FlowTypeEngine<'a> { self.start_field_literal_sibling_subquery(walk, flow_id, condition_flow, subquery) ), ConditionFlowAction::NeedCorrelated(pending_correlated_condition) => { + let mode = match walk.query.mode { + FlowMode::MergeBranch => FlowMode::MergeBranch, + // IgnoreConditions belongs to the assignment source retry. + // Correlated-condition dependencies need normal condition + // handling unless this is still a merge contribution. + FlowMode::Normal | FlowMode::IgnoreConditions => FlowMode::Normal, + }; let subquery = walk.query.at_flow( pending_correlated_condition.current_search_root_flow_id, - FlowMode::WithConditions, + mode, ); Ok(SchedulerStep::StartQuery { query: subquery, @@ -1515,6 +1647,39 @@ impl<'a> FlowTypeEngine<'a> { } } + fn finish_unreachable_condition(&mut self, walk: QueryWalk) -> SchedulerStep { + // Unreachable describes the condition edge, not the queried symbol. A + // merge query asks what this branch contributes, so the answer is + // `never`; point queries continue to the antecedent so unrelated + // symbols inside impossible branches keep their declared types. + match walk.query.mode { + FlowMode::MergeBranch => self.finish_unreachable_branch(walk), + FlowMode::Normal | FlowMode::IgnoreConditions => SchedulerStep::ContinueWalk(walk), + } + } + + fn finish_condition_result(&mut self, walk: QueryWalk, result_type: LuaType) -> SchedulerStep { + // Condition results describe a guard edge. If the guard removes every + // possible type while calculating a merge contribution, the branch is + // unreachable rather than an ordinary `never`-typed value. + if matches!(walk.query.mode, FlowMode::MergeBranch) && result_type.is_never() { + self.finish_unreachable_branch(walk) + } else { + self.finish_walk(walk, result_type) + } + } + + fn finish_unreachable_branch(&mut self, walk: QueryWalk) -> SchedulerStep { + let query = walk.query; + get_flow_var_cache(self.cache, query.var_cache_idx) + .type_cache + .insert( + (query.flow_id, query.mode), + CacheEntry::Cache(FlowQueryResult::Unreachable), + ); + SchedulerStep::ResumeNext(Ok(FlowQueryResult::Unreachable)) + } + fn finish_walk(&mut self, walk: QueryWalk, narrow_type: LuaType) -> SchedulerStep { let QueryWalk { query, @@ -1522,18 +1687,28 @@ impl<'a> FlowTypeEngine<'a> { .. } = walk; let mut final_type = narrow_type; - if query.mode.uses_conditions() { + let mut condition_made_unreachable = false; + if !matches!(query.mode, FlowMode::IgnoreConditions) { for pending_condition_narrow in pending_condition_narrows.into_iter().rev() { + let was_possible = !final_type.is_never(); final_type = pending_condition_narrow.apply(self.db, self.cache, final_type); + condition_made_unreachable |= matches!(query.mode, FlowMode::MergeBranch) + && was_possible + && final_type.is_never(); } } + let query_result = if condition_made_unreachable { + FlowQueryResult::Unreachable + } else { + FlowQueryResult::Type(final_type) + }; get_flow_var_cache(self.cache, query.var_cache_idx) .type_cache .insert( (query.flow_id, query.mode), - CacheEntry::Cache(final_type.clone()), + CacheEntry::Cache(query_result.clone()), ); - SchedulerStep::ResumeNext(Ok(final_type)) + SchedulerStep::ResumeNext(Ok(query_result)) } fn fail_query(