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 } }