473 lines
13 KiB
Go
473 lines
13 KiB
Go
package builtin
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
func init() { tool.RegisterBuiltin(codeIndex{}) }
|
|
|
|
type codeIndex struct {
|
|
workDir string
|
|
forbidRoots []string
|
|
}
|
|
|
|
func (codeIndex) Name() string { return "code_index" }
|
|
|
|
func (codeIndex) Description() string {
|
|
return "Lightweight built-in code symbol index. Prefer lsp_* for language semantics and installed code graph MCP tools for call graph, impact, and architecture relationships; use this as the local fallback for file outlines and symbol definition candidates, then verify with read_file or grep."
|
|
}
|
|
|
|
func (codeIndex) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"action":{"type":"string","enum":["outline","search"],"description":"outline lists symbols under path; search finds symbol definition candidates by name."},
|
|
"path":{"type":"string","description":"File or directory path to inspect (default \".\")."},
|
|
"query":{"type":"string","description":"Symbol name or substring for action=search."},
|
|
"kind":{"type":"string","description":"Optional symbol kind filter, such as func, method, class, type, interface, const, var, struct, enum, trait."},
|
|
"limit":{"type":"integer","description":"Maximum symbols to return (default 100, max 200).","minimum":1}
|
|
},
|
|
"required":["action"]
|
|
}`)
|
|
}
|
|
|
|
func (codeIndex) ReadOnly() bool { return true }
|
|
|
|
const (
|
|
codeIndexDefaultLimit = 100
|
|
codeIndexMaxLimit = 200
|
|
codeIndexMaxFileSize = 1 << 20
|
|
codeIndexMaxFiles = 2000
|
|
)
|
|
|
|
type codeIndexArgs struct {
|
|
Action string `json:"action"`
|
|
Path string `json:"path"`
|
|
Query string `json:"query"`
|
|
Kind string `json:"kind"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
type codeSymbol struct {
|
|
Name string
|
|
Kind string
|
|
File string
|
|
Line int
|
|
Parent string
|
|
Signature string
|
|
}
|
|
|
|
func (c codeIndex) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
p := codeIndexArgs{Path: ".", Limit: codeIndexDefaultLimit}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
p.Action = strings.ToLower(strings.TrimSpace(p.Action))
|
|
if p.Action != "outline" && p.Action != "search" {
|
|
return "", fmt.Errorf("action must be outline or search")
|
|
}
|
|
if p.Path == "" {
|
|
p.Path = "."
|
|
}
|
|
if p.Limit <= 0 {
|
|
p.Limit = codeIndexDefaultLimit
|
|
}
|
|
if p.Limit > codeIndexMaxLimit {
|
|
p.Limit = codeIndexMaxLimit
|
|
}
|
|
if p.Action == "search" && strings.TrimSpace(p.Query) == "" {
|
|
return "", fmt.Errorf("query is required for action=search")
|
|
}
|
|
|
|
root := resolveIn(c.workDir, p.Path)
|
|
collectionLimit := p.Limit
|
|
if p.Action == "outline" && hasCodeSymbolFilter(p) {
|
|
collectionLimit = 0
|
|
}
|
|
symbols, truncated, err := c.collect(ctx, root, collectionLimit, p.Action == "outline")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
symbols = filterCodeSymbols(symbols, p)
|
|
if len(symbols) > p.Limit {
|
|
symbols = symbols[:p.Limit]
|
|
truncated = true
|
|
}
|
|
return formatCodeSymbols(symbols, truncated), nil
|
|
}
|
|
|
|
func (c codeIndex) collect(ctx context.Context, root string, limit int, outline bool) ([]codeSymbol, bool, error) {
|
|
if confineRead(c.forbidRoots, root) {
|
|
return nil, false, nil
|
|
}
|
|
info, err := os.Stat(root)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("code_index %s: %w", root, err)
|
|
}
|
|
var files []string
|
|
if !info.IsDir() {
|
|
if supportedCodeIndexFile(root) {
|
|
files = append(files, root)
|
|
}
|
|
} else {
|
|
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if walkErr != nil {
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
if skipForbidDir(path, c.forbidRoots) {
|
|
return filepath.SkipDir
|
|
}
|
|
if path != root && skipCodeIndexDir(d.Name()) {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if supportedCodeIndexFile(path) && !confineRead(c.forbidRoots, path) {
|
|
files = append(files, path)
|
|
if len(files) >= codeIndexMaxFiles {
|
|
return filepath.SkipAll
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("code_index walk %s: %w", root, err)
|
|
}
|
|
}
|
|
sort.Strings(files)
|
|
|
|
var symbols []codeSymbol
|
|
truncated := len(files) >= codeIndexMaxFiles
|
|
for _, file := range files {
|
|
if ctx.Err() != nil {
|
|
return nil, truncated, ctx.Err()
|
|
}
|
|
found, err := c.parseFile(file)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
symbols = append(symbols, found...)
|
|
if outline && limit > 0 && len(symbols) >= limit {
|
|
truncated = true
|
|
break
|
|
}
|
|
}
|
|
sort.Slice(symbols, func(i, j int) bool {
|
|
if symbols[i].File != symbols[j].File {
|
|
return symbols[i].File < symbols[j].File
|
|
}
|
|
if symbols[i].Line != symbols[j].Line {
|
|
return symbols[i].Line < symbols[j].Line
|
|
}
|
|
return symbols[i].Name < symbols[j].Name
|
|
})
|
|
return symbols, truncated, nil
|
|
}
|
|
|
|
func (c codeIndex) parseFile(path string) ([]codeSymbol, error) {
|
|
if info, err := os.Stat(path); err != nil || info.Size() > codeIndexMaxFileSize {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("file too large")
|
|
}
|
|
if filepath.Ext(path) == ".go" {
|
|
return c.parseGo(path)
|
|
}
|
|
if treeSymbols, ok, err := c.parseTreeSitter(path); ok && err == nil {
|
|
textSymbols, _ := c.parseText(path)
|
|
return mergeCodeSymbols(treeSymbols, textSymbols), nil
|
|
}
|
|
return c.parseText(path)
|
|
}
|
|
|
|
func (c codeIndex) parseGo(path string) ([]codeSymbol, error) {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []codeSymbol
|
|
add := func(name, kind string, pos token.Pos, parent, sig string) {
|
|
if name == "" {
|
|
return
|
|
}
|
|
out = append(out, codeSymbol{
|
|
Name: name,
|
|
Kind: kind,
|
|
File: c.displayPath(path),
|
|
Line: fset.Position(pos).Line,
|
|
Parent: parent,
|
|
Signature: sig,
|
|
})
|
|
}
|
|
for _, decl := range f.Decls {
|
|
switch d := decl.(type) {
|
|
case *ast.FuncDecl:
|
|
parent := ""
|
|
kind := "func"
|
|
if d.Recv != nil && len(d.Recv.List) > 0 {
|
|
parent = exprName(d.Recv.List[0].Type)
|
|
kind = "method"
|
|
}
|
|
add(d.Name.Name, kind, d.Name.Pos(), parent, goFuncSignature(d, parent))
|
|
case *ast.GenDecl:
|
|
for _, spec := range d.Specs {
|
|
switch s := spec.(type) {
|
|
case *ast.TypeSpec:
|
|
kind := "type"
|
|
switch s.Type.(type) {
|
|
case *ast.StructType:
|
|
kind = "struct"
|
|
case *ast.InterfaceType:
|
|
kind = "interface"
|
|
}
|
|
add(s.Name.Name, kind, s.Name.Pos(), "", kind+" "+s.Name.Name)
|
|
case *ast.ValueSpec:
|
|
kind := strings.ToLower(d.Tok.String())
|
|
for _, name := range s.Names {
|
|
add(name.Name, kind, name.Pos(), "", kind+" "+name.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c codeIndex) parseText(path string) ([]codeSymbol, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var out []codeSymbol
|
|
sc := bufio.NewScanner(f)
|
|
line := 0
|
|
for sc.Scan() {
|
|
line++
|
|
text := sc.Text()
|
|
for _, m := range codeIndexMatchers(filepath.Ext(path)) {
|
|
if sym, ok := m.match(text); ok {
|
|
sym.File = c.displayPath(path)
|
|
sym.Line = line
|
|
out = append(out, sym)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c codeIndex) displayPath(path string) string {
|
|
if c.workDir != "" {
|
|
if rel, err := filepath.Rel(c.workDir, path); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
}
|
|
return filepath.ToSlash(path)
|
|
}
|
|
|
|
func hasCodeSymbolFilter(p codeIndexArgs) bool {
|
|
return strings.TrimSpace(p.Kind) != "" || strings.TrimSpace(p.Query) != ""
|
|
}
|
|
|
|
func filterCodeSymbols(in []codeSymbol, p codeIndexArgs) []codeSymbol {
|
|
query := strings.ToLower(strings.TrimSpace(p.Query))
|
|
kind := strings.ToLower(strings.TrimSpace(p.Kind))
|
|
out := make([]codeSymbol, 0, len(in))
|
|
for _, s := range in {
|
|
if kind != "" && strings.ToLower(s.Kind) != kind {
|
|
continue
|
|
}
|
|
if query != "" {
|
|
haystack := strings.ToLower(s.Name + " " + s.Parent + " " + s.Signature)
|
|
if !strings.Contains(haystack, query) {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func formatCodeSymbols(symbols []codeSymbol, truncated bool) string {
|
|
if len(symbols) == 0 {
|
|
return "(no symbols)"
|
|
}
|
|
var b strings.Builder
|
|
for _, s := range symbols {
|
|
name := s.Name
|
|
if s.Parent != "" {
|
|
name = s.Parent + "." + name
|
|
}
|
|
if s.Signature != "" {
|
|
fmt.Fprintf(&b, "%s:%d: %s %s — %s\n", s.File, s.Line, s.Kind, name, s.Signature)
|
|
} else {
|
|
fmt.Fprintf(&b, "%s:%d: %s %s\n", s.File, s.Line, s.Kind, name)
|
|
}
|
|
}
|
|
if truncated {
|
|
b.WriteString("... (truncated; narrow path/query/kind or raise limit)\n")
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
func mergeCodeSymbols(primary, fallback []codeSymbol) []codeSymbol {
|
|
if len(primary) == 0 {
|
|
return fallback
|
|
}
|
|
if len(fallback) == 0 {
|
|
return primary
|
|
}
|
|
out := append([]codeSymbol(nil), primary...)
|
|
seen := make(map[string]struct{}, len(primary)+len(fallback))
|
|
for _, s := range primary {
|
|
seen[codeSymbolIdentity(s)] = struct{}{}
|
|
}
|
|
for _, s := range fallback {
|
|
key := codeSymbolIdentity(s)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func codeSymbolIdentity(s codeSymbol) string {
|
|
return fmt.Sprintf("%s:%d:%s:%s:%s", s.File, s.Line, s.Kind, s.Parent, s.Name)
|
|
}
|
|
|
|
func supportedCodeIndexFile(path string) bool {
|
|
switch filepath.Ext(path) {
|
|
case ".go", ".js", ".jsx", ".ts", ".tsx", ".py", ".java", ".kt", ".kts", ".cs", ".rs", ".c", ".cc", ".cpp", ".h", ".hpp":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func skipCodeIndexDir(name string) bool {
|
|
switch name {
|
|
case ".git", "node_modules", "vendor", "__pycache__", ".idea", ".vscode", ".next", "dist", "build", "target", "coverage":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func exprName(expr ast.Expr) string {
|
|
switch t := expr.(type) {
|
|
case *ast.Ident:
|
|
return t.Name
|
|
case *ast.StarExpr:
|
|
return exprName(t.X)
|
|
case *ast.SelectorExpr:
|
|
return exprName(t.X) + "." + t.Sel.Name
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func goFuncSignature(fn *ast.FuncDecl, parent string) string {
|
|
if parent == "" {
|
|
return "func " + fn.Name.Name
|
|
}
|
|
return "func (" + parent + ") " + fn.Name.Name
|
|
}
|
|
|
|
type codeIndexMatcher struct {
|
|
kind string
|
|
re *regexp.Regexp
|
|
nameGroup int
|
|
kindGroup int
|
|
}
|
|
|
|
func (m codeIndexMatcher) match(line string) (codeSymbol, bool) {
|
|
match := m.re.FindStringSubmatch(line)
|
|
if match == nil || m.nameGroup >= len(match) {
|
|
return codeSymbol{}, false
|
|
}
|
|
name := strings.TrimSpace(match[m.nameGroup])
|
|
if name == "" {
|
|
return codeSymbol{}, false
|
|
}
|
|
kind := m.kind
|
|
if m.kindGroup > 0 && m.kindGroup < len(match) && strings.TrimSpace(match[m.kindGroup]) != "" {
|
|
kind = normalizeCodeIndexKind(match[m.kindGroup])
|
|
}
|
|
return codeSymbol{Name: name, Kind: kind, Signature: strings.TrimSpace(line)}, true
|
|
}
|
|
|
|
func normalizeCodeIndexKind(kind string) string {
|
|
kind = strings.ToLower(strings.TrimSpace(kind))
|
|
switch kind {
|
|
case "function":
|
|
return "func"
|
|
default:
|
|
return kind
|
|
}
|
|
}
|
|
|
|
var (
|
|
rePyClass = regexp.MustCompile(`^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
|
|
rePyFunc = regexp.MustCompile(`^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
|
|
reJSClass = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
|
|
reJSFunc = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(`)
|
|
reJSInterface = regexp.MustCompile(`^\s*(?:export\s+)?interface\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
|
|
reJSType = regexp.MustCompile(`^\s*(?:export\s+)?type\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
|
|
reJSEnum = regexp.MustCompile(`^\s*(?:export\s+)?enum\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`)
|
|
reJSArrow = regexp.MustCompile(`^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>`)
|
|
reJavaType = regexp.MustCompile(`^\s*(?:public|protected|private|abstract|final|static|sealed|data|\s)*\s*(class|interface|enum|record|object)\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
|
|
reJavaMeth = regexp.MustCompile(`^\s*(?:public|protected|private|static|final|abstract|synchronized|native|\s)+[\w<>\[\], ?]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*(?:\{|$)`)
|
|
reRustItem = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(fn|struct|enum|trait)\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
|
|
reCFunc = regexp.MustCompile(`^\s*(?:[\w:*&<>\[\],]+\s+)+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*(?:\{|$)`)
|
|
)
|
|
|
|
func codeIndexMatchers(ext string) []codeIndexMatcher {
|
|
switch ext {
|
|
case ".py":
|
|
return []codeIndexMatcher{{kind: "class", re: rePyClass, nameGroup: 1}, {kind: "func", re: rePyFunc, nameGroup: 1}}
|
|
case ".js", ".jsx", ".ts", ".tsx":
|
|
return []codeIndexMatcher{
|
|
{kind: "class", re: reJSClass, nameGroup: 1},
|
|
{kind: "func", re: reJSFunc, nameGroup: 1},
|
|
{kind: "interface", re: reJSInterface, nameGroup: 1},
|
|
{kind: "type", re: reJSType, nameGroup: 1},
|
|
{kind: "enum", re: reJSEnum, nameGroup: 1},
|
|
{kind: "func", re: reJSArrow, nameGroup: 1},
|
|
}
|
|
case ".java", ".kt", ".kts", ".cs":
|
|
return []codeIndexMatcher{{re: reJavaType, nameGroup: 2, kindGroup: 1}, {kind: "method", re: reJavaMeth, nameGroup: 1}}
|
|
case ".rs":
|
|
return []codeIndexMatcher{{re: reRustItem, nameGroup: 2, kindGroup: 1}}
|
|
case ".c", ".cc", ".cpp", ".h", ".hpp":
|
|
return []codeIndexMatcher{{kind: "func", re: reCFunc, nameGroup: 1}}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|