diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..49bef2dc 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -42539,9 +42539,39 @@ fn cmp_from_index(idx: usize) -> CmpOp { // ===================== stack events, calls, functions, classes ===================== impl<'a> Ctx<'a> { + fn discarded_unpack_target(&self, remaining: usize) -> ExprRef { + // Repeated local targets can have their earlier stores replaced by + // POP_TOP. Their names may then be obfuscated while the surviving + // store keeps its slot. Reuse the next direct local store's spelling: + // the compiler elides the duplicate instead of creating an extra `_` + // local. Do not cross nested unpacking, side effects or control flow. + let name = if self.version.at_least(3, 13) { + self.idx_of.get(&self.cur_offset).and_then(|&index| { + let mut discarded = 1usize; + for next in &self.instrs[index + 1..] { + match next.op { + Op::NOP | Op::CACHE | Op::EXTENDED_ARG | Op::NOT_TAKEN => {}, + Op::POP_TOP => { + discarded += 1; + if discarded >= remaining { return None; } + } + Op::STORE_FAST => return self.code.varnames.get(next.arg as usize).cloned(), + Op::STORE_FAST_STORE_FAST | Op::STORE_FAST_LOAD_FAST => { + return self.code.varnames.get(((next.arg >> 4) & 0xF) as usize).cloned(); + } + Op::STORE_DEREF => return self.code.deref_name(next.arg as usize).map(str::to_string), + _ => return None, + } + } + None + }) + } else { None }; + self.name_expr(name.as_deref().unwrap_or("_")) + } + fn handle_pop_top(&mut self) { - // 3.14 `_` elision: the compiler replaces the store of a - // throwaway `_` name inside a tuple unpack with a POP_TOP + // 3.13+ repeated-target elision: the compiler replaces an + // overwritten local store inside a tuple unpack with a POP_TOP // discarding that slot (UNPACK_SEQUENCE; POP_TOP; // STORE_FAST_STORE_FAST). The open unpack frame must consume // the slot as a placeholder target or the tuple never @@ -42552,8 +42582,9 @@ impl<'a> Ctx<'a> { if *remaining > 0 { let is_slot = matches!(self.stack.last(), Some(Sv::E(e)) if Rc::ptr_eq(e, fv)); if is_slot { + let target = self.discarded_unpack_target(*remaining); if let Some(Sv::E(_)) = self.stack.pop() { - self.collect_unpack_target(self.name_expr("_")); + self.collect_unpack_target(target); } return; } diff --git a/tests/behavior/cases/b_repeated_unpack_bindings.py b/tests/behavior/cases/b_repeated_unpack_bindings.py new file mode 100644 index 00000000..40f05f9b --- /dev/null +++ b/tests/behavior/cases/b_repeated_unpack_bindings.py @@ -0,0 +1,31 @@ +# MIN_VERSION: 3.13 + + +def underscore_loop(values): + result = [] + for value, _, discarded, discarded in values: + result.append((value, discarded, _)) + return result + + +def underscore_list(values): + return [(value, discarded, _) for value, _, discarded, discarded in values] + + +def nested_loop(values): + result = [] + for value, (discarded, discarded) in values: + result.append((value, discarded)) + return result + + +def nested_list(values): + return [(value, discarded) for value, (discarded, discarded) in values] + + +rows = [(1, 2, 3, 4), (5, 6, 7, 8)] +print(underscore_loop(rows)) +print(underscore_list(rows)) +nested = [(1, (2, 3)), (4, (5, 6))] +print(nested_loop(nested)) +print(nested_list(nested)) diff --git a/tests/fixtures/repeated_unpack_bindings.py b/tests/fixtures/repeated_unpack_bindings.py new file mode 100644 index 00000000..2b97c72e --- /dev/null +++ b/tests/fixtures/repeated_unpack_bindings.py @@ -0,0 +1,35 @@ +# Versioned .pyc files are consumed by tests/integration.rs. + +# CPython 3.13 starts replacing overwritten stores with POP_TOP. + +def repeated_loop(values): + result = [] + for value, discarded, discarded, discarded in values: + result.append(value is not None) + return result + + +def repeated_list(values): + return [value is not None for value, discarded, discarded, discarded in values] + + +def underscore_loop(values): + result = [] + for value, _, discarded, discarded in values: + result.append((value, discarded, _)) + return result + + +def underscore_list(values): + return [(value, discarded, _) for value, _, discarded, discarded in values] + + +def nested_loop(values): + result = [] + for value, (discarded, discarded) in values: + result.append((value, discarded)) + return result + + +def nested_list(values): + return [(value, discarded) for value, (discarded, discarded) in values] diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..0eb2f0be 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,34 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn overwritten_unpack_slots_reuse_the_surviving_binding() { + for version in ["3.12", "3.13", "3.14"] { + let (src, clean) = decompile_fixture(&format!("repeated_unpack_bindings.{version}.pyc")); + assert!(clean, "Python {version}: output marked incomplete:\n{src}"); + assert_contains(&src, &[ + r#"def repeated_loop(values): + result = [] + for value, discarded, discarded, discarded in values: + result.append(value is not None) + return result"#, + r#"def repeated_list(values): + return [value is not None for value, discarded, discarded, discarded in values]"#, + r#"def underscore_loop(values): + result = [] + for value, _, discarded, discarded in values: + result.append((value, discarded, _)) + return result"#, + r#"def underscore_list(values): + return [(value, discarded, _) for value, _, discarded, discarded in values]"#, + r#"def nested_loop(values): + result = [] + for value, (discarded, discarded) in values: + result.append((value, discarded)) + return result"#, + r#"def nested_list(values): + return [(value, discarded) for value, (discarded, discarded) in values]"#, + ], version); + } +} diff --git a/tests/pyc/repeated_unpack_bindings.3.12.pyc b/tests/pyc/repeated_unpack_bindings.3.12.pyc new file mode 100644 index 00000000..3b14a1b6 Binary files /dev/null and b/tests/pyc/repeated_unpack_bindings.3.12.pyc differ diff --git a/tests/pyc/repeated_unpack_bindings.3.13.pyc b/tests/pyc/repeated_unpack_bindings.3.13.pyc new file mode 100644 index 00000000..de827138 Binary files /dev/null and b/tests/pyc/repeated_unpack_bindings.3.13.pyc differ diff --git a/tests/pyc/repeated_unpack_bindings.3.14.pyc b/tests/pyc/repeated_unpack_bindings.3.14.pyc new file mode 100644 index 00000000..b94832cd Binary files /dev/null and b/tests/pyc/repeated_unpack_bindings.3.14.pyc differ