Skip to content

Commit 97f32ee

Browse files
committed
repl command /transform
1 parent 4e20acd commit 97f32ee

4 files changed

Lines changed: 128 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
- readers provide source information.
99
- `hyperbase.parsers.badness` module for parser-agnostic combined structural + token-matching validation.
1010
- built-in REPL setting `check_badness` to render a badness panel after each parse, available regardless of the active parser plugin.
11-
- more REPL commands: /load, /search, /count and /types.
11+
- more REPL commands: /load, /search, /count, /types and /transform.
1212

1313
### Changed
1414

src/hyperbase/cli/repl.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from rich.table import Table
2121
from rich.text import Text
2222

23-
from hyperbase.builders import hedge
23+
from hyperbase.builders import hedge, split_edge_str
2424
from hyperbase.constants import EdgeType
2525
from hyperbase.hyperedge import Atom, Hyperedge
2626
from hyperbase.parsers import Parser, get_parser, list_parsers
@@ -367,6 +367,13 @@ def __init__(self, parser_name: str, settings: dict[str, Any]) -> None:
367367
),
368368
"handler": self.cmd_types,
369369
},
370+
"transform": {
371+
"help": (
372+
"Rewrite loaded edges by pattern "
373+
"(e.g. /transform (X/P.{x} (Y/Ta Z)) ((Y/Mx X/P.{s}) Z))"
374+
),
375+
"handler": self.cmd_transform,
376+
},
370377
}
371378

372379
self.session = PromptSession(
@@ -978,6 +985,107 @@ def cmd_types(self, args: list) -> bool:
978985
)
979986
return False
980987

988+
def cmd_transform(self, args: list) -> bool:
989+
if not args:
990+
self.console.print(
991+
"[red]Error:[/red] /transform requires two patterns: "
992+
"[cyan]/transform <origin> <target>[/cyan]"
993+
)
994+
return False
995+
if not self.edges:
996+
self.console.print("[yellow]No edges loaded.[/yellow]")
997+
self.console.print(
998+
"[dim]Use[/dim] [cyan]/load <path>[/cyan] [dim]first.[/dim]"
999+
)
1000+
return False
1001+
1002+
text = " ".join(args)
1003+
try:
1004+
tokens = split_edge_str(text)
1005+
except ValueError as e:
1006+
self.console.print(f"[red]Error:[/red] {e}")
1007+
return False
1008+
if len(tokens) != 2:
1009+
self.console.print(
1010+
"[red]Error:[/red] expected exactly two patterns "
1011+
f"(got {len(tokens)}): [cyan]/transform <origin> <target>[/cyan]"
1012+
)
1013+
return False
1014+
1015+
try:
1016+
origin = hedge(tokens[0])
1017+
target = hedge(tokens[1])
1018+
except Exception as e:
1019+
self.console.print(f"[red]Error:[/red] failed to parse pattern: {e}")
1020+
return False
1021+
if origin is None or target is None:
1022+
self.console.print("[red]Error:[/red] could not parse patterns")
1023+
return False
1024+
1025+
try:
1026+
choice = (
1027+
self.session.prompt(
1028+
"Dry run? [Y/n] (n applies the rewrite to loaded edges): "
1029+
)
1030+
.strip()
1031+
.lower()
1032+
)
1033+
except (KeyboardInterrupt, EOFError):
1034+
self.console.print("[dim](aborted)[/dim]")
1035+
return False
1036+
dry_run = choice not in ("n", "no")
1037+
1038+
changes: list[tuple[int, Hyperedge, Hyperedge]] = []
1039+
new_edges: list[Hyperedge] = []
1040+
try:
1041+
for idx, edge in enumerate(self.edges):
1042+
transformed = edge.transform(origin, target)
1043+
if transformed != edge:
1044+
changes.append((idx, edge, transformed))
1045+
new_edges.append(transformed)
1046+
except ValueError as e:
1047+
self.console.print(f"[red]Error:[/red] {e}")
1048+
return False
1049+
except Exception as e:
1050+
self.console.print(f"[red]Error:[/red] transform failed: {e}")
1051+
return False
1052+
1053+
mode = "dry run" if dry_run else "applied"
1054+
self.console.print(
1055+
f"[green]{len(changes)}[/green] of [cyan]{len(self.edges)}[/cyan] "
1056+
f"edge(s) affected ([bold]{mode}[/bold])"
1057+
)
1058+
if not dry_run:
1059+
self.edges = new_edges
1060+
1061+
if not changes:
1062+
return False
1063+
1064+
page_size = int(self.settings.get("search_page_size", 10))
1065+
if page_size < 1:
1066+
page_size = 10
1067+
1068+
self._paginate(
1069+
changes,
1070+
lambda n, change: self._render_transform_change(n, *change),
1071+
page_size,
1072+
)
1073+
return False
1074+
1075+
def _render_transform_change(
1076+
self,
1077+
n: int,
1078+
idx: int,
1079+
before: Hyperedge,
1080+
after: Hyperedge,
1081+
) -> None:
1082+
self.console.print(f"[bold]#{n}[/bold] [dim](edge {idx})[/dim]")
1083+
self.console.print(" [dim red]before:[/dim red]")
1084+
self.console.print(self.formatter.format(before))
1085+
self.console.print(" [dim green]after:[/dim green]")
1086+
self.console.print(self.formatter.format(after))
1087+
self.console.print()
1088+
9811089
def _render_types_row(self, n: int, type_str: str, count: int) -> None:
9821090
color = "white"
9831091
if type_str:

src/hyperbase/transforms.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,13 @@ def _transform_impl(
202202
if recursive and edge.not_atom:
203203
edge = Hyperedge(tuple(_transform_impl(c, origin, target, True) for c in edge))
204204

205-
matches = match_pattern(edge, origin)
206-
if matches:
207-
bindings = matches[0]
208-
edge = _instantiate(target, origin, edge, bindings, top=True)
205+
origin_vars = _collect_var_names(origin)
206+
for bindings in match_pattern(edge, origin):
207+
# The matcher may return partial bindings when constraint propagation
208+
# fails on some pattern positions but enough vars satisfy its min_vars
209+
# threshold. For a rewrite we require every origin variable bound.
210+
if origin_vars.issubset(bindings.keys()):
211+
return _instantiate(target, origin, edge, bindings, top=True)
209212
return edge
210213

211214

tests/test_hyperedge.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,6 +1227,17 @@ def test_transform_bare_variable_substitution(self):
12271227
result = edge.transform(hedge("(eats/Pd.so X Y)"), hedge("Y"))
12281228
assert str(result) == "apples/C"
12291229

1230+
def test_transform_partial_match_unchanged(self):
1231+
# The matcher returns a partial binding {X: ...} (no Y, Z) when the
1232+
# x-position arg cannot match (Y/Ta Z). transform must treat this as
1233+
# no-match rather than blowing up on the unbound Y / Z.
1234+
edge = hedge("((had/Mm done/Pd.ox) (the/Md task) by/Ta)")
1235+
result = edge.transform(
1236+
hedge("(X/P.{x} (Y/Ta Z))"),
1237+
hedge("((Y/Mx X/P.{s}) Z)"),
1238+
)
1239+
assert result == edge
1240+
12301241

12311242
if __name__ == "__main__":
12321243
unittest.main()

0 commit comments

Comments
 (0)