// Package astquery is the structural code-search engine behind the // `search_ast` MCP tool. It runs tree-sitter pattern queries (raw // S-expressions or one of the bundled named detectors) against // already-indexed files and returns each match enriched with the // enclosing symbol from the graph, the captured nodes, and the // detector metadata. // // The package is deliberately graph-aware: results carry a // `SymbolID` field so a caller can chain straight into // `find_usages`, `verify_change`, or `apply_code_action` without a // second graph walk. The pre-filter side of the same coin is on the // caller — the MCP wrapper decides which (file, language) targets to // hand the engine, scoping by repo / project / community / churn / // fan-in / path-prefix before the engine spends a single tree-sitter // parse. That two-layer split keeps astquery independently testable // (no graph dependency) while still letting the wrapper exploit // graph predicates ast-grep can't express. package astquery import ( "context" "errors" "fmt" "os" "path/filepath" "runtime" "sort" "strings" "sync" "github.com/zzet/gortex/internal/parser" sitter "github.com/zzet/gortex/internal/parser/tsitter" ) // Target points the engine at one indexed file. Both the on-disk path // (for parsing) and the repo-prefixed graph path (for result // enrichment and SymbolLookup) are needed because the graph speaks // repo-relative IDs and the filesystem speaks absolute paths. type Target struct { AbsPath string GraphPath string Language string } // SymbolLookup resolves the enclosing function/method/closure at a // 1-based line in a graph-relative file. The MCP layer wires this // against `*graph.Graph`; tests can pass a stub. Returning empty // strings is fine — matches simply ship without symbol enrichment. type SymbolLookup func(graphPath string, line int) (symbolID, symbolName string) // LanguageResolver maps a language name (as stored on KindFile nodes — // "go", "python", "typescript", …) to its tree-sitter binding. The // engine ships a default resolver covering the languages used by the // bundled detectors; consumers can pass an extended resolver to // support raw-pattern queries against any language Gortex indexes. type LanguageResolver func(name string) *sitter.Language // Options are the engine's run knobs. Pattern XOR Detector must be // set; the engine does not invent a detector when both are blank. type Options struct { // Pattern is a raw tree-sitter S-expression query. Used when // Detector is empty. The pattern's language is inferred from // each Target's Language field — one compiled query per // distinct (pattern, language) pair, cached for the run. Pattern string // Detector is the name of a bundled detector. When set, Pattern // and Language are ignored; the engine picks the per-language // pattern from the detector definition. Detector string // Language pre-filters Targets when Pattern is set: only // targets whose Language matches this string are processed. // Empty = no language filter. Language string // Targets is the file set to scan. The MCP wrapper builds this // from the graph after applying scope predicates (repo / // project / community / fan-in / churn / path-prefix); the // engine itself does no graph walk. Targets []Target // SymbolLookup is consulted after a match to enrich the row // with the enclosing function. Optional; nil leaves SymbolID // blank. SymbolLookup SymbolLookup // Resolver maps language names to tree-sitter bindings. // Required for both raw-pattern and detector runs (a detector's // per-language patterns still need the binding to compile). // Pass DefaultLanguageResolver for the bundled set. Resolver LanguageResolver // Limit caps the total returned matches. 0 means use Default. Limit int // MaxMatchText truncates each row's `MatchText` to this many // bytes (utf-8 safe — we cut on rune boundaries). 0 → 200. MaxMatchText int // ExcludeTests drops matches whose target file path looks like // a test (per IsTestPath). Detectors default this to true; raw // patterns default to false because the user typed the query. ExcludeTests bool // Concurrency caps the worker pool. 0 → runtime.GOMAXPROCS(0). Concurrency int } // Match is a single hit. The fields are deliberately flat so the MCP // layer can hand them to the standard wire-format encoders without // translation. type Match struct { File string `json:"file"` Line int `json:"line"` EndLine int `json:"end_line"` Column int `json:"column"` EndCol int `json:"end_col"` SymbolID string `json:"symbol_id,omitempty"` SymbolName string `json:"symbol_name,omitempty"` Detector string `json:"detector,omitempty"` Severity string `json:"severity,omitempty"` Language string `json:"language"` Text string `json:"text"` Captures map[string]string `json:"captures,omitempty"` } // Result wraps the match list with summary counts. `Truncated` is set // when Limit cut the response short — useful for the MCP layer to // decide whether to surface a "narrow your filter" hint. type Result struct { Matches []Match `json:"matches"` Total int `json:"total"` Truncated bool `json:"truncated,omitempty"` FilesWalked int `json:"files_walked"` Errors []string `json:"errors,omitempty"` } const ( defaultLimit = 50 defaultMaxMatchText = 200 defaultMaxFileSize = 4 * 1024 * 1024 ) var errNoQuery = errors.New("astquery: pattern or detector is required") // Run executes the configured query across all Targets and returns // the assembled Result. The function is safe to call concurrently. // // Compilation strategy: each (pattern, language) pair is compiled // once for the run via per-language `*parser.PreparedQuery` instances // kept in a local cache. After Run returns the cache is closed so we // don't leak C resources across runs. func Run(ctx context.Context, opts Options) (Result, error) { if opts.Pattern == "" && opts.Detector == "" { return Result{}, errNoQuery } if opts.Resolver == nil { opts.Resolver = DefaultLanguageResolver } if opts.Limit <= 0 { opts.Limit = defaultLimit } if opts.MaxMatchText <= 0 { opts.MaxMatchText = defaultMaxMatchText } if opts.Concurrency <= 0 { opts.Concurrency = runtime.GOMAXPROCS(0) } plan, err := buildPlan(opts) if err != nil { return Result{}, err } defer plan.close() if len(plan.targets) == 0 { return Result{}, nil } jobs := make(chan Target, len(plan.targets)) results := make(chan []Match, opts.Concurrency) errCh := make(chan string, opts.Concurrency) var wg sync.WaitGroup for i := 0; i < opts.Concurrency; i++ { wg.Add(1) go func() { defer wg.Done() for t := range jobs { if ctx.Err() != nil { return } m, e := plan.runTarget(ctx, t, opts) if e != nil { select { case errCh <- fmt.Sprintf("%s: %v", t.GraphPath, e): default: } } if len(m) > 0 { results <- m } } }() } go func() { for _, t := range plan.targets { jobs <- t } close(jobs) wg.Wait() close(results) close(errCh) }() out := Result{FilesWalked: len(plan.targets)} for batch := range results { out.Matches = append(out.Matches, batch...) } for e := range errCh { out.Errors = append(out.Errors, e) } // Stable order: by file then line. Important for golden tests // and for agent UX (consistent listings across reruns). sort.Slice(out.Matches, func(i, j int) bool { if out.Matches[i].File != out.Matches[j].File { return out.Matches[i].File < out.Matches[j].File } return out.Matches[i].Line < out.Matches[j].Line }) out.Total = len(out.Matches) if out.Total > opts.Limit { out.Matches = out.Matches[:opts.Limit] out.Truncated = true } return out, ctx.Err() } // plan is the per-run state: one compiled query per language, the // target list filtered by Language and ExcludeTests, and the detector // definition (if any). type plan struct { queries map[string]*parser.PreparedQuery detector *Detector targets []Target pattern string } func buildPlan(opts Options) (*plan, error) { p := &plan{queries: make(map[string]*parser.PreparedQuery)} // Detector mode: ignore Pattern + Language; the detector's // per-language map drives both query compilation and target // filtering. if opts.Detector != "" { d, ok := lookupDetector(opts.Detector) if !ok { return nil, fmt.Errorf("astquery: unknown detector %q (call ListDetectors to enumerate)", opts.Detector) } p.detector = d for _, t := range opts.Targets { if _, has := d.Languages[t.Language]; !has { continue } if (opts.ExcludeTests || d.ExcludeTests) && IsTestPath(t.AbsPath) { continue } p.targets = append(p.targets, t) } // Compile one query per language used by the detector, // but only languages that actually have targets — saves // CGO compile cost when the index is monolingual. used := make(map[string]bool) for _, t := range p.targets { used[t.Language] = true } for lang := range used { pat, ok := d.Languages[lang] if !ok { continue } tsLang := opts.Resolver(lang) if tsLang == nil { continue } q, err := parser.NewPreparedQuery(pat, tsLang) if err != nil { p.close() return nil, fmt.Errorf("astquery: detector %q has invalid pattern for language %q: %w", d.Name, lang, err) } p.queries[lang] = q } return p, nil } // Raw-pattern mode: one query, one language. p.pattern = opts.Pattern for _, t := range opts.Targets { if opts.Language != "" && t.Language != opts.Language { continue } if opts.ExcludeTests && IsTestPath(t.AbsPath) { continue } p.targets = append(p.targets, t) } used := make(map[string]bool) for _, t := range p.targets { used[t.Language] = true } for lang := range used { tsLang := opts.Resolver(lang) if tsLang == nil { continue } q, err := parser.NewPreparedQuery(opts.Pattern, tsLang) if err != nil { p.close() return nil, fmt.Errorf("astquery: pattern compile (lang=%s): %w", lang, err) } p.queries[lang] = q } if len(p.queries) == 0 { return p, nil } return p, nil } func (p *plan) close() { for _, q := range p.queries { q.Close() } p.queries = nil } func (p *plan) runTarget(ctx context.Context, t Target, opts Options) ([]Match, error) { src, err := readBoundedFile(t.AbsPath, defaultMaxFileSize) if err != nil { return nil, err } return p.runBytes(ctx, t, opts, src) } func (p *plan) runBytes(_ context.Context, t Target, opts Options, src []byte) ([]Match, error) { q := p.queries[t.Language] if q == nil { return nil, nil } tsLang := opts.Resolver(t.Language) if tsLang == nil { return nil, fmt.Errorf("no tree-sitter binding for language %q", t.Language) } tree, err := parser.ParseFile(src, tsLang) if err != nil { return nil, err } defer tree.Close() hits := parser.RunPrepared(q, tree.RootNode(), src) if len(hits) == 0 { return nil, nil } out := make([]Match, 0, len(hits)) for _, h := range hits { // Prefer a "@match" capture as the row's anchor; fall back // to the largest captured node when the user didn't tag // one. This matches ast-grep's convention without making // every detector spell @match explicitly. anchor := pickAnchor(h.Captures) if anchor == nil { continue } text := truncateRune(anchor.Text, opts.MaxMatchText) caps := make(map[string]string, len(h.Captures)) for name, cn := range h.Captures { if name == "match" { continue } caps[name] = truncateRune(cn.Text, opts.MaxMatchText) } m := Match{ File: t.GraphPath, Line: anchor.StartLine + 1, EndLine: anchor.EndLine + 1, Column: anchor.StartCol, EndCol: anchor.EndCol, Language: t.Language, Text: text, Captures: caps, } if p.detector != nil { m.Detector = p.detector.Name m.Severity = p.detector.Severity if p.detector.PostFilter != nil && !p.detector.PostFilter(h, src) { continue } } if opts.SymbolLookup != nil { m.SymbolID, m.SymbolName = opts.SymbolLookup(t.GraphPath, m.Line) } out = append(out, m) } return out, nil } // pickAnchor selects the most useful capture as the result's anchor. // Priority: explicit `@match` capture > longest captured span. The // fallback is important for detectors that only define semantic // captures and never an explicit `@match`. func pickAnchor(caps map[string]*parser.CapturedNode) *parser.CapturedNode { if m, ok := caps["match"]; ok { return m } var best *parser.CapturedNode bestLen := -1 for _, c := range caps { l := len(c.Text) if l > bestLen { best = c bestLen = l } } return best } func readBoundedFile(path string, maxBytes int64) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() st, err := f.Stat() if err != nil { return nil, err } if st.Size() > maxBytes { return nil, fmt.Errorf("file too large (%d bytes; cap %d)", st.Size(), maxBytes) } buf := make([]byte, st.Size()) if _, err := f.Read(buf); err != nil && st.Size() != 0 { return nil, err } return buf, nil } // truncateRune cuts s to the first n bytes that fall on a UTF-8 // boundary. Appends "…" when truncation actually fired so consumers // don't mistake a snipped result for a complete one. func truncateRune(s string, n int) string { if len(s) <= n { return s } // Walk back to a rune start. for n > 0 && (s[n]&0xC0) == 0x80 { n-- } return s[:n] + "…" } // RunOnSource executes one query against in-memory source bytes // without touching the filesystem. Useful for unit tests, for the // "lint this buffer before commit" UX, and for any caller that // already has the bytes in hand. The function builds a Plan with a // single synthetic Target whose GraphPath equals filePath; pass an // empty filePath when the caller doesn't care. // // Limit / MaxMatchText / ExcludeTests are honored. SymbolLookup is // honored too so a caller threading a graph in can still get // enclosing-symbol enrichment for buffer-side lints. func RunOnSource(ctx context.Context, opts Options, filePath, language string, src []byte) (Result, error) { if opts.Pattern == "" && opts.Detector == "" { return Result{}, errNoQuery } if opts.Resolver == nil { opts.Resolver = DefaultLanguageResolver } if opts.Limit <= 0 { opts.Limit = defaultLimit } if opts.MaxMatchText <= 0 { opts.MaxMatchText = defaultMaxMatchText } target := Target{ AbsPath: filePath, GraphPath: filePath, Language: language, } opts.Targets = []Target{target} plan, err := buildPlan(opts) if err != nil { return Result{}, err } defer plan.close() matches, runErr := plan.runBytes(ctx, target, opts, src) out := Result{FilesWalked: 1} if runErr != nil { out.Errors = []string{fmt.Sprintf("%s: %v", filePath, runErr)} } out.Matches = matches out.Total = len(matches) if out.Total > opts.Limit { out.Matches = out.Matches[:opts.Limit] out.Truncated = true } return out, nil } // IsTestPath returns true when the file path looks like a test under // any of Gortex's recognised conventions. The list is deliberately // conservative; false negatives (a test we don't recognise) ship // matches that the agent can drop, which is much better than false // positives (skipping production code that happens to have "test" in // the name). func IsTestPath(absPath string) bool { base := filepath.Base(absPath) dir := filepath.ToSlash(filepath.Dir(absPath)) switch { case strings.HasSuffix(base, "_test.go"): return true case strings.HasSuffix(base, ".test.ts"), strings.HasSuffix(base, ".test.tsx"), strings.HasSuffix(base, ".test.js"), strings.HasSuffix(base, ".test.jsx"), strings.HasSuffix(base, ".spec.ts"), strings.HasSuffix(base, ".spec.tsx"), strings.HasSuffix(base, ".spec.js"), strings.HasSuffix(base, ".spec.jsx"): return true case strings.HasPrefix(base, "test_") && strings.HasSuffix(base, ".py"): return true case strings.HasSuffix(base, "_test.py"): return true case strings.HasSuffix(base, "_test.rb"), strings.HasSuffix(base, "_spec.rb"): return true case strings.HasSuffix(base, "Test.java"), strings.HasSuffix(base, "Tests.java"), strings.HasSuffix(base, "IT.java"): return true case strings.Contains(dir, "/__tests__/") || strings.HasSuffix(dir, "/__tests__"): return true case strings.Contains(dir, "/test/") || strings.Contains(dir, "/tests/") || strings.Contains(dir, "/spec/"): return true } return false } // Compile-time checks: the runQuery hot path takes a *parser.PreparedQuery // pointer; ensure we never lose the reference and force-close it under // us. The plan owns every query and closes them in plan.close(). var _ = parser.RunPrepared