Skip to content

Commit 28c5490

Browse files
committed
Python: Parenthesize template results based on surrounding context
Template.apply() now checks whether the returned expression needs parentheses when placed in the surrounding AST context, mirroring JavaTemplate.doApply()'s call to ParenthesizeVisitor.maybeParenthesize. Restructures Template.apply() into three phases: 1. Placeholder substitution 2. Precedence-based parenthesization (new) 3. Coordinate application (prefix, statement wrapping, formatting)
1 parent 78d3500 commit 28c5490

4 files changed

Lines changed: 313 additions & 15 deletions

File tree

rewrite-python/rewrite/src/rewrite/python/template/engine.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def apply_substitutions(
139139

140140
# Apply coordinates (wrap in statement if needed, etc.)
141141
if coordinates and result is not None:
142-
result = cls._apply_coordinates(result, cursor, coordinates)
142+
result = cls.apply_coordinates(result, cursor, coordinates)
143143

144144
return result
145145

@@ -286,7 +286,7 @@ def _extract_from_method_body(cls, method: j.MethodDeclaration, is_expression: b
286286
return statements[0]
287287

288288
@classmethod
289-
def _apply_coordinates(
289+
def apply_coordinates(
290290
cls,
291291
result: J,
292292
cursor: 'Cursor',

rewrite-python/rewrite/src/rewrite/python/template/replacement.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from .placeholder import from_placeholder
2828

2929
if TYPE_CHECKING:
30-
pass
30+
from rewrite.visitor import Cursor
3131

3232

3333
# Operator precedence for Python binary operators (higher number = higher precedence).
@@ -107,6 +107,36 @@ def _needs_parens_under_not(child: J) -> bool:
107107
return child_prec < 3
108108

109109

110+
def maybe_parenthesize(result: J, cursor: 'Cursor') -> J:
111+
"""Wrap *result* in parentheses when its precedence is lower than the
112+
surrounding context (the cursor's parent tree node).
113+
114+
This mirrors ``ParenthesizeVisitor.maybeParenthesize`` in the Java
115+
``JavaTemplate`` implementation.
116+
"""
117+
if not isinstance(result, (j.Binary, py.Binary, j.Unary)):
118+
return result
119+
120+
parent_cursor = cursor.parent_tree_cursor()
121+
parent = parent_cursor.value
122+
if not isinstance(parent, J):
123+
return result
124+
125+
# Parent is a binary operator — check precedence
126+
if isinstance(parent, (j.Binary, py.Binary)):
127+
parent_prec = _get_precedence(parent)
128+
result_prec = _get_precedence(result)
129+
if parent_prec is not None and result_prec is not None and result_prec < parent_prec:
130+
return _wrap_in_parens(result)
131+
132+
# Parent is `not` — or/and need parens underneath
133+
if isinstance(parent, j.Unary) and parent.operator == j.Unary.Type.Not:
134+
if _needs_parens_under_not(result):
135+
return _wrap_in_parens(result)
136+
137+
return result
138+
139+
110140
class PlaceholderReplacementVisitor(PythonVisitor[None]):
111141
"""
112142
Visitor that replaces placeholder identifiers with actual values.

rewrite-python/rewrite/src/rewrite/python/template/template.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -138,27 +138,33 @@ def apply(
138138
# Assume it's a MatchResult
139139
values_dict = values.as_dict()
140140

141-
# Apply substitutions
141+
# Phase 1: placeholder substitution (no coordinates yet)
142142
if values_dict:
143143
result = TemplateEngine.apply_substitutions(
144144
template_tree,
145145
values_dict,
146146
cursor,
147-
coordinates,
148147
)
149148
else:
150149
result = template_tree
151150

152-
# If no explicit coordinates, create default replacement coordinates
153-
if coordinates is None and cursor is not None:
151+
# Phase 2: parenthesize the result if it has lower precedence than
152+
# the surrounding context, mirroring JavaTemplate.doApply().
153+
# This must happen before coordinates are applied, because
154+
# _apply_coordinates may wrap the expression in ExpressionStatement.
155+
if result is not None and cursor is not None:
156+
from .replacement import maybe_parenthesize
157+
result = maybe_parenthesize(result, cursor)
158+
159+
# Phase 3: apply coordinates (prefix preservation, statement wrapping, auto-format)
160+
effective_coords = coordinates
161+
if effective_coords is None and cursor is not None:
154162
tree = cursor.value
155163
if tree is not None:
156-
result = TemplateEngine.apply_substitutions(
157-
result if values_dict else template_tree,
158-
values_dict,
159-
cursor,
160-
PythonCoordinates.replace(tree),
161-
)
164+
effective_coords = PythonCoordinates.replace(tree)
165+
166+
if effective_coords is not None and result is not None:
167+
result = TemplateEngine.apply_coordinates(result, cursor, effective_coords)
162168

163169
return result
164170

rewrite-python/rewrite/tests/python/template/test_template.py

Lines changed: 264 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,22 @@
1414

1515
"""Tests for Template class."""
1616

17+
from typing import Any, Optional
1718
from uuid import uuid4
1819

1920
import pytest
2021

22+
from rewrite import ExecutionContext, Recipe, TreeVisitor
2123
from rewrite.java import tree as j
22-
from rewrite.java.support_types import Space
24+
from rewrite.java.tree import Unary
25+
from rewrite.java.support_types import Space, JRightPadded
2326
from rewrite.markers import Markers
24-
from rewrite.python.template import template, capture, Template, TemplateBuilder
27+
from rewrite.python.template import template, capture, pattern, Template, TemplateBuilder
2528
from rewrite.python.template.engine import TemplateEngine
29+
from rewrite.python.template.replacement import maybe_parenthesize
30+
from rewrite.python.visitor import PythonVisitor
31+
from rewrite.test import RecipeSpec, python
32+
from rewrite.visitor import Cursor
2633

2734

2835
class TestTemplate:
@@ -362,3 +369,258 @@ def test_apply_with_match_result(self):
362369
assert len(result.arguments) == 1
363370
assert isinstance(result.arguments[0], j.Identifier)
364371
assert result.arguments[0].simple_name == "world"
372+
373+
374+
# ---------------------------------------------------------------------------
375+
# maybe_parenthesize unit tests
376+
# ---------------------------------------------------------------------------
377+
378+
379+
class TestMaybeParenthesize:
380+
"""Unit tests for maybe_parenthesize() — verifies that the function
381+
wraps a result node in parentheses when required by the parent context.
382+
"""
383+
384+
@staticmethod
385+
def _ident(name: str) -> j.Identifier:
386+
return j.Identifier(uuid4(), Space.EMPTY, Markers.EMPTY, [], name, None, None)
387+
388+
@staticmethod
389+
def _binary(left, op, right) -> j.Binary:
390+
return j.Binary(
391+
uuid4(), Space.EMPTY, Markers.EMPTY, left,
392+
JRightPadded(op, Space([], ' '), Markers.EMPTY),
393+
right, None,
394+
)
395+
396+
@staticmethod
397+
def _cursor_chain(*nodes) -> Cursor:
398+
"""Build a cursor chain from root → … → leaf."""
399+
cur = Cursor(None, Cursor.ROOT_VALUE)
400+
for node in nodes:
401+
cur = Cursor(cur, node)
402+
return cur
403+
404+
def test_or_result_in_and_parent(self):
405+
"""An `or` expression placed inside an `and` must be parenthesized."""
406+
or_expr = self._binary(self._ident('a'), j.Binary.Type.Or, self._ident('b'))
407+
and_parent = self._binary(or_expr, j.Binary.Type.And, self._ident('z'))
408+
cursor = self._cursor_chain(and_parent, or_expr)
409+
410+
result = maybe_parenthesize(or_expr, cursor)
411+
assert isinstance(result, j.Parentheses)
412+
413+
def test_and_result_in_or_parent(self):
414+
"""`and` has higher precedence than `or`, so no parens needed."""
415+
and_expr = self._binary(self._ident('a'), j.Binary.Type.And, self._ident('b'))
416+
or_parent = self._binary(and_expr, j.Binary.Type.Or, self._ident('z'))
417+
cursor = self._cursor_chain(or_parent, and_expr)
418+
419+
result = maybe_parenthesize(and_expr, cursor)
420+
assert not isinstance(result, j.Parentheses)
421+
422+
def test_or_result_under_not(self):
423+
"""`or` under `not` needs parens (not has precedence 3, or is 1)."""
424+
or_expr = self._binary(self._ident('a'), j.Binary.Type.Or, self._ident('b'))
425+
not_parent = j.Unary(
426+
uuid4(), Space.EMPTY, Markers.EMPTY,
427+
JRightPadded(j.Unary.Type.Not, Space.EMPTY, Markers.EMPTY),
428+
or_expr, None,
429+
)
430+
cursor = self._cursor_chain(not_parent, or_expr)
431+
432+
result = maybe_parenthesize(or_expr, cursor)
433+
assert isinstance(result, j.Parentheses)
434+
435+
def test_identifier_result_unchanged(self):
436+
"""Non-binary/unary results are returned unchanged."""
437+
ident = self._ident('x')
438+
and_parent = self._binary(ident, j.Binary.Type.And, self._ident('z'))
439+
cursor = self._cursor_chain(and_parent, ident)
440+
441+
result = maybe_parenthesize(ident, cursor)
442+
assert result is ident
443+
444+
def test_same_precedence_no_parens(self):
445+
"""`and` inside `and` — same precedence, no parens needed."""
446+
inner = self._binary(self._ident('a'), j.Binary.Type.And, self._ident('b'))
447+
outer = self._binary(inner, j.Binary.Type.And, self._ident('c'))
448+
cursor = self._cursor_chain(outer, inner)
449+
450+
result = maybe_parenthesize(inner, cursor)
451+
assert not isinstance(result, j.Parentheses)
452+
453+
def test_template_apply_parenthesizes_in_binary_context(self):
454+
"""template.apply() should parenthesize its result when the cursor
455+
parent is a higher-precedence binary operator."""
456+
from rewrite.python.tree import ExpressionStatement
457+
458+
_x = capture('x')
459+
_y = capture('y')
460+
_not_x_or_not_y = template("not {x} or not {y}", x=_x, y=_y)
461+
462+
# Build cursor: root → Binary(and) → Unary(not)
463+
# The Unary is the node being replaced by the template result.
464+
x_ident = self._ident('x')
465+
y_ident = self._ident('y')
466+
and_inner = self._binary(x_ident, j.Binary.Type.And, y_ident)
467+
not_expr = j.Unary(
468+
uuid4(), Space.EMPTY, Markers.EMPTY,
469+
JRightPadded(j.Unary.Type.Not, Space.EMPTY, Markers.EMPTY),
470+
j.Parentheses(
471+
uuid4(), Space.EMPTY, Markers.EMPTY,
472+
JRightPadded(and_inner, Space.EMPTY, Markers.EMPTY),
473+
),
474+
None,
475+
)
476+
z_ident = self._ident('z')
477+
outer_and = self._binary(not_expr, j.Binary.Type.And, z_ident)
478+
cursor = self._cursor_chain(outer_and, not_expr)
479+
480+
result = _not_x_or_not_y.apply(
481+
cursor, values={'x': x_ident, 'y': y_ident}
482+
)
483+
484+
# apply() wraps in ExpressionStatement (target Unary is a Statement);
485+
# the inner expression should be Parentheses since `or` is placed
486+
# inside an `and` context.
487+
inner = result.expression if isinstance(result, ExpressionStatement) else result
488+
assert isinstance(inner, j.Parentheses), (
489+
f"Expected Parentheses but got {type(inner).__name__}"
490+
)
491+
492+
493+
# ---------------------------------------------------------------------------
494+
# Template apply precedence in surrounding context (integration tests)
495+
# ---------------------------------------------------------------------------
496+
#
497+
# When template.apply() returns a node with lower precedence than the
498+
# site where it replaces, the framework should wrap it in parentheses.
499+
#
500+
# Example: "not {x} or not {y}" produces an `or` expression. If the
501+
# replacement site's parent is `and`, the result needs outer parentheses
502+
# because `or` has lower precedence than `and`.
503+
# ---------------------------------------------------------------------------
504+
505+
506+
class _DeMorganRecipe(Recipe):
507+
"""Minimal De Morgan recipe that demonstrates the parenthesization bug."""
508+
509+
@property
510+
def name(self) -> str:
511+
return "test.DeMorgan"
512+
513+
@property
514+
def display_name(self) -> str:
515+
return "Test De Morgan"
516+
517+
@property
518+
def description(self) -> str:
519+
return "Test De Morgan parenthesization"
520+
521+
def editor(self) -> TreeVisitor[Any, ExecutionContext]:
522+
_x = capture('x')
523+
_y = capture('y')
524+
_not_and = pattern("not ({x} and {y})", x=_x, y=_y)
525+
_not_or = pattern("not ({x} or {y})", x=_x, y=_y)
526+
_not_x_or_not_y = template("not {x} or not {y}", x=_x, y=_y)
527+
_not_x_and_not_y = template("not {x} and not {y}", x=_x, y=_y)
528+
529+
class Visitor(PythonVisitor[ExecutionContext]):
530+
def visit_unary(
531+
self, unary: Unary, p: ExecutionContext
532+
) -> Optional[Unary]:
533+
unary = super().visit_unary(unary, p)
534+
535+
match = _not_and.match(unary, self.cursor)
536+
if match:
537+
return _not_x_or_not_y.apply(self.cursor, values=match)
538+
539+
match = _not_or.match(unary, self.cursor)
540+
if match:
541+
return _not_x_and_not_y.apply(self.cursor, values=match)
542+
543+
return unary
544+
545+
return Visitor()
546+
547+
548+
class TestTemplateApplyPrecedenceInContext:
549+
"""Template.apply() result must be parenthesized when inserted into
550+
a higher-precedence context.
551+
552+
All tests use a minimal De Morgan recipe:
553+
not (x and y) → not x or not y
554+
not (x or y) → not x and not y
555+
"""
556+
557+
def test_demorgan_standalone(self):
558+
"""Baseline: De Morgan works at top level (no surrounding context)."""
559+
spec = RecipeSpec(recipe=_DeMorganRecipe())
560+
spec.rewrite_run(
561+
python(
562+
"result = not (a and b)",
563+
"result = not a or not b",
564+
)
565+
)
566+
567+
def test_demorgan_result_in_and_context(self):
568+
"""not (x and y) inside `... and z` must wrap result in parens.
569+
570+
not (x and y) and z
571+
→ (not x or not y) and z
572+
573+
Without parens: not x or not y and z
574+
Python parses: (not x) or ((not y) and z) ← WRONG
575+
"""
576+
spec = RecipeSpec(recipe=_DeMorganRecipe())
577+
spec.rewrite_run(
578+
python(
579+
"result = not (x and y) and z",
580+
"result = (not x or not y) and z",
581+
)
582+
)
583+
584+
def test_demorgan_result_in_or_context(self):
585+
"""not (x or y) inside `... or z` — no parens needed.
586+
587+
not (x or y) or z
588+
→ not x and not y or z
589+
590+
`and` has higher precedence than `or`, so
591+
Python parses: ((not x) and (not y)) or z ← correct without parens.
592+
"""
593+
spec = RecipeSpec(recipe=_DeMorganRecipe())
594+
spec.rewrite_run(
595+
python(
596+
"result = not (x or y) or z",
597+
"result = not x and not y or z",
598+
)
599+
)
600+
601+
def test_demorgan_result_on_right_of_and(self):
602+
"""z and not (x and y) must wrap result in parens.
603+
604+
z and not (x and y)
605+
→ z and (not x or not y)
606+
607+
Without parens: z and not x or not y
608+
Python parses: (z and (not x)) or (not y) ← WRONG
609+
"""
610+
spec = RecipeSpec(recipe=_DeMorganRecipe())
611+
spec.rewrite_run(
612+
python(
613+
"result = z and not (x and y)",
614+
"result = z and (not x or not y)",
615+
)
616+
)
617+
618+
def test_demorgan_result_in_if_condition(self):
619+
"""De Morgan in an if condition should still be correct."""
620+
spec = RecipeSpec(recipe=_DeMorganRecipe())
621+
spec.rewrite_run(
622+
python(
623+
"if not (x and y) and z:\n pass",
624+
"if (not x or not y) and z:\n pass",
625+
)
626+
)

0 commit comments

Comments
 (0)