1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
|
package picker
import (
"bufio"
"os"
"os/exec"
"strconv"
"strings"
)
// SearchHit is one content match: a file, the 1-based line number, and the
// matching line's text (TASK-013).
type SearchHit struct {
Path string
Line int
Text string
}
// maxHits bounds a content search so a common word can't flood the list.
const maxHits = 200
// contentSearch finds query as a case-insensitive substring across the markdown
// files under root, returning file + line hits. It shells out to ripgrep when
// available and falls back to an in-process walk otherwise (TASK-013).
func contentSearch(root, query string) []SearchHit {
query = strings.TrimSpace(query)
if query == "" {
return nil
}
if hits, ok := ripgrepSearch(root, query); ok {
return hits
}
return goWalkSearch(root, query)
}
// ripgrepSearch runs rg over the markdown files under root; ok is false when rg
// is missing or errored, so the caller can fall back to the in-process walk. A
// no-match exit (code 1) is a clean empty result, not a fallback trigger.
func ripgrepSearch(root, query string) (hits []SearchHit, ok bool) {
rg, err := exec.LookPath("rg")
if err != nil {
return nil, false
}
cmd := exec.Command(rg,
"--line-number", "--no-heading", "--color=never",
"--fixed-strings", "--ignore-case",
"-g", "*.md",
"--", query, root)
out, err := cmd.Output()
if err != nil {
if ee, isExit := err.(*exec.ExitError); isExit && ee.ExitCode() == 1 {
return nil, true // no matches
}
return nil, false
}
return parseRgOutput(string(out)), true
}
// parseRgOutput turns ripgrep's "path:line:text" lines into hits, skipping
// malformed lines and capping the count at maxHits.
func parseRgOutput(out string) []SearchHit {
var hits []SearchHit
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() && len(hits) < maxHits {
if h, ok := parseHitLine(sc.Text()); ok {
hits = append(hits, h)
}
}
return hits
}
// parseHitLine splits one "path:line:text" record. A line whose second field is
// not a number (e.g. a path containing a colon, or rg banner noise) is rejected.
func parseHitLine(line string) (SearchHit, bool) {
parts := strings.SplitN(line, ":", 3)
if len(parts) < 3 {
return SearchHit{}, false
}
n, err := strconv.Atoi(parts[1])
if err != nil {
return SearchHit{}, false
}
return SearchHit{Path: parts[0], Line: n, Text: strings.TrimSpace(parts[2])}, true
}
// goWalkSearch is the ripgrep-free fallback: walk the markdown files under root
// and scan each line for a case-insensitive substring match (TASK-013).
func goWalkSearch(root, query string) []SearchHit {
files, err := walkMarkdown(root)
if err != nil {
return nil
}
needle := strings.ToLower(query)
var hits []SearchHit
for _, f := range files {
if len(hits) >= maxHits {
break
}
data, err := os.ReadFile(f.path)
if err != nil {
continue
}
sc := bufio.NewScanner(strings.NewReader(string(data)))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
ln := 0
for sc.Scan() {
ln++
text := sc.Text()
if strings.Contains(strings.ToLower(text), needle) {
hits = append(hits, SearchHit{Path: f.path, Line: ln, Text: strings.TrimSpace(text)})
if len(hits) >= maxHits {
break
}
}
}
}
return hits
}
|