diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..00db2001 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -31768,10 +31768,30 @@ return None; // elif test would create a nested If at the same target, so keep // the Else open in that case. if let Some(top) = self.blocks.last() { + // A modern Else opened immediately after the then arm's + // unconditional skip has an authoritative finite span. A guard + // inside that span can share its merge without ending the Else: + // closing it here ejects the guarded query result and may read + // its local on the other arm before assignment. Keep older and + // inferred/try-else boundaries on their existing path. + let guarded_else_merge = self.version.at_least(3, 11) + && top.kind == BlockType::Else + && self.cur_offset > top.start + && self.cur_offset < top.end + && self.idx_of.get(&top.start).map_or(false, |&si| { + si > 0 && { + let skip = &self.instrs[si - 1]; + !skip.is_backward + && skip.end() == top.start + && skip.target == Some(top.end) + && matches!(skip.op, Op::JUMP_FORWARD | Op::JUMP | Op::JUMP_ABSOLUTE) + } + }); if matches!(top.kind, BlockType::Else | BlockType::TryElse) && top.end == target && !top.is_elif && self.cur_offset != top.start + && !guarded_else_merge { self.close_blocks_at(target); } diff --git a/tests/behavior/cases/b_guarded_else_merge.py b/tests/behavior/cases/b_guarded_else_merge.py new file mode 100644 index 00000000..a2692415 --- /dev/null +++ b/tests/behavior/cases/b_guarded_else_merge.py @@ -0,0 +1,29 @@ +# MIN_VERSION: 3.11 +import asyncio + +async def awaited(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value + + +class Owner: + def __init__(self, active, row): + self.active = active + self.row = row + async def fetch(self): + print("fetch", self.row) + return self.row + def record(self, value): + print("record", value) +print(asyncio.run(awaited(Owner(True, (9,))))) +print(asyncio.run(awaited(Owner(False, (9,))))) +print(asyncio.run(awaited(Owner(False, (None,))))) +print(asyncio.run(awaited(Owner(False, None)))) diff --git a/tests/fixtures/guarded_else_merge.py b/tests/fixtures/guarded_else_merge.py new file mode 100644 index 00000000..96521e49 --- /dev/null +++ b/tests/fixtures/guarded_else_merge.py @@ -0,0 +1,67 @@ +# Versioned .pyc files are consumed by tests/integration.rs. + +# The finite else-span guard applies to Python 3.11+. + +async def awaited(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value + + +async def synchronous(owner): + value = None + active = owner.active + if active: + value = float(1) + else: + row = owner.fetch() + if row and row[0] is not None: + value = 2 + owner.record(value) + return value + + +async def attribute(owner): + value = None + active = owner.active + if active: + value = float(active['total']) + else: + row = owner.row + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value + + +async def following_else_work(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = 2 + owner.record('queried') + owner.record(value) + return value + + +async def independent(owner): + value = None + row = None + if owner.active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = 2 + return value diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..3a67b988 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,68 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn finite_else_span_keeps_its_inner_guard() { + for version in ["3.11", "3.12", "3.13", "3.14"] { + let (src, clean) = decompile_fixture(&format!("guarded_else_merge.{version}.pyc")); + assert!(clean, "Python {version}: output marked incomplete:\n{src}"); + assert_contains(&src, &[ + r#"async def awaited(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value"#, + r#"async def synchronous(owner): + value = None + active = owner.active + if active: + value = float(1) + else: + row = owner.fetch() + if row and row[0] is not None: + value = 2 + owner.record(value) + return value"#, + r#"async def attribute(owner): + value = None + active = owner.active + if active: + value = float(active['total']) + else: + row = owner.row + if row and row[0] is not None: + value = float(row[0]) + owner.record(value) + return value"#, + r#"async def following_else_work(owner): + value = None + active = owner.active + if active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = 2 + owner.record('queried') + owner.record(value) + return value"#, + r#"async def independent(owner): + value = None + row = None + if owner.active: + value = 1 + else: + row = await owner.fetch() + if row and row[0] is not None: + value = 2 + return value"#, + ], version); + } +} diff --git a/tests/pyc/guarded_else_merge.3.11.pyc b/tests/pyc/guarded_else_merge.3.11.pyc new file mode 100644 index 00000000..226ca408 Binary files /dev/null and b/tests/pyc/guarded_else_merge.3.11.pyc differ diff --git a/tests/pyc/guarded_else_merge.3.12.pyc b/tests/pyc/guarded_else_merge.3.12.pyc new file mode 100644 index 00000000..9d8e7958 Binary files /dev/null and b/tests/pyc/guarded_else_merge.3.12.pyc differ diff --git a/tests/pyc/guarded_else_merge.3.13.pyc b/tests/pyc/guarded_else_merge.3.13.pyc new file mode 100644 index 00000000..1c15b4af Binary files /dev/null and b/tests/pyc/guarded_else_merge.3.13.pyc differ diff --git a/tests/pyc/guarded_else_merge.3.14.pyc b/tests/pyc/guarded_else_merge.3.14.pyc new file mode 100644 index 00000000..6ea01b6c Binary files /dev/null and b/tests/pyc/guarded_else_merge.3.14.pyc differ