Skip to content

Commit 4e20acd

Browse files
committed
Hyperedge.transform(): pattern-based rewrites
1 parent fc2c4d2 commit 4e20acd

4 files changed

Lines changed: 358 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44

55
### Added
66

7+
- Hyperedge.transform(): pattern-based rewrites.
78
- readers provide source information.
89
- `hyperbase.parsers.badness` module for parser-agnostic combined structural + token-matching validation.
910
- built-in REPL setting `check_badness` to render a badness panel after each parse, available regardless of the active parser plugin.
1011
- more REPL commands: /load, /search, /count and /types.
1112

1213
### Changed
1314

14-
- repl shows multiple results if available.
15+
- REPL shows multiple results if available.
1516

1617
### Removed
1718

src/hyperbase/hyperedge.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,17 @@ def simplify(self, subtypes: bool = False, namespaces: bool = False) -> Hyperedg
227227

228228
return simplify(self, subtypes=subtypes, namespaces=namespaces)
229229

230+
def transform(
231+
self,
232+
origin_pattern: Hyperedge | str | list | tuple,
233+
target_pattern: Hyperedge | str | list | tuple,
234+
recursive: bool = True,
235+
) -> Hyperedge:
236+
"""Pattern-driven rewrite. See ``hyperbase.transforms.transform``."""
237+
from hyperbase.transforms import transform
238+
239+
return transform(self, origin_pattern, target_pattern, recursive=recursive)
240+
230241
def type(self) -> str:
231242
"""Returns the type of this edge as a string.
232243
Type inference is performed.

src/hyperbase/transforms.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections.abc import Iterator
34
from typing import cast
45

56
import hyperbase.constants as const
@@ -153,3 +154,248 @@ def add_argument(
153154
new_edge = insert_argrole(edge, argrole, pos)
154155
combined = (*tuple(new_edge[: pos + 1]), arg, *tuple(new_edge[pos + 1 :]))
155156
return Hyperedge(combined)
157+
158+
159+
#############
160+
# transform #
161+
#############
162+
163+
164+
def transform(
165+
edge: Hyperedge,
166+
origin_pattern: Hyperedge | str | list | tuple,
167+
target_pattern: Hyperedge | str | list | tuple,
168+
recursive: bool = True,
169+
) -> Hyperedge:
170+
"""Pattern-driven rewrite of *edge*.
171+
172+
If ``origin_pattern`` matches *edge*, the edge is rewritten according to
173+
``target_pattern`` using the variable bindings extracted from the match.
174+
If it does not match, the edge is returned unchanged.
175+
176+
With ``{}`` argroles in the target, un-consumed arguments and argroles
177+
from the original edge at the matched (top) level are preserved alongside
178+
the target's. Without ``{}`` only what appears in the target is kept.
179+
180+
Patterns must contain only named variables; anonymous wildcards
181+
(``*``, ``.``, ``(*)``, ``...``) and functional patterns (``var``, ``any``,
182+
``atoms``, ``lemma``) raise ``ValueError``. The target's variables must be
183+
a subset of the origin's variables.
184+
185+
By default the rewrite is applied recursively (depth-first, once per
186+
level). Pass ``recursive=False`` for a shallow rewrite.
187+
"""
188+
origin = hedge(origin_pattern)
189+
target = hedge(target_pattern)
190+
_validate_transform_patterns(origin, target)
191+
return _transform_impl(edge, origin, target, recursive)
192+
193+
194+
def _transform_impl(
195+
edge: Hyperedge,
196+
origin: Hyperedge,
197+
target: Hyperedge,
198+
recursive: bool,
199+
) -> Hyperedge:
200+
from hyperbase.patterns import match_pattern
201+
202+
if recursive and edge.not_atom:
203+
edge = Hyperedge(tuple(_transform_impl(c, origin, target, True) for c in edge))
204+
205+
matches = match_pattern(edge, origin)
206+
if matches:
207+
bindings = matches[0]
208+
edge = _instantiate(target, origin, edge, bindings, top=True)
209+
return edge
210+
211+
212+
def _validate_transform_patterns(origin: Hyperedge, target: Hyperedge) -> None:
213+
from hyperbase.patterns.checks import is_variable, is_wildcard
214+
215+
for label, pattern in (("origin_pattern", origin), ("target_pattern", target)):
216+
if _contains_fun_pattern(pattern):
217+
raise ValueError(
218+
f"Functional patterns are not supported in {label}: {pattern}"
219+
)
220+
for atom in _walk_atoms(pattern):
221+
if is_wildcard(atom) and not is_variable(atom):
222+
raise ValueError(
223+
f"Anonymous wildcard '{atom}' not allowed in {label}; "
224+
f"use a named variable"
225+
)
226+
227+
origin_vars = _collect_var_names(origin)
228+
target_vars = _collect_var_names(target)
229+
extra = target_vars - origin_vars
230+
if extra:
231+
raise ValueError(
232+
f"target_pattern uses variables not in origin_pattern: {sorted(extra)}"
233+
)
234+
235+
236+
def _walk_atoms(edge: Hyperedge) -> Iterator[Atom]:
237+
if edge.atom:
238+
yield cast(Atom, edge)
239+
else:
240+
for child in edge:
241+
yield from _walk_atoms(child)
242+
243+
244+
def _contains_fun_pattern(edge: Hyperedge) -> bool:
245+
from hyperbase.patterns.checks import is_fun_pattern
246+
247+
if edge.atom:
248+
return False
249+
if is_fun_pattern(edge):
250+
return True
251+
return any(_contains_fun_pattern(c) for c in edge)
252+
253+
254+
def _collect_var_names(pattern: Hyperedge) -> set[str]:
255+
from hyperbase.patterns.checks import is_variable, variable_name
256+
257+
names: set[str] = set()
258+
for atom in _walk_atoms(pattern):
259+
if is_variable(atom):
260+
names.add(variable_name(atom))
261+
return names
262+
263+
264+
def _find_var_atom(pattern: Hyperedge, name: str) -> Atom | None:
265+
from hyperbase.patterns.checks import is_variable, variable_name
266+
267+
for atom in _walk_atoms(pattern):
268+
if is_variable(atom) and variable_name(atom) == name:
269+
return atom
270+
return None
271+
272+
273+
def _strip_braces(s: str) -> str:
274+
if len(s) >= 2 and s[0] == "{" and s[-1] == "}":
275+
return s[1:-1]
276+
return s
277+
278+
279+
def _atom_decoration_compatible(
280+
origin_parts: list[str], target_parts: list[str]
281+
) -> bool:
282+
"""True if origin and target atom decorations differ only in argroles."""
283+
o_role = origin_parts[1].split(".") if len(origin_parts) > 1 else [""]
284+
t_role = target_parts[1].split(".") if len(target_parts) > 1 else [""]
285+
if o_role[0] != t_role[0]:
286+
return False
287+
return origin_parts[2:] == target_parts[2:]
288+
289+
290+
def _instantiate(
291+
target: Hyperedge,
292+
origin: Hyperedge,
293+
original: Hyperedge,
294+
bindings: dict[str, Hyperedge],
295+
top: bool,
296+
) -> Hyperedge:
297+
from hyperbase.patterns.checks import is_variable
298+
299+
if target.atom:
300+
if is_variable(target):
301+
return _substitute_var_atom(cast(Atom, target), origin, bindings)
302+
return target
303+
304+
if top and original.not_atom and target.argroles().startswith("{"):
305+
return _instantiate_with_preserve(target, origin, original, bindings)
306+
307+
return Hyperedge(
308+
tuple(_instantiate(c, origin, original, bindings, top=False) for c in target)
309+
)
310+
311+
312+
def _substitute_var_atom(
313+
target_atom: Atom,
314+
origin: Hyperedge,
315+
bindings: dict[str, Hyperedge],
316+
) -> Hyperedge:
317+
from hyperbase.patterns.checks import variable_name
318+
319+
name = variable_name(target_atom)
320+
if name not in bindings:
321+
raise ValueError(f"Variable '{name}' has no binding in matched origin pattern")
322+
binding = bindings[name]
323+
target_parts = target_atom.parts()
324+
if len(target_parts) == 1:
325+
return binding
326+
327+
if binding.atom:
328+
binding_atom = cast(Atom, binding)
329+
new_parts = [binding_atom.root(), *target_parts[1:]]
330+
return Atom("/".join(p for p in new_parts if p))
331+
332+
origin_atom = _find_var_atom(origin, name)
333+
origin_parts = origin_atom.parts() if origin_atom else [name]
334+
if not _atom_decoration_compatible(origin_parts, target_parts):
335+
raise ValueError(
336+
f"Cannot change type or namespace on non-atomic binding for "
337+
f"variable '{name}': origin {origin_parts} vs target {target_parts}"
338+
)
339+
340+
t_role = target_parts[1].split(".") if len(target_parts) > 1 else [""]
341+
target_argroles = t_role[1] if len(t_role) > 1 else ""
342+
o_role = origin_parts[1].split(".") if len(origin_parts) > 1 else [""]
343+
origin_argroles = o_role[1] if len(o_role) > 1 else ""
344+
if target_argroles == origin_argroles:
345+
return binding
346+
bare = _strip_braces(target_argroles)
347+
if bare == "":
348+
return binding
349+
return replace_argroles(binding, bare)
350+
351+
352+
def _instantiate_with_preserve(
353+
target: Hyperedge,
354+
origin: Hyperedge,
355+
original: Hyperedge,
356+
bindings: dict[str, Hyperedge],
357+
) -> Hyperedge:
358+
consumed = _consumed_arg_indices(original, origin)
359+
edge_argroles = original.argroles()
360+
if edge_argroles.startswith("{"):
361+
edge_argroles = edge_argroles[1:-1]
362+
363+
args = list(original[1:])
364+
preserved_args = [args[i] for i in range(len(args)) if i not in consumed]
365+
preserved_argroles = "".join(
366+
edge_argroles[i]
367+
for i in range(min(len(edge_argroles), len(args)))
368+
if i not in consumed
369+
)
370+
371+
new_children = [
372+
_instantiate(c, origin, original, bindings, top=False) for c in target
373+
]
374+
final = Hyperedge((new_children[0], *preserved_args, *new_children[1:]))
375+
376+
target_spec = _strip_braces(target.argroles())
377+
new_argroles = preserved_argroles + target_spec
378+
if new_argroles == "":
379+
return final
380+
return replace_argroles(final, new_argroles)
381+
382+
383+
def _consumed_arg_indices(
384+
original_edge: Hyperedge,
385+
origin_pattern: Hyperedge,
386+
) -> set[int]:
387+
from hyperbase.patterns import match_pattern
388+
389+
consumed: set[int] = set()
390+
if origin_pattern.atom or original_edge.atom:
391+
return consumed
392+
393+
args = list(original_edge[1:])
394+
for arg_pattern in origin_pattern[1:]:
395+
for i, arg in enumerate(args):
396+
if i in consumed:
397+
continue
398+
if match_pattern(arg, arg_pattern):
399+
consumed.add(i)
400+
break
401+
return consumed

0 commit comments

Comments
 (0)