33import sys
44import time
55import traceback
6- from collections .abc import Iterable
6+ from collections import Counter
7+ from collections .abc import Callable , Iterable
78from pathlib import Path
89from 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