返回 DeepSeek-Reasonix
codeindex.go
根目录 / internal / tool / builtin / codeindex.go
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
473 lines GO