package codesearch import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/sourcegraph/zoekt" "github.com/sourcegraph/zoekt/query" "tangled.org/core/appview/models" "tangled.org/core/appview/pagination" ) type CodeSearch struct { Host string // zoekt-webserver host. example: https://zoekt.example.com Client *http.Client } func (s *CodeSearch) GetClient() *http.Client { if s.Client != nil { return s.Client } return http.DefaultClient } var ErrRepoOnlyQuery = errors.New("query only search using repo name. use repo-search service instead.") // jsonSearchArgs mirrors zoekt's /api/search request body. type jsonSearchArgs struct { Q string Opts *zoekt.SearchOptions } // jsonSearchReply mirrors zoekt's /api/search response body. type jsonSearchReply struct { Result *zoekt.SearchResult } // SearchResults is a single page of content-search results plus whether more // pages follow. type SearchResults struct { Results []models.Result HasMore bool } // Search queries zoekt server for FileNameMatch or ChunkMatch, returning the // page described by page. // // zoekt is top-N only (no offset): we cap MaxDocDisplayCount to one file beyond // the requested window, then slice the flattened results locally. Each file // yields at least one card, so fetching offset+limit+1 files guarantees at least // offset+limit+1 cards when more exist, which is enough to fill the window and // detect a next page. Top-N is score-ordered and stable across larger caps // (superset-prefix), so slicing is consistent between pages. // // It returns ErrRepoOnlyQuery when the query only matches repository names; such // queries should be served by the repo-search service rather than content search. func (s *CodeSearch) Search(ctx context.Context, queryStr string, page pagination.Page) (*SearchResults, error) { q, err := query.Parse(queryStr) if err != nil { return nil, fmt.Errorf("parse query: %w", err) } if isRepoOnly(q) { return nil, ErrRepoOnlyQuery } // ChunkMatches gives rune-based column ranges and multi-line support, which // is what we want for highlighting. opts := &zoekt.SearchOptions{ ChunkMatches: true, MaxWallTime: 10 * time.Second, NumContextLines: 2, } if page.Limit > 0 { // +1 file ⇒ at least +1 card, so we can detect a following page. opts.MaxDocDisplayCount = page.Offset + page.Limit + 1 } body, err := json.Marshal(jsonSearchArgs{ Q: queryStr, Opts: opts, }) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } url := strings.TrimRight(s.Host, "/") + "/api/search" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := s.GetClient().Do(req) if err != nil { return nil, fmt.Errorf("do request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return nil, fmt.Errorf("zoekt search: status %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } var reply jsonSearchReply if err := json.NewDecoder(resp.Body).Decode(&reply); err != nil { return nil, fmt.Errorf("decode response: %w", err) } if reply.Result == nil { return &SearchResults{}, nil } all := toResults(reply.Result) end := page.Offset + page.Limit if page.Limit <= 0 { // No window requested: return everything. return &SearchResults{Results: all}, nil } hasMore := len(all) > end // extra card present ⇒ more pages if page.Offset >= len(all) { return &SearchResults{HasMore: false}, nil } if end > len(all) { end = len(all) } return &SearchResults{Results: all[page.Offset:end], HasMore: hasMore}, nil } // toResults flattens zoekt FileMatches into local Results, emitting one Result // per ChunkMatch. zoekt returns either filename matches or content matches per // file, never both, so each chunk maps cleanly to File or Chunk. func toResults(sr *zoekt.SearchResult) []models.Result { var out []models.Result for _, fm := range sr.Files { // HACK: zoekt use int64 repo.ID as identifier, but we expect DID (string) as an repo identifier. // as a quick hack without patching zoekt, we extract the DID from RepoURLs repoDID := extractDID(sr.RepoURLs[fm.Repository]) for _, cm := range fm.ChunkMatches { r := models.Result{RepoDID: repoDID} if cm.FileName { r.File = &models.Result_FileMatch{ FilePath: fm.FileName, Branches: fm.Branches, Commit: fm.Version, Language: fm.Language, Ranges: cm.Ranges, } } else { r.Chunk = &models.Result_ChunkMatch{ FilePath: fm.FileName, Branches: fm.Branches, Commit: fm.Version, Language: fm.Language, Content: string(cm.Content), ContentStartLine: int(cm.ContentStart.LineNumber), Ranges: cm.Ranges, } } out = append(out, r) } } return out } // extractDID pulls the repo DID out of a zoekt FileURLTemplate of the form // "{appviewURL}/{repoDID}/blob/{commit}/{path}". func extractDID(urlTemplate string) syntax.DID { if urlTemplate == "" { return "" } u, err := url.Parse(urlTemplate) if err != nil { return "" } seg := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 2)[0] return syntax.DID(seg) } // isRepoOnly reports whether the query only matches repository names. Mirrors // zoekt's own logic in web/server.go: every atom is a *query.Repo, or the // top-level query is a type:repo wrapper. func isRepoOnly(q query.Q) bool { if t, ok := q.(*query.Type); ok && t.Type == query.TypeRepo { return true } repoOnly := true query.VisitAtoms(q, func(a query.Q) { _, ok := a.(*query.Repo) repoOnly = repoOnly && ok }) return repoOnly }