169 lines
3.6 KiB
Go
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 ""
|
|
}
|