Skip to content

Commit 1ff7e2d

Browse files
committed
repl /count command
1 parent b923ad4 commit 1ff7e2d

2 files changed

Lines changed: 102 additions & 10 deletions

File tree

CHANGELOG.md

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

1212
### Changed
1313

src/hyperbase/cli/repl.py

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import sys
44
import time
55
import traceback
6-
from collections.abc import Iterable
6+
from collections import Counter
7+
from collections.abc import Callable, Iterable
78
from pathlib import Path
89
from typing import Any, cast
910

@@ -354,6 +355,10 @@ def __init__(self, parser_name: str, settings: dict[str, Any]) -> None:
354355
"help": "Search loaded edges for hyperedges matching a pattern",
355356
"handler": self.cmd_search,
356357
},
358+
"count": {
359+
"help": "Count pattern matches across loaded edges (most common first)",
360+
"handler": self.cmd_count,
361+
},
357362
}
358363

359364
self.session = PromptSession(
@@ -768,7 +773,11 @@ def cmd_search(self, args: list) -> bool:
768773
f"[green]{len(hits)}[/green] match(es) "
769774
f"({'recursive' if recursive else 'top-level only'})"
770775
)
771-
self._paginate_search_results(hits, page_size)
776+
self._paginate(
777+
hits,
778+
lambda n, hit: self._render_search_hit(n, *hit),
779+
page_size,
780+
)
772781
return False
773782

774783
def _render_search_hit(
@@ -786,18 +795,20 @@ def _render_search_hit(
786795
for bi, b in enumerate(nonempty):
787796
prefix = f" bindings[{bi}]: " if len(nonempty) > 1 else " bindings: "
788797
for var, val in b.items():
789-
line = Text(prefix, style="dim")
798+
line = Text()
799+
line.append(prefix, style="dim")
790800
line.append(f"{var} = ", style="dim")
791801
line.append(self.formatter.format(val))
792802
self.console.print(line)
793803
self.console.print()
794804

795-
def _paginate_search_results(
805+
def _paginate(
796806
self,
797-
hits: list[tuple[int, Hyperedge, Hyperedge, list[dict]]],
807+
items: list[Any],
808+
render_fn: Callable[[int, Any], None],
798809
page_size: int,
799810
) -> None:
800-
total = len(hits)
811+
total = len(items)
801812
pages = (total + page_size - 1) // page_size
802813
page = 0
803814
while True:
@@ -809,8 +820,7 @@ def _paginate_search_results(
809820
f"(results {start + 1}-{end} of {total}) --[/dim]"
810821
)
811822
for i in range(start, end):
812-
top_idx, top_edge, sub, bindings = hits[i]
813-
self._render_search_hit(i + 1, top_idx, top_edge, sub, bindings)
823+
render_fn(i + 1, items[i])
814824

815825
if pages == 1:
816826
return
@@ -825,7 +835,7 @@ def _paginate_search_results(
825835
.lower()
826836
)
827837
except (KeyboardInterrupt, EOFError):
828-
self.console.print("[dim](search aborted)[/dim]")
838+
self.console.print("[dim](aborted)[/dim]")
829839
return
830840

831841
if choice in ("q", "quit", "exit"):
@@ -836,6 +846,88 @@ def _paginate_search_results(
836846
continue
837847
page += 1
838848

849+
def cmd_count(self, args: list) -> bool:
850+
if not args:
851+
self.console.print(
852+
"[red]Error:[/red] /count requires a pattern: "
853+
"[cyan]/count <pattern>[/cyan]"
854+
)
855+
return False
856+
if not self.edges:
857+
self.console.print("[yellow]No edges loaded.[/yellow]")
858+
self.console.print(
859+
"[dim]Use[/dim] [cyan]/load <path>[/cyan] [dim]first.[/dim]"
860+
)
861+
return False
862+
863+
pattern_text = " ".join(args)
864+
try:
865+
pattern = hedge(pattern_text)
866+
except Exception as e:
867+
self.console.print(f"[red]Error:[/red] failed to parse pattern: {e}")
868+
return False
869+
if pattern is None:
870+
self.console.print(
871+
f"[red]Error:[/red] could not parse pattern: "
872+
f"[cyan]{pattern_text}[/cyan]"
873+
)
874+
return False
875+
876+
recursive = bool(self.settings.get("search_recursive", True))
877+
page_size = int(self.settings.get("search_page_size", 10))
878+
if page_size < 1:
879+
page_size = 10
880+
881+
# Counter key:
882+
# - if the pattern has variables, key is sorted tuple of (var, value)
883+
# - otherwise the matched (sub)edge itself
884+
counter: Counter[Any] = Counter()
885+
for top_edge in self.edges:
886+
candidates: Iterable[Hyperedge] = (
887+
self._iter_subedges_ordered(top_edge) if recursive else [top_edge]
888+
)
889+
for sub in candidates:
890+
bindings_list = sub.match(pattern)
891+
for b in bindings_list:
892+
key: Any = tuple(sorted(b.items())) if b else sub
893+
counter[key] += 1
894+
895+
if not counter:
896+
self.console.print(
897+
f"[yellow]No matches[/yellow] for [cyan]{pattern_text}[/cyan]"
898+
)
899+
return False
900+
901+
items = counter.most_common()
902+
self.console.print(
903+
f"[green]{sum(counter.values())}[/green] match(es), "
904+
f"[cyan]{len(counter)}[/cyan] distinct "
905+
f"({'recursive' if recursive else 'top-level only'})"
906+
)
907+
self._paginate(
908+
items,
909+
lambda n, item: self._render_count_row(n, item[0], item[1]),
910+
page_size,
911+
)
912+
return False
913+
914+
def _render_count_row(self, n: int, key: object, count: int) -> None:
915+
header = Text()
916+
header.append(f"#{n} ", style="bold")
917+
header.append(f"{count}x", style="green")
918+
self.console.print(header)
919+
920+
if isinstance(key, tuple):
921+
for var, val in key:
922+
line = Text()
923+
line.append(" ", style="dim")
924+
line.append(f"{var} = ", style="dim")
925+
line.append(self.formatter.format(val))
926+
self.console.print(line)
927+
elif isinstance(key, Hyperedge):
928+
self.console.print(Text(" ") + self.formatter.format(key))
929+
self.console.print()
930+
839931
def _load_edges_from_jsonl(self, path: Path) -> tuple[list[Hyperedge], int]:
840932
edges: list[Hyperedge] = []
841933
skipped = 0

0 commit comments

Comments
 (0)