This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-core/pkg/tools/file/grep.go

207 lines
6.2 KiB
Go

package file
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"ollie/pkg/tools"
)
var ToolGrep = tools.ToolInfo{
Name: "file_grep",
Description: `Search file contents using ripgrep.
Usage:
- Primary content search tool (use instead of grep in execute_code)
- Respects .gitignore and .ignore files by default
- Default output: file paths only (files_with_matches)
- Use output_mode: 'content' for matching lines with context
- Use output_mode: 'count' for match counts only
Syntax:
- Uses ripgrep regex syntax (not GNU grep)
- Escape literal braces: \{ and \}
- multiline: true for patterns spanning lines
- Case insensitive: -i=true
Examples:
- Find TODOs: pattern='TODO', output_mode='content'
- Search specific files: glob='*.go', pattern='func main'
- With context: pattern='error', context=3, output_mode='content'`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["pattern"],
"properties": {
"pattern": {"type": "string", "description": "Regex pattern to search for (ripgrep syntax)"},
"path": {"type": "string", "description": "File or directory to search in. Defaults to cwd."},
"output_mode": {"type": "string", "description": "Default: 'files_with_matches'. 'content' shows matching lines, 'count' shows match counts.", "enum": ["content", "files_with_matches", "count"]},
"glob": {"type": "string", "description": "Glob filter, e.g. '*.js', '*.{ts,tsx}'"},
"type": {"type": "string", "description": "File type filter, e.g. 'js', 'py', 'rust'. More efficient than glob for standard types."},
"-i": {"type": "boolean", "description": "Case insensitive search"},
"-n": {"type": "boolean", "description": "Show line numbers (default true). Requires output_mode: 'content'."},
"-A": {"type": "number", "description": "Lines after match"},
"-B": {"type": "number", "description": "Lines before match"},
"-C": {"type": "number", "description": "Lines before and after match (short flag alias)"},
"context": {"type": "number", "description": "Lines before and after match"},
"multiline": {"type": "boolean", "description": "Enable multiline mode where . matches newlines. Default: false."},
"head_limit": {"type": "number", "description": "Limit output to first N lines/entries (like | head -N). Default: 0 (unlimited)."},
"offset": {"type": "number", "description": "Skip first N entries before applying head_limit (like | tail -n +N | head -N). Default: 0."}
}
}`),
}
func (s *Server) dispatchGrep(ctx context.Context, raw json.RawMessage) (string, error) {
var a struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
OutputMode string `json:"output_mode"`
Glob string `json:"glob"`
Type string `json:"type"`
IgnoreCase bool `json:"-i"`
LineNumber *bool `json:"-n"`
After *int `json:"-A"`
Before *int `json:"-B"`
ContextC *int `json:"-C"`
Context *int `json:"context"`
Multiline bool `json:"multiline"`
HeadLimit *int `json:"head_limit"`
Offset *int `json:"offset"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", fmt.Errorf("bad args: %w", err)
}
if strings.TrimSpace(a.Pattern) == "" {
return "", fmt.Errorf("pattern is required")
}
if s.rgPath == "" {
return "", fmt.Errorf("ripgrep (rg) not found in PATH")
}
outputMode := a.OutputMode
if outputMode == "" {
outputMode = "files_with_matches"
}
searchPath := a.Path
if searchPath == "" {
searchPath = s.projectDir
}
searchPath = filepath.Clean(searchPath)
if err := s.checkAccess(searchPath, false); err != nil {
return errText("%v", err), nil
}
if err := ensureSearchPath(searchPath); err != nil {
return "", err
}
cmdArgs := []string{"--no-config", "--color=never"}
switch outputMode {
case "files_with_matches":
cmdArgs = append(cmdArgs, "-l")
case "count":
cmdArgs = append(cmdArgs, "--count")
case "content":
showLineNums := true
if a.LineNumber != nil {
showLineNums = *a.LineNumber
}
if showLineNums {
cmdArgs = append(cmdArgs, "-n")
}
}
if a.IgnoreCase {
cmdArgs = append(cmdArgs, "-i")
}
if a.Multiline {
cmdArgs = append(cmdArgs, "-U", "--multiline-dotall")
}
if a.Glob != "" {
cmdArgs = append(cmdArgs, "-g", a.Glob)
}
if a.Type != "" {
cmdArgs = append(cmdArgs, "-t", a.Type)
}
ctx_ := a.Context
if ctx_ == nil {
ctx_ = a.ContextC
}
if ctx_ != nil {
cmdArgs = append(cmdArgs, "-C", fmt.Sprintf("%d", *ctx_))
}
if a.Before != nil {
cmdArgs = append(cmdArgs, "-B", fmt.Sprintf("%d", *a.Before))
}
if a.After != nil {
cmdArgs = append(cmdArgs, "-A", fmt.Sprintf("%d", *a.After))
}
cmdArgs = append(cmdArgs, "-e", a.Pattern, searchPath)
cmd := exec.CommandContext(ctx, s.rgPath, cmdArgs...)
cmd.Dir = s.projectDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return "", nil // no matches
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("%s", msg)
}
lines := splitLines(stdout.String())
lines = paginate(lines, intOrZero(a.Offset), intOrZero(a.HeadLimit))
if outputMode == "content" {
lines = formatContentLines(lines)
}
return strings.Join(lines, "\n"), nil
}
func formatContentLines(lines []string) []string {
out := make([]string, 0, len(lines))
for _, line := range lines {
if line == "--" {
out = append(out, line)
continue
}
if p, rest, ok := strings.Cut(line, ":"); ok {
if ln, content, ok2 := strings.Cut(rest, ":"); ok2 && isDigits(ln) {
out = append(out, fmt.Sprintf("%s:%05s| %s", p, ln, content))
continue
}
}
if p, rest, ok := strings.Cut(line, "-"); ok {
if ln, content, ok2 := strings.Cut(rest, "-"); ok2 && isDigits(ln) {
out = append(out, fmt.Sprintf("%s:%05s| %s", p, ln, content))
continue
}
}
out = append(out, line)
}
return out
}
func ensureSearchPath(path string) error {
info, err := statPath(path)
if err != nil {
return fmt.Errorf("error accessing path: %w", err)
}
if !info.IsDir() && !info.Mode().IsRegular() {
return fmt.Errorf("not a searchable path: %s", path)
}
return nil
}