Repository: goodmami/pe (language: python)

Issue:
Difference with machine/packrat parsers and captures
Without captures, a repetition operator has the same behavior with the packrat and machine parsers:

```pycon
>>> import pe
>>> pe.match('"a"+', 'aaa', parser="packrat")
<Match object; span=(0, 3), match='aaa'>
>>> pe.match('"a"+', 'aaa', parser="machine")
<Match object; span=(0, 3), match='aaa'>
```

But when captures are used, they differ:

```pycon
>>> pe.match('~"a"+', 'aaa', parser="packrat")
<Match object; span=(0, 3), match='aaa'>
>>> pe.match('~"a"+', 'aaa', parser="machine")
<Match object; span=(0, 1), match='a'>
```


Discussion / hints:
This issue only affects the cython machine parser. The issue is that repetitions with non-zero minimum occurrences reuse the parsing instruction object for each occurrence (below, `pis` is a list of parsing instructions):

https://github.com/goodmami/pe/blob/41676575ac4b8728f1fdce507099e9d4f166fb17/pe/_cy_machine.pyx#L405-L408

When the capture modifies the instruction to mark the start of a capture, it marks all of them. The Python machine parser is not susceptible to this bug because it uses immutable tuples instead of `Instruction` objects (at least for now).

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 172b656..3d32f97 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased][unreleased]
 
+### Fixed
+
+* Repetitions with mincount > 0 no longer reuse the instruction object ([#38])
+
 
 ## [v0.5.0][]
 
@@ -164,3 +168,4 @@ descent parser and a work-in-progress state-machine parser.
 [#21]: https://github.com/goodmami/pe/issues/21
 [#23]: https://github.com/goodmami/pe/issues/23
 [#31]: https://github.com/goodmami/pe/issues/31
+[#38]: https://github.com/goodmami/pe/issues/38
diff --git a/pe/_cy_machine.pyx b/pe/_cy_machine.pyx
index f175ac6..787d87a 100644
--- a/pe/_cy_machine.pyx
+++ b/pe/_cy_machine.pyx
@@ -123,6 +123,17 @@ cdef class Instruction:
         self.action = action
         self.name = name
 
+    def copy(self) -> Instruction:
+        return Instruction(
+            self.opcode,
+            self.oploc,
+            scanner=self.scanner,
+            marking=self.marking,
+            capturing=self.capturing,
+            action=self.action,
+            name=self.name,
+        )
+
 
 _Program = List[Instruction]
 
@@ -402,7 +413,7 @@ def _rpt(defn, mincount):
                             marking=pi.marking,
                             capturing=pi.capturing,
                             action=pi.action)]
-    return [*(pis * mincount),
+    return [*(pi.copy() for _ in range(mincount) for pi in pis),
             Instruction(BRANCH, len(pis) + 2),
             *pis,
             Instruction(UPDATE, -len(pis))]


Test patch:
diff --git a/test/test_regression.py b/test/test_regression.py
index 3ebd84e..8331397 100644
--- a/test/test_regression.py
+++ b/test/test_regression.py
@@ -27,3 +27,16 @@ def test_multi_range_charclass(parser):
     assert p.match('"1"') is not None
     assert p.match('"\U0010ffff"') is not None
     assert p.match('"a1"') is not None
+
+
+@pytest.mark.parametrize('parser', ['packrat', 'machine', 'machine-python'])
+def test_capture_repeated(parser):
+    m1 = pe.match('"a"+', 'aaa', parser=parser)
+    assert m1.group() == 'aaa'
+    assert m1.groups() == ()
+    m2 = pe.match('~"a"+', 'aaa', parser=parser)
+    assert m2.group() == 'aaa'
+    assert m2.groups() == ('aaa',)
+    m3 = pe.match('(~"a")+', 'aaa', parser=parser)
+    assert m3.group() == 'aaa'
+    assert m3.groups() == ('a', 'a', 'a')