diff --git a/appview/codesearch/codesearch.go b/appview/codesearch/codesearch.go new file mode 100644 index 00000000..4e6ce993 --- /dev/null +++ b/appview/codesearch/codesearch.go @@ -0,0 +1,201 @@ +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 +} diff --git a/appview/codesearch/codesearch_test.go b/appview/codesearch/codesearch_test.go new file mode 100644 index 00000000..37fcacba --- /dev/null +++ b/appview/codesearch/codesearch_test.go @@ -0,0 +1,54 @@ +package codesearch + +import ( + "testing" + + "github.com/sourcegraph/zoekt/query" +) + +func TestIsRepoOnly(t *testing.T) { + cases := []struct { + query string + want bool + }{ + {"repo:foo", true}, + {"repo:foo repo:bar", true}, + {"repo:foo bar", false}, + {"repo:foo file:x", false}, + {"file:x", false}, + {"type:repo foo", true}, + {"foo", false}, + {"branch:main", false}, + } + + for _, tc := range cases { + t.Run(tc.query, func(t *testing.T) { + q, err := query.Parse(tc.query) + if err != nil { + t.Fatalf("parse %q: %v", tc.query, err) + } + if got := isRepoOnly(q); got != tc.want { + t.Errorf("isRepoOnly(%q) = %v, want %v", tc.query, got, tc.want) + } + }) + } +} + +func TestExtractDID(t *testing.T) { + cases := []struct { + tmpl string + want string + }{ + {"https://tangled.org/did:plc:abc123/blob/{{.Version}}/{{.Path}}", "did:plc:abc123"}, + {"http://localhost:3000/did:web:example.com/blob/{{.Version}}/{{.Path}}", "did:web:example.com"}, + {"", ""}, + } + + for _, tc := range cases { + t.Run(tc.tmpl, func(t *testing.T) { + if got := extractDID(tc.tmpl); string(got) != tc.want { + t.Errorf("extractDID(%q) = %q, want %q", tc.tmpl, got, tc.want) + } + }) + } +} diff --git a/appview/config/config.go b/appview/config/config.go index 36a66a68..a3c4f650 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -157,6 +157,10 @@ type OgreConfig struct { Host string `env:"HOST, default=https://ogre.tangled.network"` } +type CodeSearchConfig struct { + ZoektUrl string `env:"ZOEKT_URL"` +} + type SSHConfig struct { Enabled bool `env:"ENABLED, default=false"` ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3333"` @@ -198,6 +202,7 @@ type Config struct { KnotMirror KnotMirrorConfig `env:",prefix=TANGLED_KNOTMIRROR_"` Ogre OgreConfig `env:",prefix=TANGLED_OGRE_"` SSH SSHConfig `env:",prefix=TANGLED_SSH_"` + CodeSearch CodeSearchConfig `env:",prefix=TANGLED_CODESEARCH_"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/appview/models/codesearch.go b/appview/models/codesearch.go new file mode 100644 index 00000000..421fbea1 --- /dev/null +++ b/appview/models/codesearch.go @@ -0,0 +1,47 @@ +package models + +import ( + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/sourcegraph/zoekt" +) + +// Result is union type of file/chunk match +type Result struct { + RepoDID syntax.DID + File *Result_FileMatch + Chunk *Result_ChunkMatch +} + +type Result_FileMatch struct { + FilePath string + Branches []string // branch names + Commit string // commit id + Language string + // Ranges are the matched span(s) within FilePath. LineNumber is always 1 for + // filename matches; Column is 1-based and in runes. + Ranges []zoekt.Range +} + +type Result_ChunkMatch struct { + FilePath string + Branches []string // branch names + Commit string // commit id + Language string + // Content is a contiguous run of complete lines that fully contains Ranges. + Content string + // ContentStartLine is the 1-based line number of Content's first line. + ContentStartLine int + // Ranges are the matched span(s) within the file. LineNumber/Column are + // 1-based, Column is in runes. A Range may span multiple lines. + Ranges []zoekt.Range +} + +// IsFileMatch tells if search result is from file-name match +func (r *Result) IsFileMatch() bool { + return r.File != nil +} + +// IsChunkMatch tells if search result is from chunk match +func (r *Result) IsChunkMatch() bool { + return r.Chunk != nil +} diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index 8232deb4..496f88a7 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -16,6 +16,7 @@ import ( "net/url" "path/filepath" "reflect" + "sort" "strings" "time" @@ -688,3 +689,47 @@ func durationFmt(duration time.Duration, names [4]string) string { return strings.Join(parts, " ") } + +// mergeIntervals sorts half-open rune intervals and merges overlapping/adjacent ones. +func mergeIntervals(in [][2]int) [][2]int { + if len(in) < 2 { + return in + } + sort.Slice(in, func(i, j int) bool { return in[i][0] < in[j][0] }) + out := in[:1] + for _, iv := range in[1:] { + last := &out[len(out)-1] + if iv[0] <= last[1] { + if iv[1] > last[1] { + last[1] = iv[1] + } + continue + } + out = append(out, iv) + } + return out +} + +// spanRunes splits runes into alternating unmatched/matched ChunkSpans using the +// (sorted, merged) match intervals. Returns nil for an empty line. +func spanRunes(runes []rune, intervals [][2]int) []ChunkSpan { + if len(runes) == 0 { + return nil + } + if len(intervals) == 0 { + return []ChunkSpan{{Text: string(runes)}} + } + var spans []ChunkSpan + pos := 0 + for _, iv := range intervals { + if iv[0] > pos { + spans = append(spans, ChunkSpan{Text: string(runes[pos:iv[0]])}) + } + spans = append(spans, ChunkSpan{Text: string(runes[iv[0]:iv[1]]), Match: true}) + pos = iv[1] + } + if pos < len(runes) { + spans = append(spans, ChunkSpan{Text: string(runes[pos:])}) + } + return spans +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 5d8aafa7..f89d3b9b 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -33,6 +33,7 @@ import ( "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-git/go-git/v5/plumbing" + "github.com/sourcegraph/zoekt" ) //go:embed templates/* static legal @@ -1751,6 +1752,149 @@ func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error { return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params) } +// CodeSearchResult pairs a code-search match with the resolved repository +// metadata. Repo may be nil when the matched repo is not (or no longer) known +// to the appview DB; templates guard against that. Chunk/File form a union +// mirroring models.Result, with match highlighting precomputed during the +// models.Result -> CodeSearchResult conversion in the handler. +type CodeSearchResult struct { + RepoDID syntax.DID + Repo *models.Repo + Chunk *CodeSearchResult_Chunk + File *CodeSearchResult_File +} + +// CodeSearchResult_Chunk is a content match with its lines pre-rendered. +type CodeSearchResult_Chunk struct { + FilePath string + Branches []string + Commit string + Language string + Lines []ChunkLine // precomputed from Content/ContentStartLine/Ranges +} + +// CodeSearchResult_File is a filename match with its name spans pre-rendered. +type CodeSearchResult_File struct { + FilePath string + Branches []string + Commit string + Language string + NameSpans []ChunkSpan // precomputed from FilePath/Ranges +} + +// Commit/FilePath dispatch for the top-level "View file" link, rendered outside +// the with .Chunk / with .File blocks. +func (r *CodeSearchResult) Commit() string { + if r.Chunk != nil { + return r.Chunk.Commit + } + if r.File != nil { + return r.File.Commit + } + return "" +} + +func (r *CodeSearchResult) FilePath() string { + if r.Chunk != nil { + return r.Chunk.FilePath + } + if r.File != nil { + return r.File.FilePath + } + return "" +} + +// ChunkSpan is a contiguous run of a chunk line; Match marks the runs that fall +// within a zoekt match range (rendered with a stronger highlight). +type ChunkSpan struct { + Text string + Match bool +} + +// ChunkLine is a single rendered line of a chunk match, produced by the +// chunkLines template helper. Spans split the line into matched/unmatched runs; +// Highlight marks lines that overlap any match range (subtle full-line tint). +type ChunkLine struct { + Num int + Spans []ChunkSpan + Highlight bool +} + +// ChunkLines renders a chunk's Content into per-line ChunkLines, splitting each +// line into matched/unmatched spans using ranges. startLine is the 1-based line +// number of the first line. +func ChunkLines(content string, startLine int, ranges []zoekt.Range) []ChunkLine { + if startLine < 1 { + startLine = 1 + } + // trim a single trailing newline so we don't emit a spurious empty line + content = strings.TrimSuffix(content, "\n") + lines := strings.Split(content, "\n") + out := make([]ChunkLine, len(lines)) + for i, text := range lines { + num := startLine + i + runes := []rune(text) + + // collect matched rune intervals [c0,c1) for this line + var intervals [][2]int + for _, rg := range ranges { + if num < int(rg.Start.LineNumber) || num > int(rg.End.LineNumber) { + continue + } + c0, c1 := 0, len(runes) + if num == int(rg.Start.LineNumber) { + c0 = int(rg.Start.Column) - 1 + } + if num == int(rg.End.LineNumber) { + c1 = int(rg.End.Column) - 1 + } + c0 = max(0, min(c0, len(runes))) + c1 = max(0, min(c1, len(runes))) + if c0 < c1 { + intervals = append(intervals, [2]int{c0, c1}) + } + } + intervals = mergeIntervals(intervals) + + out[i] = ChunkLine{ + Num: num, + Spans: spanRunes(runes, intervals), + Highlight: len(intervals) > 0, + } + } + return out +} + +// FileNameSpans splits a filename into matched/unmatched spans using ranges. +// Filename ranges live on line 1; columns are clamped to rune bounds. +func FileNameSpans(name string, ranges []zoekt.Range) []ChunkSpan { + runes := []rune(name) + var intervals [][2]int + for _, rg := range ranges { + if rg.Start.LineNumber > 1 || rg.End.LineNumber < 1 { + continue + } + c0 := max(0, min(int(rg.Start.Column)-1, len(runes))) + c1 := max(0, min(int(rg.End.Column)-1, len(runes))) + if c0 < c1 { + intervals = append(intervals, [2]int{c0, c1}) + } + } + return spanRunes(runes, mergeIntervals(intervals)) +} + +type CodeSearchParams struct { + BaseParams + FilterQuery string + Results []CodeSearchResult + Page pagination.Page + HasMore bool +} + +func (p *Pages) CodeSearch(w io.Writer, params CodeSearchParams) error { + return p.execute("codesearch/index", w, params) +} + func (p *Pages) Home(w io.Writer, params TimelineParams) error { return p.execute("timeline/home", w, params) } diff --git a/appview/pages/templates/codesearch/fragments/chunkCard.html b/appview/pages/templates/codesearch/fragments/chunkCard.html new file mode 100644 index 00000000..4c55d8a3 --- /dev/null +++ b/appview/pages/templates/codesearch/fragments/chunkCard.html @@ -0,0 +1,90 @@ +{{ define "codesearch/fragments/chunkCard" }} + {{ $owner := "" }} + {{ $slug := "" }} + {{ if .Repo }} + {{ $owner = resolve .Repo.Did }} + {{ $slug = .Repo.Slug }} + {{ end }} + +
+ Click a language to filter results. You can also filter by language by
+ adding lang:name to the search bar.
+