package search import ( "bufio" "bytes" "os" "os/exec" "path/filepath" "strconv" "strings" "github.com/yeho/doks/internal/registry" ) type TextSearcher struct { filesDir string registry *registry.Registry hasRipgrep bool contextLines int } func NewTextSearcher(filesDir string, reg *registry.Registry) *TextSearcher { _, err := exec.LookPath("rg") return &TextSearcher{ filesDir: filesDir, registry: reg, hasRipgrep: err == nil, } } func (t *TextSearcher) Search(query string, opts Options) ([]Result, error) { if t.hasRipgrep { return t.searchWithRipgrep(query, opts) } return t.searchPureGo(query, opts) } func (t *TextSearcher) searchWithRipgrep(query string, opts Options) ([]Result, error) { contextArg := strconv.Itoa(opts.ContextLines) cmd := exec.Command("rg", "--line-number", "--with-filename", "--context", contextArg, "--max-count", strconv.Itoa(opts.MaxResults*2), // Get more than needed, will dedupe "-i", // Case insensitive query, t.filesDir, ) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { // Exit code 1 means no matches, which is fine if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { return []Result{}, nil } // Other errors might be okay too (e.g., some files couldn't be read) if stdout.Len() == 0 { return []Result{}, nil } } return t.parseRipgrepOutput(stdout.String(), opts) } func (t *TextSearcher) parseRipgrepOutput(output string, opts Options) ([]Result, error) { results := []Result{} seen := make(map[string]bool) // Track unique file:line combinations lines := strings.Split(output, "\n") for _, line := range lines { if line == "" || line == "--" { continue } // ripgrep output format: filename:linenum:text or filename-linenum-text (context) // We want the matching lines (with :) parts := strings.SplitN(line, ":", 3) if len(parts) < 3 { continue } filename := filepath.Base(parts[0]) lineNum, err := strconv.Atoi(parts[1]) if err != nil { continue } lineText := parts[2] // Find the key for this file key := t.findKeyByFilename(filename) if key == "" { continue } // Deduplicate dedupKey := filename + ":" + strconv.Itoa(lineNum) if seen[dedupKey] { continue } seen[dedupKey] = true results = append(results, Result{ Key: key, Filename: filename, FilePath: parts[0], LineNumber: lineNum, LineText: strings.TrimSpace(lineText), }) if len(results) >= opts.MaxResults { break } } return results, nil } func (t *TextSearcher) searchPureGo(query string, opts Options) ([]Result, error) { results := []Result{} queryLower := strings.ToLower(query) entries := t.registry.List() for _, entry := range entries { filePath := filepath.Join(t.filesDir, entry.Filename) file, err := os.Open(filePath) if err != nil { continue } scanner := bufio.NewScanner(file) lineNum := 0 for scanner.Scan() { lineNum++ line := scanner.Text() if strings.Contains(strings.ToLower(line), queryLower) { results = append(results, Result{ Key: entry.Key, Filename: entry.Filename, FilePath: filePath, LineNumber: lineNum, LineText: strings.TrimSpace(line), }) if len(results) >= opts.MaxResults { file.Close() return results, nil } } } file.Close() } return results, nil } func (t *TextSearcher) findKeyByFilename(filename string) string { entries := t.registry.List() for _, entry := range entries { if entry.Filename == filename { return entry.Key } } return "" }