Skip to content

Commit 886de26

Browse files
Python: Parenthesize template results based on surrounding context (#6827)
* 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) * Python: Address review feedback on template parenthesization Remove dead `coordinates` and `cursor` params from `apply_substitutions`, extend `_get_precedence` to handle `not` unary (precedence 3), fix test calling renamed `apply_coordinates`, and add clarifying docstring for the outer/inner parenthesization boundary. * Python: Fix template tests to match updated apply_substitutions signature Remove stale `cursor=None` kwarg from test calls after the parameter was removed from TemplateEngine.apply_substitutions in 7ca8044.
1 parent 8b48c51 commit 886de26

5 files changed

Lines changed: 326 additions & 32 deletions

File tree

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

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,13 @@ def apply_substitutions(
116116
cls,
117117
template_tree: J,
118118
values: Dict[str, J],
119-
cursor: 'Cursor',
120-
coordinates: Optional[PythonCoordinates] = None
121119
) -> Optional[J]:
122120
"""
123121
Substitute placeholder identifiers with actual values.
124122
125123
Args:
126124
template_tree: The parsed template AST.
127125
values: Dict mapping capture names to their AST values.
128-
cursor: Current cursor position.
129-
coordinates: Where/how to apply the template.
130126
131127
Returns:
132128
The template with placeholders replaced by actual values.
@@ -135,13 +131,7 @@ def apply_substitutions(
135131

136132
# Replace placeholders with actual values
137133
visitor = PlaceholderReplacementVisitor(values)
138-
result = visitor.visit(template_tree, None)
139-
140-
# Apply coordinates (wrap in statement if needed, etc.)
141-
if coordinates and result is not None:
142-
result = cls._apply_coordinates(result, cursor, coordinates)
143-
144-
return result
134+
return visitor.visit(template_tree, None)
145135

146136
@classmethod
147137
def _make_cache_key(
@@ -286,7 +276,7 @@ def _extract_from_method_body(cls, method: j.MethodDeclaration, is_expression: b
286276
return statements[0]
287277

288278
@classmethod
289-
def _apply_coordinates(
279+
def apply_coordinates(
290280
cls,
291281
result: J,
292282
cursor: 'Cursor',

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

Lines changed: 39 additions & 2 deletions
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).
@@ -67,11 +67,13 @@
6767

6868

6969
def _get_precedence(expr: J) -> Optional[int]:
70-
"""Return the precedence of an expression, or None if not a binary op."""
70+
"""Return the precedence of an expression, or None if not an operator node."""
7171
if isinstance(expr, j.Binary):
7272
return _BINARY_PRECEDENCE.get(expr.operator)
7373
if isinstance(expr, py.Binary):
7474
return _BINARY_PRECEDENCE.get(expr.operator)
75+
if isinstance(expr, j.Unary) and expr.operator == j.Unary.Type.Not:
76+
return 3 # `not` sits between `and` (2) and comparisons (4)
7577
return None
7678

7779

@@ -107,6 +109,41 @@ def _needs_parens_under_not(child: J) -> bool:
107109
return child_prec < 3
108110

109111

112+
def maybe_parenthesize(result: J, cursor: 'Cursor') -> J:
113+
"""Wrap *result* in parentheses when its precedence is lower than the
114+
surrounding context (the cursor's parent tree node).
115+
116+
This handles the *outer* boundary: whether the template result as a whole
117+
needs parentheses relative to where it is being inserted in the tree.
118+
The *inner* operand parenthesization (within the template result itself)
119+
is handled by ``PlaceholderReplacementVisitor.visit_binary`` / ``visit_unary``.
120+
121+
This mirrors ``ParenthesizeVisitor.maybeParenthesize`` in the Java
122+
``JavaTemplate`` implementation.
123+
"""
124+
if not isinstance(result, (j.Binary, py.Binary, j.Unary)):
125+
return result
126+
127+
parent_cursor = cursor.parent_tree_cursor()
128+
parent = parent_cursor.value
129+
if not isinstance(parent, J):
130+
return result
131+
132+
# Parent is a binary operator — check precedence
133+
if isinstance(parent, (j.Binary, py.Binary)):
134+
parent_prec = _get_precedence(parent)
135+
result_prec = _get_precedence(result)
136+
if parent_prec is not None and result_prec is not None and result_prec < parent_prec:
137+
return _wrap_in_parens(result)
138+
139+
# Parent is `not` — or/and need parens underneath
140+
if isinstance(parent, j.Unary) and parent.operator == j.Unary.Type.Not:
141+
if _needs_parens_under_not(result):
142+
return _wrap_in_parens(result)
143+
144+
return result
145+
146+
110147
class PlaceholderReplacementVisitor(PythonVisitor[None]):
111148
"""
112149
Visitor that replaces placeholder identifiers with actual values.

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,27 +138,32 @@ 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,
146-
cursor,
147-
coordinates,
148146
)
149147
else:
150148
result = template_tree
151149

152-
# If no explicit coordinates, create default replacement coordinates
153-
if coordinates is None and cursor is not None:
150+
# Phase 2: parenthesize the result if it has lower precedence than
151+
# the surrounding context, mirroring JavaTemplate.doApply().
152+
# This must happen before coordinates are applied, because
153+
# apply_coordinates may wrap the expression in ExpressionStatement.
154+
if result is not None and cursor is not None:
155+
from .replacement import maybe_parenthesize
156+
result = maybe_parenthesize(result, cursor)
157+
158+
# Phase 3: apply coordinates (prefix preservation, statement wrapping, auto-format)
159+
effective_coords = coordinates
160+
if effective_coords is None and cursor is not None:
154161
tree = cursor.value
155162
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-
)
163+
effective_coords = PythonCoordinates.replace(tree)
164+
165+
if effective_coords is not None and result is not None:
166+
result = TemplateEngine.apply_coordinates(result, cursor, effective_coords)
162167

163168
return result
164169

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ def bar(self):
329329
)
330330

331331
def test_apply_coordinates_no_cu_context_does_not_crash(self):
332-
"""_apply_coordinates with cursor lacking CU context doesn't crash."""
332+
"""apply_coordinates with cursor lacking CU context doesn't crash."""
333333
from rewrite.visitor import Cursor
334334
from rewrite.python.template.coordinates import PythonCoordinates
335335

@@ -340,5 +340,5 @@ def test_apply_coordinates_no_cu_context_does_not_crash(self):
340340
coordinates = PythonCoordinates.replace(original)
341341
cursor = Cursor(parent=Cursor(None, Cursor.ROOT_VALUE), value=original)
342342

343-
out = TemplateEngine._apply_coordinates(result, cursor, coordinates)
343+
out = TemplateEngine.apply_coordinates(result, cursor, coordinates)
344344
assert out is not None

0 commit comments

Comments
 (0)