From cb8b58d99ecbaacc5e856a4a82489c393a06a69f Mon Sep 17 00:00:00 2001 From: Marek Langiewicz Date: Sun, 25 Oct 2026 02:37:17 +0200 Subject: [PATCH] fix(flow): the false branch of a member test keeps owners whose member can be falsy `filter_type_by_member_truthiness` asked one question for both branches: can the member be truthy? The false branch then dropped every owner whose member can be truthy, even a `boolean` that can also be false. After `if res and res.ok then return end` with `res: Res?`, `res` was `nil`. The false branch now keeps an owner unless its member is always truthy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018NxUUQmc3YFNEXrUcrVPxV --- .../src/semantic_model/flow.rs | 10 ++- .../semantic_model/infer/legacy_tests/flow.rs | 61 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/emmylua_code_analysis/src/semantic_model/flow.rs b/crates/emmylua_code_analysis/src/semantic_model/flow.rs index 77b53917f..aeb6facc5 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/flow.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/flow.rs @@ -5517,8 +5517,14 @@ fn filter_type_by_member_truthiness( } // Missing member is equivalent to nil: `obj.missing` is false. let member_ty = model.member_type(&component, key).unwrap_or(LuaType::Nil); - let truthy = type_is_truthy(&member_ty); - if truthy == keep_matching { + // The true branch keeps a component whose member can be truthy, the false branch one + // whose member can be falsy (a `boolean` member can be either). + let possible = if keep_matching { + type_is_truthy(&member_ty) + } else { + !member_ty.is_always_truthy() + }; + if possible { kept.push(component); } } diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow.rs index c1fbc0733..a67d0885b 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/flow.rs @@ -4943,4 +4943,65 @@ _ = a[2] let ty = ws.expr_ty("B"); assert_eq!(ws.humanize_type(ty), "string"); } + + /// The false branch of a member test keeps an owner whose member can be falsy, and drops one + /// whose member is always truthy. + #[test] + fn test_member_false_branch_keeps_owner_whose_member_can_be_falsy() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@class Res + ---@field ok boolean + ---@field maybe boolean? + ---@field name string + + ---@param res Res? + local function and_guard(res) + if res and res.ok then return end + AND_GUARD = res + end + + ---@param res Res? + local function nested(res) + if res then + if res.ok then return end + end + NESTED = res + end + + ---@param res Res? + local function negated(res) + if not (res and res.ok) then + NEGATED = res + end + end + + ---@param res Res? + local function optional_member(res) + if res and res.maybe then return end + OPTIONAL_MEMBER = res + end + + ---@param res Res + local function plain_owner(res) + if res.ok then return end + PLAIN_OWNER = res + end + + ---@param res Res? + local function always_truthy_member(res) + if res and res.name then return end + ALWAYS_TRUTHY_MEMBER = res + end + "#, + ); + + assert_eq!(ws.expr_ty("AND_GUARD"), ws.ty("Res?")); + assert_eq!(ws.expr_ty("NESTED"), ws.ty("Res?")); + assert_eq!(ws.expr_ty("NEGATED"), ws.ty("Res?")); + assert_eq!(ws.expr_ty("OPTIONAL_MEMBER"), ws.ty("Res?")); + assert_eq!(ws.expr_ty("PLAIN_OWNER"), ws.ty("Res")); + assert_eq!(ws.expr_ty("ALWAYS_TRUTHY_MEMBER"), ws.ty("nil")); + } }