| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "go/ast" |
| 9 | "go/parser" |
| 10 | "go/token" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "regexp" |
| 14 | "sort" |
| 15 | "strings" |
| 16 | |
| 17 | "reasonix/internal/tool" |
| 18 | ) |
| 19 | |
| 20 | func init() { tool.RegisterBuiltin(codeIndex{}) } |
| 21 | |
| 22 | type codeIndex struct { |
| 23 | workDir string |
| 24 | forbidRoots []string |
| 25 | } |
| 26 | |
| 27 | func (codeIndex) Name() string { return "code_index" } |
| 28 | |
| 29 | func (codeIndex) Description() string { |
| 30 | 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." |
| 31 | } |
| 32 | |
| 33 | func (codeIndex) Schema() json.RawMessage { |
| 34 | return json.RawMessage(`{ |
| 35 | "type":"object", |
| 36 | "properties":{ |
| 37 | "action":{"type":"string","enum":["outline","search"],"description":"outline lists symbols under path; search finds symbol definition candidates by name."}, |
| 38 | "path":{"type":"string","description":"File or directory path to inspect (default \".\")."}, |
| 39 | "query":{"type":"string","description":"Symbol name or substring for action=search."}, |
| 40 | "kind":{"type":"string","description":"Optional symbol kind filter, such as func, method, class, type, interface, const, var, struct, enum, trait."}, |
| 41 | "limit":{"type":"integer","description":"Maximum symbols to return (default 100, max 200).","minimum":1} |
| 42 | }, |
| 43 | "required":["action"] |
| 44 | }`) |
| 45 | } |
| 46 | |
| 47 | func (codeIndex) ReadOnly() bool { return true } |
| 48 | |
| 49 | const ( |
| 50 | codeIndexDefaultLimit = 100 |
| 51 | codeIndexMaxLimit = 200 |
| 52 | codeIndexMaxFileSize = 1 << 20 |
| 53 | codeIndexMaxFiles = 2000 |
| 54 | ) |
| 55 | |
| 56 | type codeIndexArgs struct { |
| 57 | Action string `json:"action"` |
| 58 | Path string `json:"path"` |
| 59 | Query string `json:"query"` |
| 60 | Kind string `json:"kind"` |
| 61 | Limit int `json:"limit"` |
| 62 | } |
| 63 | |
| 64 | type codeSymbol struct { |
| 65 | Name string |
| 66 | Kind string |
| 67 | File string |
| 68 | Line int |
| 69 | Parent string |
| 70 | Signature string |
| 71 | } |
| 72 | |
| 73 | func (c codeIndex) Execute(ctx context.Context, args json.RawMessage) (string, error) { |
| 74 | p := codeIndexArgs{Path: ".", Limit: codeIndexDefaultLimit} |
| 75 | if err := json.Unmarshal(args, &p); err != nil { |
| 76 | return "", fmt.Errorf("invalid args: %w", err) |
| 77 | } |
| 78 | p.Action = strings.ToLower(strings.TrimSpace(p.Action)) |
| 79 | if p.Action != "outline" && p.Action != "search" { |
| 80 | return "", fmt.Errorf("action must be outline or search") |
| 81 | } |
| 82 | if p.Path == "" { |
| 83 | p.Path = "." |
| 84 | } |
| 85 | if p.Limit <= 0 { |
| 86 | p.Limit = codeIndexDefaultLimit |
| 87 | } |
| 88 | if p.Limit > codeIndexMaxLimit { |
| 89 | p.Limit = codeIndexMaxLimit |
| 90 | } |
| 91 | if p.Action == "search" && strings.TrimSpace(p.Query) == "" { |
| 92 | return "", fmt.Errorf("query is required for action=search") |
| 93 | } |
| 94 | |
| 95 | root := resolveIn(c.workDir, p.Path) |
| 96 | collectionLimit := p.Limit |
| 97 | if p.Action == "outline" && hasCodeSymbolFilter(p) { |
| 98 | collectionLimit = 0 |
| 99 | } |
| 100 | symbols, truncated, err := c.collect(ctx, root, collectionLimit, p.Action == "outline") |
| 101 | if err != nil { |
| 102 | return "", err |
| 103 | } |
| 104 | symbols = filterCodeSymbols(symbols, p) |
| 105 | if len(symbols) > p.Limit { |
| 106 | symbols = symbols[:p.Limit] |
| 107 | truncated = true |
| 108 | } |
| 109 | return formatCodeSymbols(symbols, truncated), nil |
| 110 | } |
| 111 | |
| 112 | func (c codeIndex) collect(ctx context.Context, root string, limit int, outline bool) ([]codeSymbol, bool, error) { |
| 113 | if confineRead(c.forbidRoots, root) { |
| 114 | return nil, false, nil |
| 115 | } |
| 116 | info, err := os.Stat(root) |
| 117 | if err != nil { |
| 118 | return nil, false, fmt.Errorf("code_index %s: %w", root, err) |
| 119 | } |
| 120 | var files []string |
| 121 | if !info.IsDir() { |
| 122 | if supportedCodeIndexFile(root) { |
| 123 | files = append(files, root) |
| 124 | } |
| 125 | } else { |
| 126 | err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { |
| 127 | if ctx.Err() != nil { |
| 128 | return ctx.Err() |
| 129 | } |
| 130 | if walkErr != nil { |
| 131 | return nil |
| 132 | } |
| 133 | if d.IsDir() { |
| 134 | if skipForbidDir(path, c.forbidRoots) { |
| 135 | return filepath.SkipDir |
| 136 | } |
| 137 | if path != root && skipCodeIndexDir(d.Name()) { |
| 138 | return filepath.SkipDir |
| 139 | } |
| 140 | return nil |
| 141 | } |
| 142 | if supportedCodeIndexFile(path) && !confineRead(c.forbidRoots, path) { |
| 143 | files = append(files, path) |
| 144 | if len(files) >= codeIndexMaxFiles { |
| 145 | return filepath.SkipAll |
| 146 | } |
| 147 | } |
| 148 | return nil |
| 149 | }) |
| 150 | if err != nil { |
| 151 | return nil, false, fmt.Errorf("code_index walk %s: %w", root, err) |
| 152 | } |
| 153 | } |
| 154 | sort.Strings(files) |
| 155 | |
| 156 | var symbols []codeSymbol |
| 157 | truncated := len(files) >= codeIndexMaxFiles |
| 158 | for _, file := range files { |
| 159 | if ctx.Err() != nil { |
| 160 | return nil, truncated, ctx.Err() |
| 161 | } |
| 162 | found, err := c.parseFile(file) |
| 163 | if err != nil { |
| 164 | continue |
| 165 | } |
| 166 | symbols = append(symbols, found...) |
| 167 | if outline && limit > 0 && len(symbols) >= limit { |
| 168 | truncated = true |
| 169 | break |
| 170 | } |
| 171 | } |
| 172 | sort.Slice(symbols, func(i, j int) bool { |
| 173 | if symbols[i].File != symbols[j].File { |
| 174 | return symbols[i].File < symbols[j].File |
| 175 | } |
| 176 | if symbols[i].Line != symbols[j].Line { |
| 177 | return symbols[i].Line < symbols[j].Line |
| 178 | } |
| 179 | return symbols[i].Name < symbols[j].Name |
| 180 | }) |
| 181 | return symbols, truncated, nil |
| 182 | } |
| 183 | |
| 184 | func (c codeIndex) parseFile(path string) ([]codeSymbol, error) { |
| 185 | if info, err := os.Stat(path); err != nil || info.Size() > codeIndexMaxFileSize { |
| 186 | if err != nil { |
| 187 | return nil, err |
| 188 | } |
| 189 | return nil, fmt.Errorf("file too large") |
| 190 | } |
| 191 | if filepath.Ext(path) == ".go" { |
| 192 | return c.parseGo(path) |
| 193 | } |
| 194 | if treeSymbols, ok, err := c.parseTreeSitter(path); ok && err == nil { |
| 195 | textSymbols, _ := c.parseText(path) |
| 196 | return mergeCodeSymbols(treeSymbols, textSymbols), nil |
| 197 | } |
| 198 | return c.parseText(path) |
| 199 | } |
| 200 | |
| 201 | func (c codeIndex) parseGo(path string) ([]codeSymbol, error) { |
| 202 | fset := token.NewFileSet() |
| 203 | f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) |
| 204 | if err != nil { |
| 205 | return nil, err |
| 206 | } |
| 207 | var out []codeSymbol |
| 208 | add := func(name, kind string, pos token.Pos, parent, sig string) { |
| 209 | if name == "" { |
| 210 | return |
| 211 | } |
| 212 | out = append(out, codeSymbol{ |
| 213 | Name: name, |
| 214 | Kind: kind, |
| 215 | File: c.displayPath(path), |
| 216 | Line: fset.Position(pos).Line, |
| 217 | Parent: parent, |
| 218 | Signature: sig, |
| 219 | }) |
| 220 | } |
| 221 | for _, decl := range f.Decls { |
| 222 | switch d := decl.(type) { |
| 223 | case *ast.FuncDecl: |
| 224 | parent := "" |
| 225 | kind := "func" |
| 226 | if d.Recv != nil && len(d.Recv.List) > 0 { |
| 227 | parent = exprName(d.Recv.List[0].Type) |
| 228 | kind = "method" |
| 229 | } |
| 230 | add(d.Name.Name, kind, d.Name.Pos(), parent, goFuncSignature(d, parent)) |
| 231 | case *ast.GenDecl: |
| 232 | for _, spec := range d.Specs { |
| 233 | switch s := spec.(type) { |
| 234 | case *ast.TypeSpec: |
| 235 | kind := "type" |
| 236 | switch s.Type.(type) { |
| 237 | case *ast.StructType: |
| 238 | kind = "struct" |
| 239 | case *ast.InterfaceType: |
| 240 | kind = "interface" |
| 241 | } |
| 242 | add(s.Name.Name, kind, s.Name.Pos(), "", kind+" "+s.Name.Name) |
| 243 | case *ast.ValueSpec: |
| 244 | kind := strings.ToLower(d.Tok.String()) |
| 245 | for _, name := range s.Names { |
| 246 | add(name.Name, kind, name.Pos(), "", kind+" "+name.Name) |
| 247 | } |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | } |
| 252 | return out, nil |
| 253 | } |
| 254 | |
| 255 | func (c codeIndex) parseText(path string) ([]codeSymbol, error) { |
| 256 | f, err := os.Open(path) |
| 257 | if err != nil { |
| 258 | return nil, err |
| 259 | } |
| 260 | defer f.Close() |
| 261 | |
| 262 | var out []codeSymbol |
| 263 | sc := bufio.NewScanner(f) |
| 264 | line := 0 |
| 265 | for sc.Scan() { |
| 266 | line++ |
| 267 | text := sc.Text() |
| 268 | for _, m := range codeIndexMatchers(filepath.Ext(path)) { |
| 269 | if sym, ok := m.match(text); ok { |
| 270 | sym.File = c.displayPath(path) |
| 271 | sym.Line = line |
| 272 | out = append(out, sym) |
| 273 | break |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | if err := sc.Err(); err != nil { |
| 278 | return nil, err |
| 279 | } |
| 280 | return out, nil |
| 281 | } |
| 282 | |
| 283 | func (c codeIndex) displayPath(path string) string { |
| 284 | if c.workDir != "" { |
| 285 | if rel, err := filepath.Rel(c.workDir, path); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { |
| 286 | return filepath.ToSlash(rel) |
| 287 | } |
| 288 | } |
| 289 | return filepath.ToSlash(path) |
| 290 | } |
| 291 | |
| 292 | func hasCodeSymbolFilter(p codeIndexArgs) bool { |
| 293 | return strings.TrimSpace(p.Kind) != "" || strings.TrimSpace(p.Query) != "" |
| 294 | } |
| 295 | |
| 296 | func filterCodeSymbols(in []codeSymbol, p codeIndexArgs) []codeSymbol { |
| 297 | query := strings.ToLower(strings.TrimSpace(p.Query)) |
| 298 | kind := strings.ToLower(strings.TrimSpace(p.Kind)) |
| 299 | out := make([]codeSymbol, 0, len(in)) |
| 300 | for _, s := range in { |
| 301 | if kind != "" && strings.ToLower(s.Kind) != kind { |
| 302 | continue |
| 303 | } |
| 304 | if query != "" { |
| 305 | haystack := strings.ToLower(s.Name + " " + s.Parent + " " + s.Signature) |
| 306 | if !strings.Contains(haystack, query) { |
| 307 | continue |
| 308 | } |
| 309 | } |
| 310 | out = append(out, s) |
| 311 | } |
| 312 | return out |
| 313 | } |
| 314 | |
| 315 | func formatCodeSymbols(symbols []codeSymbol, truncated bool) string { |
| 316 | if len(symbols) == 0 { |
| 317 | return "(no symbols)" |
| 318 | } |
| 319 | var b strings.Builder |
| 320 | for _, s := range symbols { |
| 321 | name := s.Name |
| 322 | if s.Parent != "" { |
| 323 | name = s.Parent + "." + name |
| 324 | } |
| 325 | if s.Signature != "" { |
| 326 | fmt.Fprintf(&b, "%s:%d: %s %s — %s\n", s.File, s.Line, s.Kind, name, s.Signature) |
| 327 | } else { |
| 328 | fmt.Fprintf(&b, "%s:%d: %s %s\n", s.File, s.Line, s.Kind, name) |
| 329 | } |
| 330 | } |
| 331 | if truncated { |
| 332 | b.WriteString("... (truncated; narrow path/query/kind or raise limit)\n") |
| 333 | } |
| 334 | return strings.TrimRight(b.String(), "\n") |
| 335 | } |
| 336 | |
| 337 | func mergeCodeSymbols(primary, fallback []codeSymbol) []codeSymbol { |
| 338 | if len(primary) == 0 { |
| 339 | return fallback |
| 340 | } |
| 341 | if len(fallback) == 0 { |
| 342 | return primary |
| 343 | } |
| 344 | out := append([]codeSymbol(nil), primary...) |
| 345 | seen := make(map[string]struct{}, len(primary)+len(fallback)) |
| 346 | for _, s := range primary { |
| 347 | seen[codeSymbolIdentity(s)] = struct{}{} |
| 348 | } |
| 349 | for _, s := range fallback { |
| 350 | key := codeSymbolIdentity(s) |
| 351 | if _, ok := seen[key]; ok { |
| 352 | continue |
| 353 | } |
| 354 | seen[key] = struct{}{} |
| 355 | out = append(out, s) |
| 356 | } |
| 357 | return out |
| 358 | } |
| 359 | |
| 360 | func codeSymbolIdentity(s codeSymbol) string { |
| 361 | return fmt.Sprintf("%s:%d:%s:%s:%s", s.File, s.Line, s.Kind, s.Parent, s.Name) |
| 362 | } |
| 363 | |
| 364 | func supportedCodeIndexFile(path string) bool { |
| 365 | switch filepath.Ext(path) { |
| 366 | case ".go", ".js", ".jsx", ".ts", ".tsx", ".py", ".java", ".kt", ".kts", ".cs", ".rs", ".c", ".cc", ".cpp", ".h", ".hpp": |
| 367 | return true |
| 368 | default: |
| 369 | return false |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | func skipCodeIndexDir(name string) bool { |
| 374 | switch name { |
| 375 | case ".git", "node_modules", "vendor", "__pycache__", ".idea", ".vscode", ".next", "dist", "build", "target", "coverage": |
| 376 | return true |
| 377 | default: |
| 378 | return false |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | func exprName(expr ast.Expr) string { |
| 383 | switch t := expr.(type) { |
| 384 | case *ast.Ident: |
| 385 | return t.Name |
| 386 | case *ast.StarExpr: |
| 387 | return exprName(t.X) |
| 388 | case *ast.SelectorExpr: |
| 389 | return exprName(t.X) + "." + t.Sel.Name |
| 390 | default: |
| 391 | return "" |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | func goFuncSignature(fn *ast.FuncDecl, parent string) string { |
| 396 | if parent == "" { |
| 397 | return "func " + fn.Name.Name |
| 398 | } |
| 399 | return "func (" + parent + ") " + fn.Name.Name |
| 400 | } |
| 401 | |
| 402 | type codeIndexMatcher struct { |
| 403 | kind string |
| 404 | re *regexp.Regexp |
| 405 | nameGroup int |
| 406 | kindGroup int |
| 407 | } |
| 408 | |
| 409 | func (m codeIndexMatcher) match(line string) (codeSymbol, bool) { |
| 410 | match := m.re.FindStringSubmatch(line) |
| 411 | if match == nil || m.nameGroup >= len(match) { |
| 412 | return codeSymbol{}, false |
| 413 | } |
| 414 | name := strings.TrimSpace(match[m.nameGroup]) |
| 415 | if name == "" { |
| 416 | return codeSymbol{}, false |
| 417 | } |
| 418 | kind := m.kind |
| 419 | if m.kindGroup > 0 && m.kindGroup < len(match) && strings.TrimSpace(match[m.kindGroup]) != "" { |
| 420 | kind = normalizeCodeIndexKind(match[m.kindGroup]) |
| 421 | } |
| 422 | return codeSymbol{Name: name, Kind: kind, Signature: strings.TrimSpace(line)}, true |
| 423 | } |
| 424 | |
| 425 | func normalizeCodeIndexKind(kind string) string { |
| 426 | kind = strings.ToLower(strings.TrimSpace(kind)) |
| 427 | switch kind { |
| 428 | case "function": |
| 429 | return "func" |
| 430 | default: |
| 431 | return kind |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | var ( |
| 436 | rePyClass = regexp.MustCompile(`^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\b`) |
| 437 | rePyFunc = regexp.MustCompile(`^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`) |
| 438 | reJSClass = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`) |
| 439 | reJSFunc = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(`) |
| 440 | reJSInterface = regexp.MustCompile(`^\s*(?:export\s+)?interface\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`) |
| 441 | reJSType = regexp.MustCompile(`^\s*(?:export\s+)?type\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`) |
| 442 | reJSEnum = regexp.MustCompile(`^\s*(?:export\s+)?enum\s+([A-Za-z_$][A-Za-z0-9_$]*)\b`) |
| 443 | 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*=>`) |
| 444 | 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`) |
| 445 | reJavaMeth = regexp.MustCompile(`^\s*(?:public|protected|private|static|final|abstract|synchronized|native|\s)+[\w<>\[\], ?]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*(?:\{|$)`) |
| 446 | reRustItem = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(fn|struct|enum|trait)\s+([A-Za-z_][A-Za-z0-9_]*)\b`) |
| 447 | reCFunc = regexp.MustCompile(`^\s*(?:[\w:*&<>\[\],]+\s+)+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*(?:\{|$)`) |
| 448 | ) |
| 449 | |
| 450 | func codeIndexMatchers(ext string) []codeIndexMatcher { |
| 451 | switch ext { |
| 452 | case ".py": |
| 453 | return []codeIndexMatcher{{kind: "class", re: rePyClass, nameGroup: 1}, {kind: "func", re: rePyFunc, nameGroup: 1}} |
| 454 | case ".js", ".jsx", ".ts", ".tsx": |
| 455 | return []codeIndexMatcher{ |
| 456 | {kind: "class", re: reJSClass, nameGroup: 1}, |
| 457 | {kind: "func", re: reJSFunc, nameGroup: 1}, |
| 458 | {kind: "interface", re: reJSInterface, nameGroup: 1}, |
| 459 | {kind: "type", re: reJSType, nameGroup: 1}, |
| 460 | {kind: "enum", re: reJSEnum, nameGroup: 1}, |
| 461 | {kind: "func", re: reJSArrow, nameGroup: 1}, |
| 462 | } |
| 463 | case ".java", ".kt", ".kts", ".cs": |
| 464 | return []codeIndexMatcher{{re: reJavaType, nameGroup: 2, kindGroup: 1}, {kind: "method", re: reJavaMeth, nameGroup: 1}} |
| 465 | case ".rs": |
| 466 | return []codeIndexMatcher{{re: reRustItem, nameGroup: 2, kindGroup: 1}} |
| 467 | case ".c", ".cc", ".cpp", ".h", ".hpp": |
| 468 | return []codeIndexMatcher{{kind: "func", re: reCFunc, nameGroup: 1}} |
| 469 | default: |
| 470 | return nil |
| 471 | } |
| 472 | } |
| 473 |