"""Grammar index for runtime lookup. Loads grammars.yml and builds a lookup index mapping (package, context_symbol) → GBNF grammar string. Usage: from bex.grammar_index import load_grammar_index idx = load_grammar_index("/path/to/project") grammar = idx.get(file_path="src/services/Chat.kt", context_symbol="return") all_grammars = idx.get_package("src/services") """ import os import re import yaml _LABEL_RE = re.compile(r"^(.+)\s+\[([^\]]+)\]$") class GrammarIndex: """In-memory index of grammars keyed by (package, context_symbol).""" def __init__(self, project_root, entries): """ Args: project_root: absolute path to the project root. entries: list of dicts with keys: package, grammar, score, methods, algorithm. """ self.project_root = project_root self._by_package = {} # package → [(symbol, grammar, score)] self._all = entries for e in entries: label = e["package"] m = _LABEL_RE.match(label) if m: pkg = m.group(1).rstrip("/") symbol = m.group(2) else: pkg = label.rstrip("/") symbol = "" self._by_package.setdefault(pkg, []).append(( symbol, e["grammar"], e.get("score", 0), e.get("methods", 0), )) # Sort each package's entries by score descending (best first) for pkg in self._by_package: self._by_package[pkg].sort(key=lambda x: -x[2]) def get(self, file_path, context_symbol=None): """Get the best grammar for a file, optionally filtered by context. Args: file_path: path to the source file (absolute or relative to project_root). context_symbol: if provided, match the leaf grammar whose first symbol is this. If None, return the best grammar for the package. Returns: GBNF grammar string, or None if no match. """ pkg = self._resolve_package(file_path) entries = self._by_package.get(pkg, []) if not entries: return None if context_symbol: for sym, grammar, score, methods in entries: if sym == context_symbol: return grammar # Fall back to best grammar for the package return entries[0][1] if entries else None def get_package(self, file_path): """Get all grammars for a file's package. Returns: list of (context_symbol, grammar, score, methods) tuples, sorted by score descending. Empty list if no match. """ pkg = self._resolve_package(file_path) return list(self._by_package.get(pkg, [])) def get_all(self): """Return all entries as a flat list.""" return list(self._all) def packages(self): """Return sorted list of all indexed packages.""" return sorted(self._by_package.keys()) def _resolve_package(self, file_path): """Map a file path to its package.""" # Make absolute if relative if not os.path.isabs(file_path): file_path = os.path.join(self.project_root, file_path) # Get directory of file relative to project root try: rel = os.path.relpath(os.path.dirname(file_path), self.project_root) except ValueError: # Different drives on Windows return "" if rel == ".": return "" return rel def __repr__(self): return f"GrammarIndex({self.project_root}, {len(self._all)} entries, {len(self._by_package)} packages)" def load_grammar_index(project_root): """Load grammars.yml from {project_root}/.dervish/grammars.yml. Returns GrammarIndex, or empty index if no file found. """ yml_path = os.path.join(project_root, ".dervish", "grammars.yml") if not os.path.exists(yml_path): return GrammarIndex(project_root, []) with open(yml_path) as f: data = yaml.safe_load(f) entries = [] if isinstance(data, dict): for module, items in data.items(): if not isinstance(items, list): continue for item in items: if isinstance(item, dict) and "package" in item and "grammar" in item: entries.append(item) return GrammarIndex(project_root, entries)