Skip to content

Commit e35880e

Browse files
Alex619829Alex619829
authored andcommitted
Add code search with zoekt support (#8827)
This PR adds zoekt as a code search engine for forgejo. This Pull Request is a continuation of the discussion #8302. The meilisearch search engine was not suitable, as it is not designed for searching by code. The zoekt project was proposed instead. Zoekt copes well with code indexing, but its operating principle differs from such search engines as elasticsearch. While elasticsearch can return a result in a ready-made form (with pagination, ready-made snippets, etc.) and forgejo only needs to show this result in the interface with a little work with the data, zoekt works completely differently. Zoekt finds matches in the repository index and returns a response. The response contains a line with the search word, its number from the file, and also a context, if specified in the request. This response is not suitable for Forgejo, so you need to assemble it yourself. To assemble the response from Zoekt into a form acceptable for Forgejo, I had to write some code and create a new function `searchZoektResult`, since the existing `searchResult` function is completely unsuitable for this search engine. I also had to write logic for pagination, highlighting, and correct display of lines in found snippets with a match, but this is a feature of Zoekt. At the moment, Zoekt does not support deleting a repository index by repo_id, it only supports complete deletion of all repositories. But I still implemented the Delete function, which deletes a specific repository by its ID. Co-authored-by: Aleksandr Gamzin <gamzin@altlinux.org> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8827 Reviewed-by: Shiny Nematoda <snematoda@noreply.codeberg.org> Reviewed-by: Gusted <gusted@noreply.codeberg.org>
1 parent 6f5df4f commit e35880e

19 files changed

Lines changed: 1334 additions & 130 deletions

File tree

assets/go-licenses.json

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

custom/conf/app.example.ini

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,10 +1481,10 @@ LEVEL = Info
14811481
;; If empty then it defaults to `sources` only, as if you'd like to disable fully please see REPO_INDEXER_ENABLED.
14821482
;REPO_INDEXER_REPO_TYPES = sources,forks,mirrors,templates
14831483
;;
1484-
;; Code search engine type, could be `bleve` or `elasticsearch`.
1484+
;; Code search engine type, could be `bleve`, `zoekt` or `elasticsearch`.
14851485
;REPO_INDEXER_TYPE = bleve
14861486
;;
1487-
;; Index file used for code search. available when `REPO_INDEXER_TYPE` is bleve
1487+
;; Index file used for code search. available when `REPO_INDEXER_TYPE` is bleve or zoekt
14881488
;REPO_INDEXER_PATH = indexers/repos.bleve
14891489
;;
14901490
;; Code indexer connection string, available when `REPO_INDEXER_TYPE` is elasticsearch. i.e. http://elastic:changeme@localhost:9200
@@ -1494,10 +1494,10 @@ LEVEL = Info
14941494
;REPO_INDEXER_NAME = gitea_codes
14951495
;;
14961496
;; A comma separated list of glob patterns (see https://github.com/gobwas/glob) to include
1497-
;; in the index; default is empty
1497+
;; in the index; it's not compatible with the `zoekt` indexer type; default is empty
14981498
;REPO_INDEXER_INCLUDE =
14991499
;;
1500-
;; A comma separated list of glob patterns to exclude from the index; ; default is empty
1500+
;; A comma separated list of glob patterns to exclude from the index; it's not compatible with the `zoekt` indexer type; default is empty
15011501
;REPO_INDEXER_EXCLUDE =
15021502
;;
15031503
;; If vendored files should be excluded.

go.mod

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ require (
9292
github.com/redis/go-redis/v9 v9.19.0
9393
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
9494
github.com/sergi/go-diff v1.4.0
95+
github.com/sourcegraph/zoekt v0.0.0-20260114143800-c747a3bccc2a
9596
github.com/stretchr/testify v1.11.1
9697
github.com/syndtr/goleveldb v1.0.0
9798
github.com/ulikunitz/xz v0.5.15
@@ -117,10 +118,11 @@ require (
117118
)
118119

119120
require (
120-
cloud.google.com/go/compute/metadata v0.6.0 // indirect
121+
cloud.google.com/go/compute/metadata v0.7.0 // indirect
121122
code.superseriousbusiness.org/go-png-image-structure/v2 v2.3.0 // indirect
122123
filippo.io/edwards25519 v1.2.0 // indirect
123124
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect
125+
github.com/RoaringBitmap/roaring v1.9.4 // indirect
124126
github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect
125127
github.com/STARRY-S/zip v0.2.3 // indirect
126128
github.com/andybalholm/brotli v1.2.0 // indirect
@@ -147,6 +149,7 @@ require (
147149
github.com/blevesearch/zapx/v15 v15.4.3 // indirect
148150
github.com/blevesearch/zapx/v16 v16.3.4 // indirect
149151
github.com/blevesearch/zapx/v17 v17.1.2 // indirect
152+
github.com/bmatcuk/doublestar v1.3.4 // indirect
150153
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
151154
github.com/bodgit/plumbing v1.3.0 // indirect
152155
github.com/bodgit/sevenzip v1.6.1 // indirect
@@ -195,6 +198,8 @@ require (
195198
github.com/gorilla/css v1.0.1 // indirect
196199
github.com/gorilla/mux v1.8.1 // indirect
197200
github.com/gorilla/securecookie v1.1.2 // indirect
201+
github.com/grafana/regexp v0.0.0-20240607082908-2cb410fa05da // indirect
202+
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
198203
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
199204
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
200205
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -226,6 +231,7 @@ require (
226231
github.com/olekukonko/tablewriter v1.0.7 // indirect
227232
github.com/onsi/ginkgo v1.16.5 // indirect
228233
github.com/onsi/gomega v1.34.1 // indirect
234+
github.com/opentracing/opentracing-go v1.2.0 // indirect
229235
github.com/philhofer/fwd v1.2.0 // indirect
230236
github.com/pierrec/lz4/v4 v4.1.22 // indirect
231237
github.com/pkg/errors v0.9.1 // indirect
@@ -239,6 +245,7 @@ require (
239245
github.com/rs/xid v1.6.0 // indirect
240246
github.com/sirupsen/logrus v1.9.4 // indirect
241247
github.com/sorairolake/lzip-go v0.3.8 // indirect
248+
github.com/sourcegraph/go-ctags v0.0.0-20250729094530-349a251d78d8 // indirect
242249
github.com/spf13/afero v1.15.0 // indirect
243250
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
244251
github.com/stretchr/objx v0.5.2 // indirect
@@ -256,6 +263,8 @@ require (
256263
golang.org/x/mod v0.35.0 // indirect
257264
golang.org/x/time v0.15.0 // indirect
258265
golang.org/x/tools v0.44.0 // indirect
266+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
267+
google.golang.org/grpc v1.75.0 // indirect
259268
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
260269
gopkg.in/yaml.v2 v2.4.0 // indirect
261270
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 108 additions & 2 deletions
Large diffs are not rendered by default.

modules/indexer/code/bleve/bleve.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,7 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
384384
}
385385
return total, searchResults, searchResultLanguages, nil
386386
}
387+
388+
func (b *Indexer) Formatter() internal.ResultFormatter {
389+
return nil
390+
}

modules/indexer/code/elasticsearch/elasticsearch.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,7 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
418418

419419
return total, hits, extractAggs(countResult), err
420420
}
421+
422+
func (b *Indexer) Formatter() internal.ResultFormatter {
423+
return nil
424+
}

modules/indexer/code/highlight.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright 2026 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package code
5+
6+
import "forgejo.org/modules/indexer/code/internal"
7+
8+
func HighlightSearchResultCode(filename string, lineNums []int, highlightRanges [][3]int, code string) []internal.ResultLine {
9+
return internal.HighlightSearchResultCode(filename, lineNums, highlightRanges, code)
10+
}

modules/indexer/code/indexer.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"forgejo.org/modules/indexer/code/bleve"
1818
"forgejo.org/modules/indexer/code/elasticsearch"
1919
"forgejo.org/modules/indexer/code/internal"
20+
"forgejo.org/modules/indexer/code/zoekt"
2021
"forgejo.org/modules/log"
2122
"forgejo.org/modules/process"
2223
"forgejo.org/modules/queue"
@@ -129,7 +130,7 @@ func Init() {
129130

130131
// Create the Queue
131132
switch setting.Indexer.RepoType {
132-
case "bleve", "elasticsearch":
133+
case "bleve", "elasticsearch", "zoekt":
133134
handler := func(items ...*internal.IndexerData) (unhandled []*internal.IndexerData) {
134135
indexer := *globalIndexer.Load()
135136
// make it a process to allow for cancellation (especially during integration tests where no global shutdown happens)
@@ -200,6 +201,25 @@ func Init() {
200201
close(waitChannel)
201202
log.Fatal("PID: %d Unable to initialize the elasticsearch Repository Indexer connstr: %s Error: %v", os.Getpid(), util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr), err)
202203
}
204+
case "zoekt":
205+
log.Info("PID: %d Initializing Repository Indexer at: %s", os.Getpid(), setting.Indexer.RepoPath)
206+
defer func() {
207+
if err := recover(); err != nil {
208+
log.Error("PANIC whilst initializing repository indexer: %v\nStacktrace: %s", err, log.Stack(2))
209+
log.Error("The indexer files are likely corrupted and may need to be deleted")
210+
log.Error("You can completely remove the \"%s\" directory to make Gitea recreate the indexes", setting.Indexer.RepoPath)
211+
}
212+
}()
213+
214+
rIndexer = zoekt.NewIndexer(setting.Indexer.RepoPath)
215+
existed, err = rIndexer.Init(ctx)
216+
if err != nil {
217+
cancel()
218+
(*globalIndexer.Load()).Close()
219+
close(waitChannel)
220+
221+
log.Fatal("PID: %d Unable to initialize the zoekt Repository Indexer at path: %s Error: %v", os.Getpid(), setting.Indexer.RepoPath, err)
222+
}
203223

204224
default:
205225
log.Fatal("PID: %d Unknown Indexer type: %s", os.Getpid(), setting.Indexer.RepoType)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2026 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package internal
5+
6+
import (
7+
"bytes"
8+
"html/template"
9+
"strings"
10+
11+
"forgejo.org/modules/highlight"
12+
"forgejo.org/services/gitdiff"
13+
)
14+
15+
func WriteStrings(buf *bytes.Buffer, strs ...string) error {
16+
for _, s := range strs {
17+
_, err := buf.WriteString(s)
18+
if err != nil {
19+
return err
20+
}
21+
}
22+
return nil
23+
}
24+
25+
const (
26+
highlightTagStart = "<span class=\"search-highlight\">"
27+
highlightTagEnd = "</span>"
28+
)
29+
30+
func HighlightSearchResultCode(filename string, lineNums []int, highlightRanges [][3]int, code string) []ResultLine {
31+
hcd := gitdiff.NewHighlightCodeDiff()
32+
hcd.CollectUsedRunes(code)
33+
startTag, endTag := hcd.NextPlaceholder(), hcd.NextPlaceholder()
34+
hcd.PlaceholderTokenMap[startTag] = highlightTagStart
35+
hcd.PlaceholderTokenMap[endTag] = highlightTagEnd
36+
37+
// we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting
38+
hl, _ := highlight.Code(filename, "", code)
39+
conv := hcd.ConvertToPlaceholders(string(hl))
40+
convLines := strings.Split(conv, "\n")
41+
42+
// each highlightRange is of the form [line number, start byte offset, end byte offset]
43+
for _, highlightRange := range highlightRanges {
44+
ln, start, end := highlightRange[0], highlightRange[1], highlightRange[2]
45+
line := convLines[ln]
46+
if line == "" || len(line) <= start || len(line) < end {
47+
continue
48+
}
49+
50+
sr := strings.NewReader(line)
51+
sb := strings.Builder{}
52+
count := -1
53+
isOpen := false
54+
for r, size, err := sr.ReadRune(); err == nil; r, size, err = sr.ReadRune() {
55+
if token, ok := hcd.PlaceholderTokenMap[r];
56+
// token was not found
57+
!ok {
58+
count += size
59+
} else if
60+
// token was marked as used
61+
token == "" ||
62+
// the token is not an valid html tag emitted by chroma
63+
!(len(token) > 6 && (token[0:5] == "<span" || token[0:6] == "</span")) {
64+
count++
65+
} else if !isOpen {
66+
// open the tag only after all other placeholders
67+
sb.WriteRune(r)
68+
continue
69+
} else if isOpen && count < end {
70+
// if the tag is open, but a placeholder exists in between
71+
// close the tag
72+
sb.WriteRune(endTag)
73+
// write the placeholder
74+
sb.WriteRune(r)
75+
// reopen the tag
76+
sb.WriteRune(startTag)
77+
continue
78+
}
79+
80+
switch {
81+
case count >= end:
82+
// if tag is not open, no need to close
83+
if !isOpen {
84+
break
85+
}
86+
sb.WriteRune(endTag)
87+
isOpen = false
88+
case count >= start:
89+
// if tag is open, do not open again
90+
if isOpen {
91+
break
92+
}
93+
isOpen = true
94+
sb.WriteRune(startTag)
95+
}
96+
97+
sb.WriteRune(r)
98+
}
99+
if isOpen {
100+
sb.WriteRune(endTag)
101+
}
102+
convLines[ln] = sb.String()
103+
}
104+
conv = strings.Join(convLines, "\n")
105+
106+
highlightedLines := strings.Split(hcd.Recover(conv), "\n")
107+
// The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n`
108+
lines := make([]ResultLine, min(len(highlightedLines), len(lineNums)))
109+
for i := range len(lines) {
110+
lines[i].Num = lineNums[i]
111+
lines[i].FormattedContent = template.HTML(highlightedLines[i])
112+
}
113+
return lines
114+
}

modules/indexer/code/internal/indexer.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Indexer interface {
1818
Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *RepoChanges) error
1919
Delete(ctx context.Context, repoID int64) error
2020
Search(ctx context.Context, opts *SearchOptions) (int64, []*SearchResult, []*SearchResultLanguages, error)
21+
Formatter() ResultFormatter
2122
}
2223

2324
type CodeSearchMode int
@@ -72,3 +73,7 @@ func (d *dummyIndexer) Delete(ctx context.Context, repoID int64) error {
7273
func (d *dummyIndexer) Search(ctx context.Context, opts *SearchOptions) (int64, []*SearchResult, []*SearchResultLanguages, error) {
7374
return 0, nil, nil, errors.New("indexer is not ready")
7475
}
76+
77+
func (d *dummyIndexer) Formatter() ResultFormatter {
78+
return nil
79+
}

0 commit comments

Comments
 (0)