Repository: maxfischer2781/bootpeg

Issue:
Verify Rule+Action captures
Both ``Rule`` and ``Action`` can statically derive their provided/supplied arguments. A ``Rule`` should check that its ``Action`` is compatible.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13?src=pr&el=h1) Report
> Merging [#13](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13?src=pr&el=desc) (894d03d) into [main](https://codecov.io/gh/maxfischer2781/bootpeg/commit/c0090c9cb553788d0c85a5958c19fa7872f28e7d?el=desc) (c0090c9) will **increase** coverage by `2.71%`.
> The diff coverage is `97.61%`.

[![Impacted file tree graph](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13/graphs/tree.svg?width=650&height=150&src=pr&token=CBJ51BF8Y6)](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##             main      #13      +/-   ##
==========================================
+ Coverage   80.64%   83.36%   +2.71%     
==========================================
  Files           6        6              
  Lines         589      619      +30     
  Branches       67       74       +7     
==========================================
+ Hits          475      516      +41     
+ Misses        104       92      -12     
- Partials       10       11       +1     
```


| [Impacted Files](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [bootpeg/pika/act.py](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13/diff?src=pr&el=tree#diff-Ym9vdHBlZy9waWthL2FjdC5weQ==) | `84.72% <97.29%> (+6.57%)` | :arrow_up: |
| [tests/test\_boot.py](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9ib290LnB5) | `100.00% <100.00%> (ø)` | |
| [bootpeg/pika/peg.py](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13/diff?src=pr&el=tree#diff-Ym9vdHBlZy9waWthL3BlZy5weQ==) | `90.77% <0.00%> (+2.01%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13?src=pr&el=footer). Last update [c0090c9...894d03d](https://codecov.io/gh/maxfischer2781/bootpeg/pull/13?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/bootpeg/pika/act.py b/bootpeg/pika/act.py
index afa59bb..7f8aafd 100644
--- a/bootpeg/pika/act.py
+++ b/bootpeg/pika/act.py
@@ -4,7 +4,7 @@ Pika bottom-up Peg parser extension to transform parsed source
 from typing import TypeVar, Any, Dict, Tuple
 import re
 
-from .peg import Clause, D, Match, MemoTable, MemoKey, Literal, nested_str
+from .peg import Clause, D, Match, MemoTable, MemoKey, Literal, nested_str, Reference
 from ..utility import mono, cache_hash
 
 
@@ -80,13 +80,42 @@ class Capture(Clause[D]):
         return f"{self.name}={nested_str(self.sub_clauses[0])}"
 
 
+def captures(head: Clause):
+    if isinstance(head, Capture):
+        yield head
+    elif isinstance(head, Reference):
+        return
+    else:
+        for clause in head.sub_clauses:
+            yield from captures(clause)
+
+
+class ActionCaptureError(TypeError):
+    def __init__(self, missing, extra, rule):
+        self.missing = missing
+        self.extra = extra
+        self.rule = rule
+        msg = f"extra {', '.join(map(repr, extra))}" if extra else ""
+        msg += " and " if extra and missing else ""
+        msg += f"missing {', '.join(map(repr, missing))}" if missing else ""
+        super().__init__(f"{msg} captures: {self.rule}")
+
+
 class Rule(Clause[D]):
     __slots__ = ("sub_clauses", "action", "_hash")
 
-    def __init__(self, sub_clause: Clause[D], action):
+    def __init__(self, sub_clause: Clause[D], action: "Action"):
         self.sub_clauses = (sub_clause,)
         self.action = action
         self._hash = None
+        self._verify_captures()
+
+    def _verify_captures(self):
+        captured_names = {capture.name for capture in captures(self.sub_clauses[0])}
+        if captured_names.symmetric_difference(self.action.parameters):
+            additional = captured_names.difference(self.action.parameters)
+            missing = set(self.action.parameters) - captured_names
+            raise ActionCaptureError(missing=missing, extra=additional, rule=self)
 
     @property
     def maybe_zero(self):
@@ -123,7 +152,7 @@ class Discard:
 
 
 class Action:
-    __slots__ = ("literal", "_py_source", "_py_code")
+    __slots__ = ("literal", "_py_names", "_py_source", "_py_code")
     # TODO: Define these via a PEG parser
     unpack = re.compile(r"\.\*")
     named = re.compile(r"(^|[ (])\.([a-zA-Z]+)")
@@ -131,24 +160,24 @@ class Action:
 
     def __init__(self, literal: str):
         self.literal = literal.strip()
+        self._py_names = tuple(match.group(2) for match in self.named.finditer(literal))
         self._py_source = self._encode(self.literal)
         self._py_code = compile(self._py_source, self._py_source, "eval")
 
+    @property
+    def parameters(self) -> Tuple[str, ...]:
+        """The parameter names used by the action"""
+        return self._py_names
+
     def __call__(self, __namespace, *args, **kwargs):
-        try:
-            return eval(self._py_code, __namespace)(*args, **kwargs)
-        except Exception as err:
-            raise type(err)(f"{err} <{self._py_source}>")
-
-    @classmethod
-    def _encode(cls, literal):
-        names = [
-            f"{cls.mangle}{match.group(2)}" for match in cls.named.finditer(literal)
-        ]
-        body = cls.named.sub(
-            rf"\1 {cls.mangle}\2", cls.unpack.sub(rf" {cls.mangle}all", literal)
+        return eval(self._py_code, __namespace)(*args, **kwargs)
+
+    def _encode(self, literal):
+        names = [f"{self.mangle}{name}" for name in self._py_names]
+        body = self.named.sub(
+            rf"\1 {self.mangle}\2", self.unpack.sub(rf" {self.mangle}all", literal)
         )
-        return f'lambda {cls.mangle}all, {", ".join(names)}: {body}'
+        return f'lambda {self.mangle}all, {", ".join(names)}: {body}'
 
     def __eq__(self, other):
         return isinstance(other, Action) and self.literal == other.literal
@@ -196,6 +225,8 @@ def postorder_transform(
         matches = matches if matches else source[position : position + match.length]
         try:
             result = clause.action(namespace, matches, **captures)
+        except ActionCaptureError:
+            raise
         except Exception as exc:
             raise TransformFailure(clause, matches, captures, exc)
         return (result,) if not isinstance(result, Discard) else (), {}


Test patch:
diff --git a/tests/test_boot.py b/tests/test_boot.py
index 63f0bcf..b1cd914 100644
--- a/tests/test_boot.py
+++ b/tests/test_boot.py
@@ -1,3 +1,5 @@
+import pytest
+
 from bootpeg.pika import boot
 
 
@@ -24,3 +26,7 @@ def test_features():
     opt_repeat = boot.boot(parser, 'rule:\n    | [ " "+ ]\n')
     non_repeat = boot.boot(parser, 'rule:\n    | " "*\n')
     assert opt_repeat.clauses == non_repeat.clauses
+    with pytest.raises(TypeError):
+        boot.boot(parser, 'rule:\n    | [ " "+ ] { .missing }\n')
+    with pytest.raises(TypeError):
+        boot.boot(parser, 'rule:\n    | extra=([ " "+ ]) { () }\n')