▍ humdrum codex / glint v1.0.2
license AGPL-3.0
3.2 KB raw
  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
}