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 }