Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

341 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package parser
import (
"context"
"fmt"
"sync"
"time"
ts "github.com/tree-sitter/go-tree-sitter"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
)
const parseTimeout = 5 * time.Second
// parserPool reuses *sitter.Parser instances across ParseFile calls so
// each indexer worker amortises one parser allocation instead of
// allocating + freeing a C-side TSParser per file.
//
// Only parsers whose last parse SUCCEEDED are pooled. A parse cancelled
// during tree-sitter's balancing phase leaves the C parser's internal
// canceled_balancing flag set, and ts_parser_reset does NOT clear it —
// so the next ts_parser_parse on that parser jumps straight to the
// balance label and aborts the whole process on ts_assert(finished_tree).
// ParseFile therefore Closes any parser whose parse returned an error
// instead of recycling it; Reset() cannot sanitise such a parser.
var parserPool = sync.Pool{
New: func() any { return sitter.NewParser() },
}
// getParser checks a parser out of the pool and binds lang to it.
func getParser(lang *sitter.Language) *sitter.Parser {
p := parserPool.Get().(*sitter.Parser)
p.SetLanguage(lang)
return p
}
// putParser returns a parser to the pool after a SUCCESSFUL parse.
// Reset drops the finished-tree and old-tree references so the pooled
// parser doesn't pin that memory. Never call this for a parser whose
// parse errored — Reset cannot clear the C-side canceled_balancing
// flag, so ParseFile Closes errored parsers instead.
func putParser(p *sitter.Parser) {
if p == nil {
return
}
p.Reset()
parserPool.Put(p)
}
// CapturedNode holds information about a single captured tree-sitter node.
type CapturedNode struct {
Text string
StartLine int // 0-based (tree-sitter native)
EndLine int // 0-based
StartCol int
EndCol int
Node *sitter.Node
}
// QueryResult represents a single match from a tree-sitter query.
type QueryResult struct {
Captures map[string]*CapturedNode
}
// ParseFile parses source bytes with the given language and returns the tree.
// The caller must call tree.Close() when done.
func ParseFile(src []byte, lang *sitter.Language) (*sitter.Tree, error) {
parser := getParser(lang)
// Pool the parser only on a clean parse. An errored parse (cancelled
// / timed out) may have left the C parser's canceled_balancing flag
// set, which ts_parser_reset cannot clear — recycling it would abort
// the process on the next caller's parse. The defer Closes the
// parser unless the success path pooled it.
pooled := false
defer func() {
if !pooled {
parser.Close()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), parseTimeout)
defer cancel()
tree, err := parser.ParseCtx(ctx, nil, src)
if err != nil {
return nil, fmt.Errorf("tree-sitter parse: %w", err)
}
putParser(parser)
pooled = true
return tree, nil
}
// PreparedQuery is a compiled tree-sitter query safe to reuse across
// many Parse calls. Compile once at extractor init and hang on to it —
// queries are thread-safe for read-only use and avoid the per-call
// CGO compile that dominated large-repo indexing.
type PreparedQuery struct {
q *sitter.Query
// names maps a capture index to its name, cached at compile time.
// ts.Query.CaptureNameForId crosses CGO and allocates a fresh
// string per call; a query firing thousands of times per file made
// that roughly one allocation per capture across the whole index.
names []string
}
// NewPreparedQuery compiles a tree-sitter query pattern for the given
// language. The returned *PreparedQuery is safe for concurrent use by
// many goroutines running queries via a pooled QueryCursor.
func NewPreparedQuery(pattern string, lang *sitter.Language) (*PreparedQuery, error) {
q, err := sitter.NewQuery([]byte(pattern), lang)
if err != nil {
return nil, fmt.Errorf("tree-sitter query compile: %w", err)
}
return &PreparedQuery{q: q, names: q.Inner().CaptureNames()}, nil
}
// captureName resolves a capture index to its name from the cached
// slice, falling back to the CGO accessor only for an index outside
// the cached range (e.g. a PreparedQuery built without the cache).
func (pq *PreparedQuery) captureName(id uint32) string {
if int(id) < len(pq.names) {
return pq.names[id]
}
return pq.q.CaptureNameForId(id)
}
// MustPreparedQuery is NewPreparedQuery that panics on compile error.
// Use for extractor-internal queries that are compile-time constants:
// an error is a bug in the extractor, not runtime data, so crashing
// loud at init is the right behavior.
func MustPreparedQuery(pattern string, lang *sitter.Language) *PreparedQuery {
q, err := NewPreparedQuery(pattern, lang)
if err != nil {
panic(err)
}
return q
}
// Close releases the underlying query. After Close the PreparedQuery
// must not be used.
func (pq *PreparedQuery) Close() {
if pq != nil && pq.q != nil {
pq.q.Close()
pq.q = nil
}
}
// cursorPool reuses *ts.QueryCursor across query runs. The new
// QueryCursor is stateless across Matches() calls — each call starts
// fresh iteration — so pooling is safe.
var cursorPool = sync.Pool{
New: func() any { return ts.NewQueryCursor() },
}
func getCursor() *ts.QueryCursor { return cursorPool.Get().(*ts.QueryCursor) }
func putCursor(c *ts.QueryCursor) { cursorPool.Put(c) }
// RunQuery executes a tree-sitter S-expression query against a node and
// returns all matches with their captures. The query is compiled on
// every call — use RunPrepared with a precompiled query in hot paths.
func RunQuery(pattern string, lang *sitter.Language, node *sitter.Node, src []byte) ([]QueryResult, error) {
q, err := sitter.NewQuery([]byte(pattern), lang)
if err != nil {
return nil, fmt.Errorf("tree-sitter query compile: %w", err)
}
defer q.Close()
return runQuery(&PreparedQuery{q: q, names: q.Inner().CaptureNames()}, node, src), nil
}
// RunPrepared executes a precompiled query against a node and returns
// all matches with their captures.
func RunPrepared(pq *PreparedQuery, node *sitter.Node, src []byte) []QueryResult {
if pq == nil || pq.q == nil {
return nil
}
return runQuery(pq, node, src)
}
// runQuery is the hot iterator: it drives the cursor, copies captures
// out of the cursor's reusable buffer before calling Next() again, and
// assembles QueryResult values the extractors expect.
func runQuery(pq *PreparedQuery, node *sitter.Node, src []byte) []QueryResult {
if node == nil || node.Inner() == nil {
return nil
}
cursor := getCursor()
defer putCursor(cursor)
iter := cursor.Matches(pq.q.Inner(), node.Inner(), src)
var results []QueryResult
for {
match := iter.Next()
if match == nil {
break
}
if len(match.Captures) == 0 {
continue
}
qr := QueryResult{Captures: make(map[string]*CapturedNode, len(match.Captures))}
for _, c := range match.Captures {
// c.Node is a value; copying it detaches from the cursor's
// per-match buffer so the pointer stays valid after Next().
nodeCopy := c.Node
name := pq.captureName(c.Index)
sp := nodeCopy.StartPosition()
ep := nodeCopy.EndPosition()
qr.Captures[name] = &CapturedNode{
Text: nodeCopy.Utf8Text(src),
StartLine: int(sp.Row),
EndLine: int(ep.Row),
StartCol: int(sp.Column),
EndCol: int(ep.Column),
Node: node.WrapVal(nodeCopy),
}
}
results = append(results, qr)
}
return results
}
// EachMatch runs a prepared query and invokes fn for each match.
//
// Hot-path contract: the QueryResult.Captures map and each *CapturedNode
// it returns are REUSED across matches within a single EachMatch call
// (and recycled into a sync.Pool when the call returns). Callers must
// consume what they need from each match synchronously inside fn and
// MUST NOT retain Captures or its *CapturedNode values past the next
// match iteration — the underlying storage is overwritten in place.
// Copy out scalars (Text / StartLine / EndLine / Node) into a caller-
// owned struct if you need to defer work to a post-pass.
//
// This contract eliminates the per-match `make(map[string]*CapturedNode)`
// and per-capture `&CapturedNode{}` allocations that dominated
// EachMatch's heap churn on large repos (5.5 GB cumulative across a
// 35k-file linux/drivers index before pooling).
func EachMatch(pq *PreparedQuery, node *sitter.Node, src []byte, fn func(QueryResult)) {
if pq == nil || pq.q == nil {
return
}
if node == nil || node.Inner() == nil {
return
}
cursor := getCursor()
defer putCursor(cursor)
scratch := getMatchScratch()
defer putMatchScratch(scratch)
iter := cursor.Matches(pq.q.Inner(), node.Inner(), src)
for {
match := iter.Next()
if match == nil {
break
}
if len(match.Captures) == 0 {
continue
}
// Reset the per-match views in place. Map storage and node
// slab survive across matches within this call and across
// pooled calls; clear() is O(n) but n is tiny (one entry per
// capture, typically < 8).
clear(scratch.captures)
if cap(scratch.nodes) < len(match.Captures) {
scratch.nodes = make([]CapturedNode, len(match.Captures))
} else {
scratch.nodes = scratch.nodes[:len(match.Captures)]
}
for i, c := range match.Captures {
nodeCopy := c.Node
name := pq.captureName(c.Index)
sp := nodeCopy.StartPosition()
ep := nodeCopy.EndPosition()
scratch.nodes[i] = CapturedNode{
Text: nodeCopy.Utf8Text(src),
StartLine: int(sp.Row),
EndLine: int(ep.Row),
StartCol: int(sp.Column),
EndCol: int(ep.Column),
Node: node.WrapVal(nodeCopy),
}
scratch.captures[name] = &scratch.nodes[i]
}
fn(QueryResult{Captures: scratch.captures})
}
}
// matchScratch holds the reusable storage backing a single EachMatch
// call's captures map and *CapturedNode pointers. Pooled across calls
// to amortise the map + slab allocations that dominated EachMatch heap
// churn before the hot-path contract was tightened.
type matchScratch struct {
captures map[string]*CapturedNode
nodes []CapturedNode
}
var matchScratchPool = sync.Pool{
New: func() any {
return &matchScratch{
captures: make(map[string]*CapturedNode, 8),
nodes: make([]CapturedNode, 0, 8),
}
},
}
func getMatchScratch() *matchScratch {
return matchScratchPool.Get().(*matchScratch)
}
func putMatchScratch(s *matchScratch) {
if s == nil {
return
}
// Drop pathological growth so one outlier file (eg autogenerated
// 50k-match header) doesn't pin a huge buffer across the rest of
// the run. 256 captures covers normal kernel .c — anything bigger
// re-allocates next time.
if cap(s.nodes) > 256 {
s.nodes = make([]CapturedNode, 0, 8)
}
clear(s.captures)
matchScratchPool.Put(s)
}
// NOTE: a previous experiment added a per-EachMatch string intern table
// to dedupe Utf8Text copies of repeated identifiers (kmalloc, printk,
// etc. in kernel C). It REGRESSED wall time on linux/drivers by
// 1113s (62.6s clear-on-put / 64.6s preserve-on-put vs the 51.4s
// pool-only baseline). The map machinery cost per-capture exceeded the
// alloc savings: the unique-identifier rate is too high (most captures
// are file-local function/var names, not the small recurring helper
// set), and preserve-across-calls made it worse as the growing map
// slowed every lookup. The text copy in Utf8Text is fine as-is — keep
// pooling, drop interning.
// NodeText extracts the text content of a tree-sitter node from source bytes.
func NodeText(node *sitter.Node, src []byte) string {
return node.Content(src)
}