diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..d38543e9 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -31680,9 +31680,12 @@ return None; comp.tern_chain_t = None; // 3.12+ inline filters come in two layouts: // - INVERTED (same-line 3.12; ALL of 3.13/3.14): the - // jump lands ON the append (target is - // LIST_APPEND/SET_ADD/MAP_ADD/YIELD after pads) and - // the fall-through is the skip back edge. The jump + // jump accepts this filter, either at the next filter + // or the append, while fall-through skips the item. + // Recognize the exact pad/back-edge span first: scanning + // from the target can encounter a later filter's skip + // before reaching the append and invert this filter. + // The jump // polarity IS the filter polarity: PJT->`if C`, // PJF->`if not C`. // - CLASSIC (3.12 line-broken filters): PJF->shared @@ -31699,7 +31702,22 @@ return None; // loop TOP, and scanning from there would wrap around // and find the append after the filter again // (_py_abc 3.12 __new__ line-broken setcomp) - let target_is_append = target > self.cur_offset + let fallthrough_skips_item = self.version.at_least(3, 12) + && target > self.cur_offset + && target < comp.end + && self.idx_of.get(&self.cur_offset).zip(self.idx_of.get(&target)) + .map_or(false, |(&ci, &ti)| { + if ti <= ci { return false; } + let mut significant = self.instrs[ci + 1..ti].iter() + .filter(|x| !matches!(x.op, Op::NOP | Op::NOT_TAKEN | Op::CACHE)); + significant.next().map_or(false, |x| { + x.is_backward + && matches!(x.op, Op::JUMP_BACKWARD | Op::JUMP_BACKWARD_NO_INTERRUPT + | Op::JUMP_ABSOLUTE | Op::JUMP) + && x.target == comp.for_iter_offsets.last().copied() + }) && significant.next().is_none() + }); + let target_accepts_item = fallthrough_skips_item || (target > self.cur_offset && self .idx_of .get(&target) @@ -31734,8 +31752,8 @@ return None; | Op::YIELD_VALUE ) }) - }); - let c = if target_is_append { + })); + let c = if target_accepts_item { if jump_if_true { cond } else { @@ -31755,7 +31773,7 @@ return None; cond }; if let Some(cur) = &mut comp.cur { - if comp_if_break && !target_is_append { + if comp_if_break && !target_accepts_item { cur.if_break = true; } cur.ifs.push(c); diff --git a/tests/behavior/cases/b_chained_comprehension_filters.py b/tests/behavior/cases/b_chained_comprehension_filters.py new file mode 100644 index 00000000..0320c450 --- /dev/null +++ b/tests/behavior/cases/b_chained_comprehension_filters.py @@ -0,0 +1,25 @@ +# MIN_VERSION: 3.12 + + +def methods_equal(rows, wanted): + return [row.tag for row in rows if row.value == wanted if row.ready() if row.final()] + + +def methods_unequal(rows, wanted): + return [row.tag for row in rows if row.value != wanted if not row.ready() if row.final()] + + +class Row: + def __init__(self, tag, value, ready): + self.tag = tag + self.value = value + self.available = ready + def ready(self): + print("ready", self.tag) + return self.available + def final(self): + print("final", self.tag) + return True +rows = [Row("a", 1, True), Row("b", 2, True), Row("c", 1, False)] +print(methods_equal(rows, 1)) +print(methods_unequal(rows, 2)) diff --git a/tests/fixtures/chained_comprehension_filters.py b/tests/fixtures/chained_comprehension_filters.py new file mode 100644 index 00000000..e3d73b5e --- /dev/null +++ b/tests/fixtures/chained_comprehension_filters.py @@ -0,0 +1,61 @@ +def list_positive_separate(values): + return [value for value in values if isinstance(value, str) if value] + + +def list_positive_joined(values): + return [value for value in values if isinstance(value, str) and value] + + +def list_negative_separate(values): + return [value for value in values if not isinstance(value, str) if value] + + +def list_negative_joined(values): + return [value for value in values if not isinstance(value, str) and value] + + +def set_positive_separate(values): + return {value for value in values if isinstance(value, str) if value} + + +def set_positive_joined(values): + return {value for value in values if isinstance(value, str) and value} + + +def set_negative_separate(values): + return {value for value in values if not isinstance(value, str) if value} + + +def set_negative_joined(values): + return {value for value in values if not isinstance(value, str) and value} + + +def dict_positive_separate(values): + return {value: value for value in values if isinstance(value, str) if value} + + +def dict_positive_joined(values): + return {value: value for value in values if isinstance(value, str) and value} + + +def dict_negative_separate(values): + return {value: value for value in values if not isinstance(value, str) if value} + + +def dict_negative_joined(values): + return {value: value for value in values if not isinstance(value, str) and value} + + +def methods_equal(rows, wanted): + return [row.tag for row in rows if row.value == wanted if row.ready() if row.final()] + + +def methods_unequal(rows, wanted): + return [row.tag for row in rows if row.value != wanted if not row.ready() if row.final()] + + +def broken_lines(values): + return [value + for value in values + if isinstance(value, str) + if value] diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..c4b834d3 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,40 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn chained_comprehension_filters_keep_each_polarity() { + for version in ["3.11", "3.12", "3.13", "3.14"] { + let fixture = format!("chained_comprehension_filters.{version}.pyc"); + let (src, clean) = decompile_fixture(&fixture); + assert!(clean, "{fixture}: output marked incomplete:\n{src}"); + for (kind, open, close, element) in [ + ("list", "[", "]", "value"), + ("set", "{", "}", "value"), + ("dict", "{", "}", "value: value"), + ] { + for (polarity, negation) in [("positive", ""), ("negative", "not ")] { + for join in ["separate", "joined"] { + let expected = format!( + "def {kind}_{polarity}_{join}(values):\n return {open}{element} for value in values if {negation}isinstance(value, str) if value{close}" + ); + assert_contains(&src, &[&expected], &fixture); + } + } + } + assert_contains( + &src, + &[ + "return [row.tag for row in rows if row.value == wanted if row.ready() if row.final()]", + "return [row.tag for row in rows if row.value != wanted if not row.ready() if row.final()]", + ], + &fixture, + ); + let normalized = src.split_whitespace().collect::>().join(" "); + assert_contains( + &normalized, + &["def broken_lines(values): return [value for value in values if isinstance(value, str) if value]"], + &fixture, + ); + } +} diff --git a/tests/pyc/chained_comprehension_filters.3.11.pyc b/tests/pyc/chained_comprehension_filters.3.11.pyc new file mode 100644 index 00000000..c1ed1162 Binary files /dev/null and b/tests/pyc/chained_comprehension_filters.3.11.pyc differ diff --git a/tests/pyc/chained_comprehension_filters.3.12.pyc b/tests/pyc/chained_comprehension_filters.3.12.pyc new file mode 100644 index 00000000..4f795f2c Binary files /dev/null and b/tests/pyc/chained_comprehension_filters.3.12.pyc differ diff --git a/tests/pyc/chained_comprehension_filters.3.13.pyc b/tests/pyc/chained_comprehension_filters.3.13.pyc new file mode 100644 index 00000000..278ea6af Binary files /dev/null and b/tests/pyc/chained_comprehension_filters.3.13.pyc differ diff --git a/tests/pyc/chained_comprehension_filters.3.14.pyc b/tests/pyc/chained_comprehension_filters.3.14.pyc new file mode 100644 index 00000000..d1b07c59 Binary files /dev/null and b/tests/pyc/chained_comprehension_filters.3.14.pyc differ