Files
doks/internal/search/text_search.go
2026-01-22 23:03:49 -06:00

169 lines
3.6 KiB
Go

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