// Package search reads one index, filtered by read access inside the query. Chapter 17. package search import ( "context" "strings" "github.com/barerepo/server/internal/store" ) // Kind orders the set: code first, because a barerepo is usually searched for a symbol. type Kind int const ( Code Kind = iota Thread Repo ) func (k Kind) String() string { switch k { case Code: return "code" case Thread: return "thread" default: return "repo" } } // Result is one row of one set, so every row names the repository it came from. type Result struct { Kind Kind Owner string Name string Path string // code: the file. thread: the number. Line int Text string // the matching line, or the thread title Context string Href string } // Finder is the index query, an interface so the page depends on the question and not the schema. type Finder interface { FindDocs(ctx context.Context, viewer, query string, limit int) ([]store.Doc, error) } // Search asks the index once. The read filter is inside that query, never applied to the answer. func Search(ctx context.Context, db Finder, viewer, query string, limit int) []Result { query = strings.TrimSpace(query) if query == "" || limit <= 0 || db == nil { return nil } docs, err := db.FindDocs(ctx, viewer, query, limit) if err != nil { return nil } out := make([]Result, 0, len(docs)) for _, d := range docs { if len(out) >= limit { break } owner, name, ok := strings.Cut(d.Repo, "/") if !ok { continue } switch d.Kind { case store.Code: for _, m := range matches(d.Body, query, linesPerFile) { out = append(out, Result{Kind: Code, Owner: owner, Name: name, Path: d.Path, Line: m.n, Text: m.text, Href: "/" + d.Repo + "/file/" + defaultRef + "/" + d.Path}) } case store.Thread: line, _ := match(d.Body, query) out = append(out, Result{Kind: Thread, Owner: owner, Name: name, Path: d.Path, Text: d.Title, Context: line, Href: "/" + d.Repo + "/thread/" + d.Path}) default: out = append(out, Result{Kind: Repo, Owner: owner, Name: name, Text: d.Title, Context: d.Body, Href: "/" + d.Repo}) } } return out } // defaultRef is what a file link resolves through, because HEAD is the branch this index was built from. const defaultRef = "HEAD" // linesPerFile bounds one file's share of a result set, so a common word cannot fill the page. const linesPerFile = 3 // hit is one matching line and the number it sits on. type hit struct { text string n int } // matches finds where the query is, so a reader sees the occurrences and not only the first. func matches(body, query string, limit int) []hit { want := strings.ToLower(query) lines := strings.Split(body, "\n") var out []hit for i, line := range lines { if !strings.Contains(strings.ToLower(line), want) { continue } out = append(out, hit{strings.TrimRight(line, "\r"), i + 1}) if len(out) == limit { break } } return out } // match is one line, for a thread, where the excerpt is context rather than a result of its own. func match(body, query string) (string, int) { if m := matches(body, query, 1); len(m) == 1 { return m[0].text, m[0].n } return "", 0 }