Repository: facebookincubator/Bowler

Issue:
Modifiers can't replace Nodes
Hi,

First of all thanks for this great project. It's helping on modernizing the architecture of [GraphQL Python](https://github.com/graphql-python/) projects.

## Use Case

I'm trying to port automatically Python 3 code to Python 2 (to not break compatibility for old projects using the library, similar to what babel is doing).
On this quest, I was trying to replace the calls to `my_var = cast(MyType, old_var)` to `my_var = old_var`.

For it, I created the following pattern and modifier:
#### example.py
```python
# This is the file that we want to modify
my_var = cast(MyType, old_var)
```

#### bowler_file
```python
from bowler import Query

query = Query("example.py")

cast_pattern = """
    power< "cast"
        trailer< "(" cast_args=any* ")" >
    >
"""

def cast_modifier(node, capture, filename):
    args = capture.get("cast_args")
    last_arg = args[-1].children[-1]
    return last_arg

query.select(cast_pattern).modify(cast_modifier).diff()
```

The modifier is returning a different node than the provided as argument in the modifier function, so it should be replaced.

However, based on the [current implementation](https://github.com/facebookincubator/Bowler/blob/master/bowler/query.py#L901-L904) the return value of the fixer & callback is actually not used, therefore no matter what the fixer or callback returns... the return value will always be skipped, forcing to have always the same node on the modifiers.

It seems this specific case is documented, however is not implemented.
> Returned values will automatically replace the matched element in the syntax tree.
https://pybowler.io/docs/api-query#modify


Comments:

Patch:
diff --git a/bowler/query.py b/bowler/query.py
index d00397a..ef95cd4 100644
--- a/bowler/query.py
+++ b/bowler/query.py
@@ -37,6 +37,7 @@ from .types import (
     START,
     SYMBOL,
     TOKEN,
+    BowlerException,
     Callback,
     Capture,
     Filename,
@@ -910,13 +911,21 @@ class Query:
             PATTERN = pattern  # type: ignore
             BM_compatible = bm_compat
 
-            def transform(self, node: Node, capture: Capture) -> None:
+            def transform(self, node: LN, capture: Capture) -> Optional[LN]:
                 filename = cast(Filename, self.filename)
+                returned_node = None
                 if not filters or all(f(node, capture, filename) for f in filters):
                     if transform.fixer:
-                        transform.fixer().transform(node, capture)
+                        returned_node = transform.fixer().transform(node, capture)
                     for callback in callbacks:
-                        callback(node, capture, filename)
+                        if returned_node and returned_node is not node:
+                            raise BowlerException(
+                                "Only the last fixer/callback may return "
+                                "a different node.  See "
+                                "https://pybowler.io/docs/api-modifiers"
+                            )
+                        returned_node = callback(node, capture, filename)
+                return returned_node
 
         return Fixer
 


Test patch:
diff --git a/bowler/tests/lib.py b/bowler/tests/lib.py
index 1d17ba3..b1f4c29 100644
--- a/bowler/tests/lib.py
+++ b/bowler/tests/lib.py
@@ -71,7 +71,7 @@ class BowlerTestCase(unittest.TestCase):
         def local_modifier(node, capture, filename):
             # When in_process=False, this runs in another process.  See notes below.
             try:
-                modifier(node, capture, filename)
+                return modifier(node, capture, filename)
             except Exception as e:
                 exception_queue.put(e)
 
diff --git a/bowler/tests/query.py b/bowler/tests/query.py
index 9debe83..db87c8c 100644
--- a/bowler/tests/query.py
+++ b/bowler/tests/query.py
@@ -5,7 +5,10 @@
 # This source code is licensed under the MIT license found in the
 # LICENSE file in the root directory of this source tree.
 
+from unittest import mock
+
 from ..query import SELECTORS, Query
+from ..types import TOKEN, BowlerException, Leaf
 from .lib import BowlerTestCase
 
 
@@ -61,14 +64,55 @@ def g(x): pass
         def selector(arg):
             return Query(arg).select_function("f")
 
-        def modifier(q):
+        def add_modifier(q):
             return q.add_argument("y", "5")
 
         output = self.run_bowler_modifier(
-            input, selector_func=selector, modifier_func=modifier
+            input, selector_func=selector, modifier_func=add_modifier
         )
         expected = """\
 def f(x, y=5): pass
 def g(x): pass
 [f(), g()]"""
         self.assertMultiLineEqual(expected, output)
+
+    def test_modifier_return_value(self):
+        input = "a+b"
+
+        def modifier(node, capture, filename):
+            new_op = Leaf(TOKEN.MINUS, "-")
+            return new_op
+
+        output = self.run_bowler_modifier(input, "'+'", modifier)
+        self.assertEqual("a-b", output)
+
+    def test_modifier_return_value_multiple(self):
+        input = "a+b"
+
+        def noop_modifier(node, capture, filename):
+            print("Noop modifier")
+            pass
+
+        def modifier(node, capture, filename):
+            print("Modifier")
+            new_op = Leaf(TOKEN.MINUS, "-")
+            return new_op
+
+        def add_ok_modifier(q):
+            return q.modify(noop_modifier).modify(modifier)
+
+        output = self.run_bowler_modifier(input, "'+'", modifier_func=add_ok_modifier)
+        self.assertEqual("a-b", output)
+
+        def add_bad_modifier(q):
+            return q.modify(modifier).modify(noop_modifier)
+
+        with mock.patch("bowler.tool.log.error") as error:
+            output = self.run_bowler_modifier(
+                input, "'+'", modifier_func=add_bad_modifier
+            )
+            self.assertEqual("a+b", output)  # unmodified
+            self.assertTrue(error.call_args)
+            self.assertIn(
+                "Only the last fixer/callback may return", error.call_args[0][0]
+            )