|
14 | 14 |
|
15 | 15 | """Tests for Template class.""" |
16 | 16 |
|
| 17 | +from typing import Any, Optional |
17 | 18 | from uuid import uuid4 |
18 | 19 |
|
19 | 20 | import pytest |
20 | 21 |
|
| 22 | +from rewrite import ExecutionContext, Recipe, TreeVisitor |
21 | 23 | 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 |
23 | 26 | 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 |
25 | 28 | 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 |
26 | 33 |
|
27 | 34 |
|
28 | 35 | class TestTemplate: |
@@ -362,3 +369,258 @@ def test_apply_with_match_result(self): |
362 | 369 | assert len(result.arguments) == 1 |
363 | 370 | assert isinstance(result.arguments[0], j.Identifier) |
364 | 371 | 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