Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
6.0 kB · 201 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202package 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}