Commit b1cab2086b

b1cab2086b46d9d57b18b492ecf16061b03ff0f0

parent: ebab9139d1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:45 UTC

symbols: index definitions per default-branch tree

A background worker builds one symbol index per repository from the
default branch head, keyed by its tree id (migration). Push,
merge, default-branch change, import and pull-mirror sync queue a
request. Go is parsed with go/parser; other languages match by line.
Bounded by file size, vendored and generated paths, 200k symbols and
two minutes; failed and partial runs are recorded, not retried.

Ref #293

Layout: unified · split

cmd/gitbayd/main.go +4
@@ -37,6 +37,7 @@ import (
3737 "gitbay.org/gitbay/internal/seal"
3838 "gitbay.org/gitbay/internal/sshd"
3939 "gitbay.org/gitbay/internal/store"
40 "gitbay.org/gitbay/internal/symbols"
4041 "gitbay.org/gitbay/internal/toolpath"
4142 "gitbay.org/gitbay/internal/webhook"
4243)
@@ -228,6 +229,9 @@ func serveCmd() *cobra.Command {
228229 go deps.New(st, cfg, func(owner, name string) string {
229230 return control.RepoDir(cfg.Server.Root, owner, name)
230231 }, buildinfo.String()).Run(whCtx)
232 go symbols.New(st, func(owner, name string) string {
233 return control.RepoDir(cfg.Server.Root, owner, name)
234 }).Run(whCtx)
231235
232236 // One pack-generation budget for SSH, smart HTTP and git://.
233237 // Anonymous clients ("ip:" principals) share all but one
internal/control/import.go +1
@@ -173,6 +173,7 @@ func runRepoImport(c *Ctx, args []string) int {
173173 gitutil.SetHead(dir, branch)
174174 c.Store.UpdateDefaultBranch(id, branch)
175175 }
176 c.Store.RequestSymbolIndex(id, false)
176177
177178 c.Store.RecordEvent(id, c.User.ID, "repo.imported", fmt.Sprintf(`{"from":%q}`, from))
178179 type out struct {
internal/control/mr.go +1
@@ -1611,6 +1611,7 @@ func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
16111611 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, c.Scope, targetSHA, newSHA)
16121612 ProcessMRDescription(c.Store, repo, mr, c.User.ID, c.Scope)
16131613 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
1614 c.Store.RequestSymbolIndex(repo.ID, false)
16141615 }
16151616 // A merge moves the ref directly, so it never reaches post-receive and
16161617 // none of the ref-update work fires on its own. The event webhooks
internal/control/refsupdated.go +2
@@ -51,6 +51,8 @@ func RefsUpdated(st *store.Store, cfg config.Config, repoID, userID int64, scope
5151 dir := RepoDir(cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
5252 ProcessCommitMessages(st, dir, pushedRepo, userID, scope, u.Old, u.New)
5353 RecordLandedCommits(st, dir, pushedRepo, u.Old, u.New)
54 // The symbol index is rebuilt by its worker, never here.
55 st.RequestSymbolIndex(pushedRepo.ID, false)
5456 }
5557 // A branch push with a .gitbay/ci.yml queues one build per job.
5658 if pushedRepoErr == nil && !u.IsDelete {
internal/control/repo.go +1
@@ -790,6 +790,7 @@ func runSetDefaultBranch(c *Ctx, args []string) int {
790790 if err := c.Store.UpdateDefaultBranch(repo.ID, branch); err != nil {
791791 return c.fail(protocol.ExitFailure, "%v", err)
792792 }
793 c.Store.RequestSymbolIndex(repo.ID, false)
793794 return c.emit(map[string]string{"default_branch": branch}, func(w io.Writer) {
794795 fmt.Fprintf(w, "default branch of %s is now %s\n", repo.Path(), branch)
795796 })
internal/gitutil/treeblobs.go added +117
@@ -0,0 +1,117 @@
1package gitutil
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "os/exec"
11 "strconv"
12 "strings"
13
14 "gitbay.org/gitbay/internal/toolpath"
15)
16
17// ListBlobs lists every blob in a tree, recursively, with its size. Name
18// is the path from the tree's root. Paths are read NUL-separated, so no
19// file name is quoted or split.
20func ListBlobs(ctx context.Context, dir, tree string) ([]TreeEntry, error) {
21 out, err := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir,
22 "ls-tree", "-r", "-l", "-z", "--end-of-options", tree).Output()
23 if err != nil {
24 return nil, fmt.Errorf("ls-tree %s: %w", tree, err)
25 }
26 var entries []TreeEntry
27 for _, rec := range bytes.Split(out, []byte{0}) {
28 meta, name, ok := strings.Cut(string(rec), "\t")
29 if !ok {
30 continue
31 }
32 f := strings.Fields(meta)
33 if len(f) != 4 || f[1] != "blob" {
34 continue
35 }
36 // ls-tree prints a size of BAD for a blob it cannot read.
37 size, err := strconv.ParseInt(f[3], 10, 64)
38 if err != nil {
39 return nil, fmt.Errorf("ls-tree %s: cannot read %s", tree, name)
40 }
41 entries = append(entries, TreeEntry{Mode: f[0], Type: f[1], SHA: f[2], Size: size, Name: name})
42 }
43 return entries, nil
44}
45
46// CatBlobs reads blobs by id through one `git cat-file --batch`, calling
47// fn with each one's index in shas and its contents, in order. fn
48// returning false stops the read. Cancelling parent stops it too, and is
49// reported as parent's error.
50func CatBlobs(parent context.Context, dir string, shas []string, fn func(i int, data []byte) bool) error {
51 ctx, cancel := context.WithCancel(parent)
52 defer cancel()
53 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "-C", dir, "cat-file", "--batch")
54 stdin, err := cmd.StdinPipe()
55 if err != nil {
56 return err
57 }
58 stdout, err := cmd.StdoutPipe()
59 if err != nil {
60 return err
61 }
62 if err := cmd.Start(); err != nil {
63 return err
64 }
65 go func() {
66 w := bufio.NewWriter(stdin)
67 for _, s := range shas {
68 if _, err := fmt.Fprintln(w, s); err != nil {
69 break
70 }
71 }
72 w.Flush()
73 stdin.Close()
74 }()
75 r := bufio.NewReader(stdout)
76 readErr := func() error {
77 for i := range shas {
78 header, err := r.ReadString('\n')
79 if err != nil {
80 return err
81 }
82 // <sha> <type> <size>, or <sha> missing
83 f := strings.Fields(header)
84 if len(f) != 3 {
85 return fmt.Errorf("cat-file: %s", strings.TrimSpace(header))
86 }
87 size, err := strconv.ParseInt(f[2], 10, 64)
88 if err != nil {
89 return fmt.Errorf("cat-file: %s", strings.TrimSpace(header))
90 }
91 data := make([]byte, size+1) // the object and its trailing newline
92 if _, err := io.ReadFull(r, data); err != nil {
93 return err
94 }
95 if !fn(i, data[:size]) {
96 return errStopped
97 }
98 }
99 return nil
100 }()
101 if readErr != nil {
102 cancel()
103 }
104 io.Copy(io.Discard, stdout)
105 waitErr := cmd.Wait()
106 switch {
107 case readErr == errStopped:
108 return nil
109 case parent.Err() != nil:
110 return parent.Err()
111 case readErr != nil:
112 return readErr
113 }
114 return waitErr
115}
116
117var errStopped = errors.New("stopped")
internal/hookd/symbols_test.go added +63
@@ -0,0 +1,63 @@
1package hookd
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "gitbay.org/gitbay/internal/control"
10 "gitbay.org/gitbay/internal/policy"
11 "gitbay.org/gitbay/internal/store"
12 "gitbay.org/gitbay/internal/symbols"
13)
14
15// A push to the default branch asks for a symbol index and builds none
16// itself; a push to another branch asks for nothing. The worker builds
17// the index afterwards.
18func TestPostReceiveRequestsSymbolIndex(t *testing.T) {
19 f := newShapeFixture(t)
20 requested := func() bool {
21 t.Helper()
22 reqs, err := f.st.SymbolRequests()
23 if err != nil {
24 t.Fatal(err)
25 }
26 return len(reqs) == 1 && reqs[0].RepoID == f.repo.ID && !reqs[0].Force
27 }
28
29 f.git(f.src, "checkout", "-q", "-b", "side")
30 side := f.appCommit("side")
31 f.sync()
32 f.srv.postReceive(Request{RepoID: f.repo.ID, UserID: f.uid, Scope: "full", Updates: []policy.RefUpdate{
33 {Ref: "refs/heads/side", Old: zeroSHA40, New: side}}})
34 if requested() {
35 t.Fatal("a push to a side branch asked for a symbol index")
36 }
37
38 f.git(f.src, "checkout", "-q", "main")
39 os.WriteFile(filepath.Join(f.src, "main.go"), []byte("package main\n\nfunc Serve() {}\n"), 0o644)
40 f.git(f.src, "add", ".")
41 f.git(f.src, "commit", "-q", "-m", "go")
42 head := f.sha("HEAD")
43 f.sync()
44 f.srv.postReceive(Request{RepoID: f.repo.ID, UserID: f.uid, Scope: "full", Updates: []policy.RefUpdate{
45 {Ref: "refs/heads/main", Old: f.base, New: head}}})
46 if !requested() {
47 t.Fatal("a push to the default branch did not ask for a symbol index")
48 }
49 if _, err := f.st.SymbolIndexFor(f.repo.ID); err != store.ErrNotFound {
50 t.Fatalf("the push built an index itself: %v", err)
51 }
52
53 w := symbols.New(f.st, func(owner, name string) string { return control.RepoDir(f.root, owner, name) })
54 w.Sweep(context.Background())
55 x, err := f.st.SymbolIndexFor(f.repo.ID)
56 if err != nil || x.Commit != head || x.State != "ok" {
57 t.Fatalf("index after the sweep = %+v, %v", x, err)
58 }
59 rows, _ := f.st.SearchSymbols(x.ID, "Serve", "", 0, 0)
60 if len(rows) != 1 || rows[0].Path != "main.go" || rows[0].Line != 3 {
61 t.Fatalf("Serve = %+v", rows)
62 }
63}
internal/mirror/mirror.go +3
@@ -84,6 +84,9 @@ func (w *Worker) sweep() {
8484 w.St.SetMirrorResult(m.ID, err.Error())
8585 } else {
8686 w.St.SetMirrorResult(m.ID, "")
87 if m.Direction == "pull" {
88 w.St.RequestSymbolIndex(m.RepoID, false)
89 }
8790 }
8891 }
8992}
internal/store/migrations/0072_symbols.down.sql added +3
@@ -0,0 +1,3 @@
1DROP TABLE symbol_requests;
2DROP TABLE symbols;
3DROP TABLE symbol_indexes;
internal/store/migrations/0072_symbols.up.sql added +45
@@ -0,0 +1,45 @@
1-- One symbol index per repository: the definitions found in the tree of
2-- the default branch's head (#293). tree is the key: a head whose tree is
3-- the indexed one needs no new index. state is ok, partial (a bound was
4-- reached; note says which) or failed (note says why), and a failed or
5-- partial index is kept as the record for its tree rather than retried.
6-- AUTOINCREMENT because a replaced index must not hand its id to the
7-- next: a paging cursor names the index it was taken from.
8CREATE TABLE symbol_indexes (
9 id INTEGER PRIMARY KEY AUTOINCREMENT,
10 repo_id INTEGER NOT NULL UNIQUE REFERENCES repos(id) ON DELETE CASCADE,
11 commit_sha TEXT NOT NULL,
12 tree TEXT NOT NULL,
13 state TEXT NOT NULL CHECK (state IN ('ok', 'partial', 'failed')),
14 note TEXT NOT NULL DEFAULT '',
15 files INTEGER NOT NULL DEFAULT 0,
16 symbols INTEGER NOT NULL DEFAULT 0,
17 built_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
18);
19-- name is what is listed, key the name as written at a use site (they
20-- differ for Go methods: Type.Method and Method). lname and lkey are
21-- their lower-case forms, for case-insensitive prefix ranges.
22CREATE TABLE symbols (
23 id INTEGER PRIMARY KEY,
24 index_id INTEGER NOT NULL REFERENCES symbol_indexes(id) ON DELETE CASCADE,
25 name TEXT NOT NULL,
26 lname TEXT NOT NULL,
27 key TEXT NOT NULL,
28 lkey TEXT NOT NULL,
29 kind TEXT NOT NULL,
30 path TEXT NOT NULL,
31 line INTEGER NOT NULL
32);
33CREATE INDEX symbols_lname ON symbols(index_id, lname);
34CREATE INDEX symbols_lkey ON symbols(index_id, lkey);
35CREATE INDEX symbols_key ON symbols(index_id, key);
36CREATE INDEX symbols_path ON symbols(index_id, path, line);
37-- Repositories waiting for the index worker. seq counts requests, so a
38-- push that lands while a build runs leaves its request in place; force
39-- rebuilds even when the tree is the indexed one.
40CREATE TABLE symbol_requests (
41 repo_id INTEGER PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE,
42 seq INTEGER NOT NULL DEFAULT 1,
43 force INTEGER NOT NULL DEFAULT 0,
44 requested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
45);
internal/store/symbols.go added +251
@@ -0,0 +1,251 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "strings"
7 "unicode/utf8"
8)
9
10// SymbolIndex is a repository's symbol index: what was indexed and how it
11// went. The states are described with the table's schema.
12type SymbolIndex struct {
13 ID int64
14 RepoID int64
15 Commit string
16 Tree string
17 State string // ok | partial | failed
18 Note string
19 Files int
20 Symbols int
21 BuiltAt string
22}
23
24// SymbolRow is one definition. Key is the name as written where it is
25// used; see internal/symbols.
26type SymbolRow struct {
27 ID int64
28 Name string
29 Key string
30 Kind string
31 Path string
32 Line int
33}
34
35// SymbolTarget is where a key is defined: Count definitions, the first of
36// them at Path and Line.
37type SymbolTarget struct {
38 Count int
39 Path string
40 Line int
41}
42
43// SymbolRequest is a repository waiting for the index worker.
44type SymbolRequest struct {
45 RepoID int64
46 Seq int64
47 Force bool
48}
49
50// RequestSymbolIndex queues a repository for the index worker. A request
51// already waiting is bumped, so one taken by a running build is not
52// cleared when that build ends; force is kept once set.
53func (s *Store) RequestSymbolIndex(repoID int64, force bool) error {
54 _, err := s.DB.Exec(`
55 INSERT INTO symbol_requests (repo_id, force) VALUES (?, ?)
56 ON CONFLICT (repo_id) DO UPDATE SET seq = seq + 1,
57 force = MAX(force, excluded.force),
58 requested_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, repoID, force)
59 return err
60}
61
62// SymbolRequests lists the waiting repositories, oldest request first.
63func (s *Store) SymbolRequests() ([]SymbolRequest, error) {
64 rows, err := s.DB.Query(`SELECT repo_id, seq, force FROM symbol_requests ORDER BY requested_at, repo_id`)
65 if err != nil {
66 return nil, err
67 }
68 defer rows.Close()
69 var out []SymbolRequest
70 for rows.Next() {
71 var r SymbolRequest
72 if err := rows.Scan(&r.RepoID, &r.Seq, &r.Force); err != nil {
73 return nil, err
74 }
75 out = append(out, r)
76 }
77 return out, rows.Err()
78}
79
80// DoneSymbolRequest clears a request the worker has handled, unless it was
81// requested again since it was read.
82func (s *Store) DoneSymbolRequest(r SymbolRequest) error {
83 _, err := s.DB.Exec("DELETE FROM symbol_requests WHERE repo_id = ? AND seq = ?", r.RepoID, r.Seq)
84 return err
85}
86
87// SymbolIndexFor returns a repository's index, or ErrNotFound when none
88// has been built.
89func (s *Store) SymbolIndexFor(repoID int64) (SymbolIndex, error) {
90 var x SymbolIndex
91 err := s.DB.QueryRow(`
92 SELECT id, repo_id, commit_sha, tree, state, note, files, symbols, built_at
93 FROM symbol_indexes WHERE repo_id = ?`, repoID).
94 Scan(&x.ID, &x.RepoID, &x.Commit, &x.Tree, &x.State, &x.Note, &x.Files, &x.Symbols, &x.BuiltAt)
95 if errors.Is(err, sql.ErrNoRows) {
96 return x, ErrNotFound
97 }
98 return x, err
99}
100
101// symbolInsertRows is how many rows one INSERT carries: eight columns
102// each, well under SQLite's variable limit.
103const symbolInsertRows = 200
104
105// ReplaceSymbolIndex swaps a repository's index for a new one in one
106// transaction: readers see the old index or the new, never a mix. The
107// symbols count is taken from syms.
108func (s *Store) ReplaceSymbolIndex(x SymbolIndex, syms []SymbolRow) (int64, error) {
109 tx, err := s.DB.Begin()
110 if err != nil {
111 return 0, err
112 }
113 defer tx.Rollback()
114 if _, err := tx.Exec("DELETE FROM symbol_indexes WHERE repo_id = ?", x.RepoID); err != nil {
115 return 0, err
116 }
117 res, err := tx.Exec(`
118 INSERT INTO symbol_indexes (repo_id, commit_sha, tree, state, note, files, symbols)
119 VALUES (?, ?, ?, ?, ?, ?, ?)`, x.RepoID, x.Commit, x.Tree, x.State, x.Note, x.Files, len(syms))
120 if err != nil {
121 return 0, err
122 }
123 id, err := res.LastInsertId()
124 if err != nil {
125 return 0, err
126 }
127 for len(syms) > 0 {
128 n := min(len(syms), symbolInsertRows)
129 var q strings.Builder
130 q.WriteString("INSERT INTO symbols (index_id, name, lname, key, lkey, kind, path, line) VALUES ")
131 args := make([]any, 0, n*8)
132 for i, r := range syms[:n] {
133 if i > 0 {
134 q.WriteString(",")
135 }
136 q.WriteString("(?,?,?,?,?,?,?,?)")
137 args = append(args, id, r.Name, strings.ToLower(r.Name), r.Key, strings.ToLower(r.Key), r.Kind, r.Path, r.Line)
138 }
139 if _, err := tx.Exec(q.String(), args...); err != nil {
140 return 0, err
141 }
142 syms = syms[n:]
143 }
144 return id, tx.Commit()
145}
146
147// SearchSymbols finds the symbols whose name or key starts with q, ignoring
148// case, ranked: an exact match first, then a prefix match, each
149// case-sensitive before case-insensitive, then by name, path and line.
150// after is the id of the last row of the previous page, 0 for the first;
151// an id that is no longer in the result gives an empty page. kind filters
152// when not empty.
153func (s *Store) SearchSymbols(indexID int64, q, kind string, limit int, after int64) ([]SymbolRow, error) {
154 lq := strings.ToLower(q)
155 hi := lq + "\U0010FFFF"
156 n := utf8.RuneCountInString(q)
157 lim := -1
158 if limit > 0 {
159 lim = limit
160 }
161 rows, err := s.DB.Query(`
162 WITH m AS (
163 SELECT id, name, key, kind, path, line,
164 CASE WHEN name = ? OR key = ? THEN 0
165 WHEN substr(name, 1, ?) = ? OR substr(key, 1, ?) = ? THEN 1
166 WHEN lname = ? OR lkey = ? THEN 2
167 ELSE 3 END AS rank
168 FROM symbols
169 WHERE index_id = ?
170 AND ((lname >= ? AND lname < ?) OR (lkey >= ? AND lkey < ?))
171 AND (? = '' OR kind = ?)
172 )
173 SELECT id, name, key, kind, path, line FROM m
174 WHERE ? = 0 OR (rank, name, path, line, id) >
175 (SELECT rank, name, path, line, id FROM m WHERE id = ?)
176 ORDER BY rank, name, path, line, id
177 LIMIT ?`,
178 q, q, n, q, n, q, lq, lq,
179 indexID, lq, hi, lq, hi, kind, kind,
180 after, after, lim)
181 if err != nil {
182 return nil, err
183 }
184 return scanSymbols(rows)
185}
186
187// SymbolsInFile lists one file's symbols in line order.
188func (s *Store) SymbolsInFile(indexID int64, path string) ([]SymbolRow, error) {
189 rows, err := s.DB.Query(`
190 SELECT id, name, key, kind, path, line FROM symbols
191 WHERE index_id = ? AND path = ? ORDER BY line, id`, indexID, path)
192 if err != nil {
193 return nil, err
194 }
195 return scanSymbols(rows)
196}
197
198func scanSymbols(rows *sql.Rows) ([]SymbolRow, error) {
199 defer rows.Close()
200 var out []SymbolRow
201 for rows.Next() {
202 var r SymbolRow
203 if err := rows.Scan(&r.ID, &r.Name, &r.Key, &r.Kind, &r.Path, &r.Line); err != nil {
204 return nil, err
205 }
206 out = append(out, r)
207 }
208 return out, rows.Err()
209}
210
211// symbolKeysPerQuery bounds the IN list of one SymbolTargets query.
212const symbolKeysPerQuery = 500
213
214// SymbolTargets looks up where each of keys is defined, case-sensitively.
215// A key with no definition is absent from the result.
216func (s *Store) SymbolTargets(indexID int64, keys []string) (map[string]SymbolTarget, error) {
217 out := map[string]SymbolTarget{}
218 for len(keys) > 0 {
219 n := min(len(keys), symbolKeysPerQuery)
220 args := make([]any, 0, n+1)
221 args = append(args, indexID)
222 for _, k := range keys[:n] {
223 args = append(args, k)
224 }
225 // SQLite takes the bare columns of an aggregate query with MIN
226 // from the row that holds the minimum: the first definition.
227 rows, err := s.DB.Query(`
228 SELECT key, COUNT(*), path, line, MIN(id) FROM symbols
229 WHERE index_id = ? AND key IN (?`+strings.Repeat(",?", n-1)+`)
230 GROUP BY key`, args...)
231 if err != nil {
232 return nil, err
233 }
234 for rows.Next() {
235 var k string
236 var t SymbolTarget
237 var id int64
238 if err := rows.Scan(&k, &t.Count, &t.Path, &t.Line, &id); err != nil {
239 rows.Close()
240 return nil, err
241 }
242 out[k] = t
243 }
244 rows.Close()
245 if err := rows.Err(); err != nil {
246 return nil, err
247 }
248 keys = keys[n:]
249 }
250 return out, nil
251}
internal/store/symbols_test.go added +93
@@ -0,0 +1,93 @@
1package store
2
3import (
4 "strings"
5 "testing"
6)
7
8func symbolFixture(t *testing.T) (*Store, int64) {
9 t.Helper()
10 s := open(t)
11 if err := s.MigrateUp(); err != nil {
12 t.Fatal(err)
13 }
14 uid, err := s.CreateUser("alice", false)
15 if err != nil {
16 t.Fatal(err)
17 }
18 repoID, err := s.CreateRepo("user", uid, "app", "public")
19 if err != nil {
20 t.Fatal(err)
21 }
22 idx, err := s.ReplaceSymbolIndex(SymbolIndex{RepoID: repoID, Commit: "c", Tree: "t", State: "ok"}, []SymbolRow{
23 {Name: "handlers", Key: "handlers", Kind: "var", Path: "b.go", Line: 1},
24 {Name: "Server.Handle", Key: "Handle", Kind: "method", Path: "a.go", Line: 9},
25 {Name: "Handle", Key: "Handle", Kind: "function", Path: "z.go", Line: 3},
26 {Name: "handle", Key: "handle", Kind: "function", Path: "c.go", Line: 2},
27 {Name: "HandleFunc", Key: "HandleFunc", Kind: "function", Path: "a.go", Line: 20},
28 {Name: "Other", Key: "Other", Kind: "type", Path: "a.go", Line: 30},
29 })
30 if err != nil {
31 t.Fatal(err)
32 }
33 return s, idx
34}
35
36func symbolNames(rows []SymbolRow) string {
37 var out []string
38 for _, r := range rows {
39 out = append(out, r.Name)
40 }
41 return strings.Join(out, " ")
42}
43
44// Exact before prefix, case-sensitive before not, then by name; a method
45// matches on the name it is called by as well as Type.Method.
46func TestSearchSymbolsRanks(t *testing.T) {
47 s, idx := symbolFixture(t)
48 rows, err := s.SearchSymbols(idx, "Handle", "", 0, 0)
49 if err != nil {
50 t.Fatal(err)
51 }
52 if got, want := symbolNames(rows), "Handle Server.Handle HandleFunc handle handlers"; got != want {
53 t.Fatalf("got %q, want %q", got, want)
54 }
55 rows, _ = s.SearchSymbols(idx, "Handle", "method", 0, 0)
56 if got := symbolNames(rows); got != "Server.Handle" {
57 t.Fatalf("kind filter: %q", got)
58 }
59 rows, _ = s.SearchSymbols(idx, "server.", "", 0, 0)
60 if got := symbolNames(rows); got != "Server.Handle" {
61 t.Fatalf("Type. prefix: %q", got)
62 }
63
64 // Paging walks the same order without repeats.
65 var paged []SymbolRow
66 var after int64
67 for {
68 page, err := s.SearchSymbols(idx, "handle", "", 2, after)
69 if err != nil {
70 t.Fatal(err)
71 }
72 if len(page) == 0 {
73 break
74 }
75 paged = append(paged, page...)
76 after = page[len(page)-1].ID
77 }
78 all, _ := s.SearchSymbols(idx, "handle", "", 0, 0)
79 if symbolNames(paged) != symbolNames(all) || len(all) != 5 {
80 t.Fatalf("paged %q, all %q", symbolNames(paged), symbolNames(all))
81 }
82}
83
84func TestSymbolTargets(t *testing.T) {
85 s, idx := symbolFixture(t)
86 got, err := s.SymbolTargets(idx, []string{"Handle", "Other", "missing"})
87 if err != nil {
88 t.Fatal(err)
89 }
90 if len(got) != 2 || got["Handle"].Count != 2 || got["Other"] != (SymbolTarget{1, "a.go", 30}) {
91 t.Fatalf("targets = %+v", got)
92 }
93}
internal/symbols/extract.go added +342
@@ -0,0 +1,342 @@
1// Package symbols finds definitions in source files and keeps one index of
2// them per repository, for `repo symbols` and the blob view's links from a
3// name to where it is defined.
4//
5// The indexer is pure Go, with no external tagger. Go is parsed with
6// go/parser; the other languages are matched a line at a time against a
7// small table of anchored patterns, which finds the common definition
8// shapes and misses the unusual ones.
9//
10// Go function, method (named Type.Method), type, const, var
11// Swift function, class, struct, enum, interface (protocol), type (typealias)
12// Rust function, struct, enum, interface (trait), type, module, const, macro
13// Python function, method (an indented def), class
14// JavaScript, TS function, class, interface, type, enum, const
15// C and C++ headers function (prototype), struct, enum, class, type (typedef), macro (#define)
16// Shell function
17// Org, Markdown section (a heading)
18//
19// The index covers the tree of the default branch's head and is keyed by
20// that tree's id, so a push that leaves the tree as it was is not indexed
21// again. Worker builds it in the background after a push to the default
22// branch.
23package symbols
24
25import (
26 "bytes"
27 "go/ast"
28 "go/parser"
29 "go/token"
30 "path"
31 "regexp"
32 "strings"
33)
34
35// Kinds are the symbol kinds the indexer records, in the order they are
36// listed.
37var Kinds = []string{
38 "function", "method", "class", "struct", "enum", "interface",
39 "type", "const", "var", "module", "macro", "section",
40}
41
42// ValidKind reports whether k is one of Kinds.
43func ValidKind(k string) bool {
44 for _, v := range Kinds {
45 if v == k {
46 return true
47 }
48 }
49 return false
50}
51
52// Symbol is one definition in one file. Name is what is listed; Key is the
53// name as it is written where the symbol is used, which differs from Name
54// only for Go methods (Name "Server.Handle", Key "Handle").
55type Symbol struct {
56 Name string
57 Key string
58 Kind string
59 Line int
60}
61
62// MaxFileBytes is the largest file the indexer reads.
63const MaxFileBytes = 1 << 20
64
65// Skip reports whether a file is left out of the index before it is read:
66// too large, vendored, generated by its name, or in a language the indexer
67// does not know.
68func Skip(p string, size int64) bool {
69 if size > MaxFileBytes || Language(p) == "" {
70 return true
71 }
72 for _, seg := range strings.Split(path.Dir(p), "/") {
73 if seg == "vendor" || seg == "node_modules" {
74 return true
75 }
76 }
77 base := path.Base(p)
78 return strings.HasSuffix(base, "_gen.go") || strings.HasSuffix(base, ".pb.go") ||
79 strings.HasSuffix(base, ".min.js")
80}
81
82var languages = map[string]string{
83 ".go": "go",
84 ".swift": "swift",
85 ".rs": "rust",
86 ".py": "python",
87 ".js": "js",
88 ".mjs": "js",
89 ".cjs": "js",
90 ".jsx": "js",
91 ".ts": "js",
92 ".tsx": "js",
93 ".mts": "js",
94 ".h": "c",
95 ".hh": "c",
96 ".hpp": "c",
97 ".hxx": "c",
98 ".sh": "shell",
99 ".bash": "shell",
100 ".zsh": "shell",
101 ".org": "org",
102 ".md": "markdown",
103 ".markdown": "markdown",
104}
105
106// Language names the indexer's language for a path, or "" when it has
107// none.
108func Language(p string) string {
109 return languages[strings.ToLower(path.Ext(p))]
110}
111
112// generatedGo is the marker `go generate` tools write, per the Go
113// convention for generated files.
114var generatedGo = regexp.MustCompile(`(?m)^// Code generated .* DO NOT EDIT\.$`)
115
116// Extract returns the definitions in one file, in line order.
117func Extract(p string, data []byte) []Symbol {
118 switch lang := Language(p); lang {
119 case "":
120 return nil
121 case "go":
122 return extractGo(data)
123 case "org", "markdown":
124 return extractHeadings(lang, data)
125 default:
126 return extractLines(rules[lang], data)
127 }
128}
129
130func extractGo(data []byte) []Symbol {
131 head := data
132 if len(head) > 4096 {
133 head = head[:4096]
134 }
135 if generatedGo.Match(head) {
136 return nil
137 }
138 fset := token.NewFileSet()
139 // A file that does not parse still yields the declarations before the
140 // error.
141 f, _ := parser.ParseFile(fset, "", data, parser.SkipObjectResolution)
142 if f == nil {
143 return nil
144 }
145 var out []Symbol
146 add := func(id *ast.Ident, name, kind string) {
147 if id == nil || id.Name == "_" {
148 return
149 }
150 out = append(out, Symbol{Name: name, Key: id.Name, Kind: kind, Line: fset.Position(id.Pos()).Line})
151 }
152 for _, d := range f.Decls {
153 switch d := d.(type) {
154 case *ast.FuncDecl:
155 if d.Recv == nil || len(d.Recv.List) == 0 {
156 add(d.Name, d.Name.Name, "function")
157 continue
158 }
159 recv := receiverName(d.Recv.List[0].Type)
160 if recv == "" {
161 add(d.Name, d.Name.Name, "method")
162 continue
163 }
164 add(d.Name, recv+"."+d.Name.Name, "method")
165 case *ast.GenDecl:
166 for _, spec := range d.Specs {
167 switch s := spec.(type) {
168 case *ast.TypeSpec:
169 add(s.Name, s.Name.Name, "type")
170 case *ast.ValueSpec:
171 kind := "var"
172 if d.Tok == token.CONST {
173 kind = "const"
174 }
175 for _, n := range s.Names {
176 add(n, n.Name, kind)
177 }
178 }
179 }
180 }
181 }
182 return out
183}
184
185// receiverName is the type a method is declared on, without a pointer or
186// type parameters.
187func receiverName(e ast.Expr) string {
188 for {
189 switch t := e.(type) {
190 case *ast.StarExpr:
191 e = t.X
192 case *ast.ParenExpr:
193 e = t.X
194 case *ast.IndexExpr:
195 e = t.X
196 case *ast.IndexListExpr:
197 e = t.X
198 case *ast.Ident:
199 return t.Name
200 default:
201 return ""
202 }
203 }
204}
205
206// rule is one line pattern: the first submatch is the name.
207type rule struct {
208 re *regexp.Regexp
209 kind string
210}
211
212func r(kind, pattern string) rule { return rule{regexp.MustCompile(pattern), kind} }
213
214// ident is a name in the languages the line rules cover.
215const ident = `([A-Za-z_$][A-Za-z0-9_$]*)`
216
217var rules = map[string][]rule{
218 "swift": {
219 r("function", `^\s*(?:@\w+\s+)*(?:(?:public|private|fileprivate|internal|open|static|class|final|override|mutating|nonisolated|async|convenience|required)\s+)*func\s+`+ident),
220 r("class", `^\s*(?:@\w+\s+)*(?:(?:public|private|fileprivate|internal|open|final)\s+)*(?:class|actor)\s+`+ident),
221 r("struct", `^\s*(?:@\w+\s+)*(?:(?:public|private|fileprivate|internal)\s+)*struct\s+`+ident),
222 r("enum", `^\s*(?:@\w+\s+)*(?:(?:public|private|fileprivate|internal|indirect)\s+)*enum\s+`+ident),
223 r("interface", `^\s*(?:@\w+\s+)*(?:(?:public|private|fileprivate|internal)\s+)*protocol\s+`+ident),
224 r("type", `^\s*(?:(?:public|private|fileprivate|internal)\s+)*typealias\s+`+ident),
225 },
226 "rust": {
227 r("function", `^\s*(?:pub(?:\([^)]*\))?\s+)?(?:(?:const|async|unsafe|extern(?:\s+"[^"]*")?)\s+)*fn\s+`+ident),
228 r("struct", `^\s*(?:pub(?:\([^)]*\))?\s+)?struct\s+`+ident),
229 r("enum", `^\s*(?:pub(?:\([^)]*\))?\s+)?enum\s+`+ident),
230 r("interface", `^\s*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?trait\s+`+ident),
231 r("type", `^\s*(?:pub(?:\([^)]*\))?\s+)?type\s+`+ident),
232 r("module", `^\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+`+ident),
233 r("const", `^\s*(?:pub(?:\([^)]*\))?\s+)?(?:const|static(?:\s+mut)?)\s+`+ident+`\s*:`),
234 r("macro", `^\s*macro_rules!\s+`+ident),
235 },
236 "python": {
237 r("function", `^(?:async\s+)?def\s+`+ident),
238 r("method", `^\s+(?:async\s+)?def\s+`+ident),
239 r("class", `^\s*class\s+`+ident),
240 },
241 "js": {
242 r("function", `^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*`+ident),
243 r("class", `^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+`+ident),
244 r("interface", `^\s*(?:export\s+)?(?:declare\s+)?interface\s+`+ident),
245 r("type", `^\s*(?:export\s+)?(?:declare\s+)?type\s+`+ident+`\s*(?:<[^=]*>)?\s*=`),
246 r("enum", `^\s*(?:export\s+)?(?:declare\s+)?(?:const\s+)?enum\s+`+ident),
247 r("const", `^(?:export\s+)?const\s+`+ident+`\s*(?::[^=]*)?=`),
248 },
249 "c": {
250 r("macro", `^\s*#\s*define\s+`+ident),
251 r("struct", `^\s*(?:typedef\s+)?struct\s+`+ident+`\s*\{`),
252 r("enum", `^\s*(?:typedef\s+)?enum\s+(?:class\s+)?`+ident+`\s*(?::[^{]*)?\{`),
253 r("class", `^\s*class\s+`+ident+`\s*(?::[^{;]*)?\{`),
254 r("type", `^\s*typedef\s+[^;(]*?\b`+ident+`\s*;`),
255 r("type", `^\s*}\s*`+ident+`\s*;`),
256 r("function", `^[A-Za-z_][\w\s\*&:<>,]*?[\s\*&]`+ident+`\s*\([^;{]*\)\s*(?:const\s*)?;`),
257 },
258 "shell": {
259 r("function", `^\s*function\s+([A-Za-z_][A-Za-z0-9_:.-]*)`),
260 r("function", `^\s*([A-Za-z_][A-Za-z0-9_:.-]*)\s*\(\)\s*(?:\{|$)`),
261 },
262}
263
264// keywords are never names, though a rule can capture one: Swift's
265// `class var x` reads as a class named var, C's `if (x);` as a prototype.
266var keywords = map[string]bool{
267 "return": true, "if": true, "while": true, "for": true, "switch": true,
268 "sizeof": true, "else": true, "case": true, "do": true, "goto": true,
269 "var": true, "let": true, "func": true, "static": true,
270}
271
272func extractLines(rs []rule, data []byte) []Symbol {
273 var out []Symbol
274 line := 0
275 for len(data) > 0 {
276 line++
277 var text []byte
278 if i := bytes.IndexByte(data, '\n'); i >= 0 {
279 text, data = data[:i], data[i+1:]
280 } else {
281 text, data = data, nil
282 }
283 if len(text) > 1000 {
284 continue
285 }
286 for _, ru := range rs {
287 m := ru.re.FindSubmatch(text)
288 if m == nil {
289 continue
290 }
291 name := string(m[1])
292 if keywords[name] {
293 continue
294 }
295 out = append(out, Symbol{Name: name, Key: name, Kind: ru.kind, Line: line})
296 break
297 }
298 }
299 return out
300}
301
302var (
303 mdHeading = regexp.MustCompile(`^#{1,6}\s+(.+?)\s*#*\s*$`)
304 orgHeading = regexp.MustCompile(`^\*+\s+(.+?)\s*$`)
305)
306
307// extractHeadings lists headings as sections, skipping what sits inside a
308// code block, where a line starting with # or * is code.
309func extractHeadings(lang string, data []byte) []Symbol {
310 var out []Symbol
311 inBlock := false
312 for i, text := range strings.Split(string(data), "\n") {
313 trimmed := strings.TrimSpace(text)
314 if lang == "markdown" {
315 if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
316 inBlock = !inBlock
317 continue
318 }
319 } else {
320 lower := strings.ToLower(trimmed)
321 if strings.HasPrefix(lower, "#+begin_") {
322 inBlock = true
323 continue
324 }
325 if strings.HasPrefix(lower, "#+end_") {
326 inBlock = false
327 continue
328 }
329 }
330 if inBlock || len(text) > 1000 {
331 continue
332 }
333 re := mdHeading
334 if lang == "org" {
335 re = orgHeading
336 }
337 if m := re.FindStringSubmatch(text); m != nil {
338 out = append(out, Symbol{Name: m[1], Key: m[1], Kind: "section", Line: i + 1})
339 }
340 }
341 return out
342}
internal/symbols/extract_test.go added +187
@@ -0,0 +1,187 @@
1package symbols
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7)
8
9// got renders symbols as "kind name key line", one per entry, key left
10// out when it equals the name.
11func got(syms []Symbol) []string {
12 var out []string
13 for _, s := range syms {
14 if s.Key != s.Name {
15 out = append(out, fmt.Sprintf("%s %s %s %d", s.Kind, s.Name, s.Key, s.Line))
16 continue
17 }
18 out = append(out, fmt.Sprintf("%s %s %d", s.Kind, s.Name, s.Line))
19 }
20 return out
21}
22
23func TestExtract(t *testing.T) {
24 tests := []struct {
25 path string
26 src string
27 want []string
28 }{
29 {"a.go", `package a
30
31// Doc.
32func Top() {}
33
34type Server struct{}
35
36type (
37 List[T any] struct{}
38 ID int
39)
40
41func (s *Server) Handle() {}
42func (l List[T]) Push(v T) {}
43func (List[T]) Len() int { return 0 }
44
45const Max = 3
46const (
47 A, B = 1, 2
48 _ = 9
49)
50
51var global int
52`, []string{
53 "function Top 4", "type Server 6", "type List 9", "type ID 10",
54 "method Server.Handle Handle 13", "method List.Push Push 14", "method List.Len Len 15",
55 "const Max 17", "const A 19", "const B 19", "var global 23",
56 }},
57 {"broken.go", "package a\n\nfunc Before() {}\n\nfunc (\n", []string{"function Before 3"}},
58 {"gen.go", "// Code generated by stringer; DO NOT EDIT.\n\npackage a\n\nfunc X() {}\n", nil},
59 {"a.swift", `import Foundation
60
61public final class Store {
62 static func make() -> Store { Store() }
63 class var shared: Store { make() }
64}
65struct Point { var x: Int }
66enum Mode { case a }
67protocol Drawable {}
68actor Cache {}
69typealias Handler = () -> Void
70@MainActor func onMain() {}
71`, []string{
72 "class Store 3", "function make 4", "struct Point 7", "enum Mode 8",
73 "interface Drawable 9", "class Cache 10", "type Handler 11", "function onMain 12",
74 }},
75 {"lib.rs", `pub fn open() {}
76pub(crate) async fn fetch() {}
77struct Inner;
78pub enum Kind { A }
79pub trait Store {}
80type Result<T> = std::result::Result<T, Error>;
81mod tests {}
82pub const LIMIT: usize = 3;
83static mut COUNT: u32 = 0;
84macro_rules! bail {}
85impl Store for Inner {}
86`, []string{
87 "function open 1", "function fetch 2", "struct Inner 3", "enum Kind 4",
88 "interface Store 5", "type Result 6", "module tests 7", "const LIMIT 8",
89 "const COUNT 9", "macro bail 10",
90 }},
91 {"app.py", `import os
92
93def main():
94 pass
95
96class Client:
97 async def get(self):
98 return helper()
99
100async def run():
101 pass
102`, []string{"function main 3", "class Client 6", "method get 7", "function run 10"}},
103 {"web.ts", `export function render() {}
104export default async function load() {}
105function* gen() {}
106export class View {}
107export interface Props {}
108export type ID = string;
109export type Map<K, V> = Record<K, V>;
110export const enum Color { Red }
111export const LIMIT = 10;
112const handler = () => {};
113 const inner = 1;
114`, []string{
115 "function render 1", "function load 2", "function gen 3", "class View 4",
116 "interface Props 5", "type ID 6", "type Map 7", "enum Color 8",
117 "const LIMIT 9", "const handler 10",
118 }},
119 {"api.h", `#ifndef API_H
120#define API_H
121#define MAX_LEN 64
122struct buffer {
123 int len;
124};
125typedef struct buffer buffer_t;
126typedef enum mode { MODE_A } mode_t;
127int buffer_open(const char *path);
128static inline void *buffer_data(buffer_t *b);
129typedef struct {
130 int x;
131} point;
132#endif
133`, []string{
134 "macro API_H 2", "macro MAX_LEN 3", "struct buffer 4", "type buffer_t 7",
135 "enum mode 8", "function buffer_open 9", "function buffer_data 10", "type point 13",
136 }},
137 {"deploy.sh", `#!/bin/sh
138build() {
139 go build ./...
140}
141function release {
142 build
143}
144ship_it ()
145{
146 release
147}
148`, []string{"function build 2", "function release 5", "function ship_it 8"}},
149 {"README.md", "# Title\n\nText\n\n```sh\n# not a heading\n```\n\n## Usage ##\n", []string{"section Title 1", "section Usage 9"}},
150 {"notes.org", "* Top\n#+begin_src sh\n* not a heading\n#+end_src\n** Child heading\n", []string{"section Top 1", "section Child heading 5"}},
151 {"data.json", `{"a": 1}`, nil},
152 }
153 for _, tc := range tests {
154 t.Run(tc.path, func(t *testing.T) {
155 g := got(Extract(tc.path, []byte(tc.src)))
156 if strings.Join(g, "\n") != strings.Join(tc.want, "\n") {
157 t.Errorf("got:\n%s\nwant:\n%s", strings.Join(g, "\n"), strings.Join(tc.want, "\n"))
158 }
159 })
160 }
161}
162
163func TestSkip(t *testing.T) {
164 tests := []struct {
165 path string
166 size int64
167 skip bool
168 }{
169 {"main.go", 100, false},
170 {"main.go", MaxFileBytes + 1, true},
171 {"main.go", MaxFileBytes, false},
172 {"vendor/x/y.go", 10, true},
173 {"web/node_modules/a/index.js", 10, true},
174 {"internal/vendors/y.go", 10, false},
175 {"zz_gen.go", 10, true},
176 {"api.pb.go", 10, true},
177 {"static/app.min.js", 10, true},
178 {"static/app.js", 10, false},
179 {"image.png", 10, true},
180 {"Makefile", 10, true},
181 }
182 for _, tc := range tests {
183 if got := Skip(tc.path, tc.size); got != tc.skip {
184 t.Errorf("Skip(%q, %d) = %v, want %v", tc.path, tc.size, got, tc.skip)
185 }
186 }
187}
internal/symbols/worker.go added +175
@@ -0,0 +1,175 @@
1package symbols
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "time"
10
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// Bounds on one index run.
16const (
17 // DefaultMaxSymbols is where a repository's index stops growing.
18 DefaultMaxSymbols = 200_000
19 // DefaultMaxTime is how long one run may take.
20 DefaultMaxTime = 2 * time.Minute
21)
22
23// Worker builds the index for each repository that has asked for one:
24// post-receive and the merge path ask after the default branch moves, and
25// `admin symbols reindex` asks with force. One repository at a time.
26type Worker struct {
27 St *store.Store
28 RepoDir func(owner, name string) string
29 Tick time.Duration
30 MaxSymbols int
31 MaxTime time.Duration
32}
33
34func New(st *store.Store, repoDir func(owner, name string) string) *Worker {
35 tick := 5 * time.Second
36 if v := os.Getenv("GITBAY_SYMBOLS_TICK"); v != "" {
37 if d, err := time.ParseDuration(v); err == nil {
38 tick = d
39 }
40 }
41 return &Worker{St: st, RepoDir: repoDir, Tick: tick,
42 MaxSymbols: DefaultMaxSymbols, MaxTime: DefaultMaxTime}
43}
44
45// Run sweeps until ctx ends.
46func (w *Worker) Run(ctx context.Context) {
47 t := time.NewTicker(w.Tick)
48 defer t.Stop()
49 for {
50 select {
51 case <-ctx.Done():
52 return
53 case <-t.C:
54 w.Sweep(ctx)
55 }
56 }
57}
58
59// Sweep handles every waiting request once. A request is cleared whatever
60// the outcome, so a repository whose index fails is not retried until
61// it is asked for again.
62func (w *Worker) Sweep(ctx context.Context) {
63 reqs, err := w.St.SymbolRequests()
64 if err != nil {
65 slog.Error("symbols: listing requests", "err", err)
66 return
67 }
68 for _, req := range reqs {
69 if ctx.Err() != nil {
70 return
71 }
72 err := w.Index(ctx, req.RepoID, req.Force)
73 if ctx.Err() != nil {
74 return // shutting down: the request stays for the next start
75 }
76 if err != nil {
77 slog.Warn("symbols: indexing", "repo", req.RepoID, "err", err)
78 }
79 w.St.DoneSymbolRequest(req)
80 }
81}
82
83// Index brings one repository's index up to its default branch's head.
84// A head whose tree is already indexed is left alone unless force is set.
85// The error is for the log: an index that could not be built is recorded
86// as failed, and one cut short by a bound as partial.
87func (w *Worker) Index(ctx context.Context, repoID int64, force bool) error {
88 repo, err := w.St.RepoByID(repoID)
89 if errors.Is(err, store.ErrNotFound) {
90 return nil
91 } else if err != nil {
92 return err
93 }
94 dir := w.RepoDir(repo.OwnerName, repo.Name)
95 commit, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
96 if err != nil {
97 return nil // no default branch yet: nothing to index
98 }
99 tree, err := gitutil.ResolveTree(dir, commit)
100 if err != nil {
101 return err
102 }
103 if cur, err := w.St.SymbolIndexFor(repo.ID); err == nil && cur.Tree == tree && !force {
104 return nil
105 }
106 x := store.SymbolIndex{RepoID: repo.ID, Commit: commit, Tree: tree, State: "ok"}
107 syms, files, runErr := w.collect(ctx, dir, tree)
108 if ctx.Err() != nil {
109 return ctx.Err()
110 }
111 x.Files = files
112 switch {
113 case errors.Is(runErr, errSymbolCap):
114 x.State, x.Note = "partial", fmt.Sprintf("stopped at %d symbols", w.MaxSymbols)
115 case errors.Is(runErr, context.DeadlineExceeded):
116 x.State, x.Note = "partial", fmt.Sprintf("stopped after %s", w.MaxTime)
117 case runErr != nil:
118 x.State, x.Note, syms = "failed", runErr.Error(), nil
119 }
120 if _, err := w.St.ReplaceSymbolIndex(x, syms); err != nil {
121 return err
122 }
123 if x.State != "ok" {
124 return fmt.Errorf("%s %s: %s", repo.Path(), x.State, x.Note)
125 }
126 return nil
127}
128
129var errSymbolCap = errors.New("symbol cap reached")
130
131// collect reads every indexable blob in tree and extracts its symbols,
132// stopping at the symbol cap or the time bound with what it has.
133func (w *Worker) collect(ctx context.Context, dir, tree string) ([]store.SymbolRow, int, error) {
134 ctx, cancel := context.WithTimeout(ctx, w.MaxTime)
135 defer cancel()
136 blobs, err := gitutil.ListBlobs(ctx, dir, tree)
137 if err != nil {
138 if ctx.Err() != nil {
139 return nil, 0, ctx.Err()
140 }
141 return nil, 0, err
142 }
143 var paths, shas []string
144 for _, b := range blobs {
145 if b.Mode == "120000" || b.Mode == "160000" || Skip(b.Name, b.Size) {
146 continue
147 }
148 paths = append(paths, b.Name)
149 shas = append(shas, b.SHA)
150 }
151 var out []store.SymbolRow
152 files := 0
153 capped := false
154 err = gitutil.CatBlobs(ctx, dir, shas, func(i int, data []byte) bool {
155 if gitutil.IsBinary(data) {
156 return true
157 }
158 files++
159 for _, s := range Extract(paths[i], data) {
160 if len(out) >= w.MaxSymbols {
161 capped = true
162 return false
163 }
164 out = append(out, store.SymbolRow{Name: s.Name, Key: s.Key, Kind: s.Kind, Path: paths[i], Line: s.Line})
165 }
166 return true
167 })
168 if capped {
169 return out, files, errSymbolCap
170 }
171 if err != nil && ctx.Err() == nil {
172 return nil, files, err
173 }
174 return out, files, err
175}
internal/symbols/worker_test.go added +234
@@ -0,0 +1,234 @@
1package symbols
2
3import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/store"
13)
14
15type fixture struct {
16 t *testing.T
17 st *store.Store
18 repo store.Repo
19 src string
20 bare string
21 w *Worker
22}
23
24func newFixture(t *testing.T) *fixture {
25 t.Helper()
26 st, err := store.Open(":memory:")
27 if err != nil {
28 t.Fatal(err)
29 }
30 t.Cleanup(func() { st.Close() })
31 if err := st.MigrateUp(); err != nil {
32 t.Fatal(err)
33 }
34 uid, err := st.CreateUser("alice", false)
35 if err != nil {
36 t.Fatal(err)
37 }
38 id, err := st.CreateRepo("user", uid, "app", "public")
39 if err != nil {
40 t.Fatal(err)
41 }
42 repo, err := st.RepoByID(id)
43 if err != nil {
44 t.Fatal(err)
45 }
46 root := t.TempDir()
47 f := &fixture{t: t, st: st, repo: repo, src: filepath.Join(root, "src"), bare: filepath.Join(root, "app.git")}
48 f.w = &Worker{St: st, RepoDir: func(owner, name string) string { return f.bare },
49 MaxSymbols: DefaultMaxSymbols, MaxTime: DefaultMaxTime}
50 f.git(root, "init", "-q", "-b", repo.DefaultBranch, "src")
51 f.git(root, "init", "-q", "--bare", f.bare)
52 return f
53}
54
55func (f *fixture) git(dir string, args ...string) string {
56 f.t.Helper()
57 cmd := exec.Command("git", args...)
58 cmd.Dir = dir
59 cmd.Env = append(os.Environ(),
60 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
61 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
62 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
63 out, err := cmd.CombinedOutput()
64 if err != nil {
65 f.t.Fatalf("git %v: %v\n%s", args, err, out)
66 }
67 return strings.TrimSpace(string(out))
68}
69
70// commit writes files (an empty content removes one) and pushes the
71// default branch to the bare repository.
72func (f *fixture) commit(files map[string]string) string {
73 f.t.Helper()
74 for name, content := range files {
75 p := filepath.Join(f.src, name)
76 if content == "" {
77 os.Remove(p)
78 continue
79 }
80 os.MkdirAll(filepath.Dir(p), 0o755)
81 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
82 f.t.Fatal(err)
83 }
84 }
85 f.git(f.src, "add", "-A")
86 f.git(f.src, "commit", "-q", "--allow-empty", "-m", "c")
87 f.git(f.src, "push", "-q", "--force", f.bare, "HEAD:refs/heads/"+f.repo.DefaultBranch)
88 return f.git(f.src, "rev-parse", "HEAD")
89}
90
91func (f *fixture) sweep(force bool) store.SymbolIndex {
92 f.t.Helper()
93 if err := f.st.RequestSymbolIndex(f.repo.ID, force); err != nil {
94 f.t.Fatal(err)
95 }
96 f.w.Sweep(context.Background())
97 if reqs, _ := f.st.SymbolRequests(); len(reqs) != 0 {
98 f.t.Fatalf("request left after a sweep: %+v", reqs)
99 }
100 x, err := f.st.SymbolIndexFor(f.repo.ID)
101 if err != nil {
102 f.t.Fatal(err)
103 }
104 return x
105}
106
107func (f *fixture) names(x store.SymbolIndex) []string {
108 f.t.Helper()
109 rows, err := f.st.SearchSymbols(x.ID, "", "", 0, 0)
110 if err != nil {
111 f.t.Fatal(err)
112 }
113 var out []string
114 for _, r := range rows {
115 out = append(out, r.Path+":"+r.Name)
116 }
117 return out
118}
119
120func TestWorkerIndexesDefaultBranch(t *testing.T) {
121 f := newFixture(t)
122 head := f.commit(map[string]string{
123 "main.go": "package main\n\nfunc Hello() {}\n",
124 "vendor/dep/dep.go": "package dep\n\nfunc Vendored() {}\n",
125 "gen/zz_gen.go": "package gen\n\nfunc Generated() {}\n",
126 "big/big.go": "package big\n\nfunc Big() {}\n" + strings.Repeat("//\n", MaxFileBytes/3),
127 "docs/guide.md": "# Guide\n",
128 "assets/logo.png": "\x89PNG\r\n",
129 "node_modules/x.js": "function hidden() {}\n",
130 "web/app.js": "function shown() {}\n",
131 })
132 x := f.sweep(false)
133 if x.State != "ok" || x.Commit != head {
134 t.Fatalf("index = %+v, want ok at %s", x, head)
135 }
136 got := strings.Join(f.names(x), " ")
137 for _, want := range []string{"main.go:Hello", "docs/guide.md:Guide", "web/app.js:shown"} {
138 if !strings.Contains(got, want) {
139 t.Errorf("missing %s in %s", want, got)
140 }
141 }
142 for _, skip := range []string{"Vendored", "Generated", "Big", "hidden"} {
143 if strings.Contains(got, skip) {
144 t.Errorf("%s indexed: %s", skip, got)
145 }
146 }
147 if x.Files != 3 || x.Symbols != 3 {
148 t.Errorf("files %d symbols %d, want 3 and 3", x.Files, x.Symbols)
149 }
150}
151
152// A new commit whose tree is the indexed one is not indexed again; a
153// changed tree replaces the index, and force rebuilds an unchanged one.
154func TestWorkerKeysOnTree(t *testing.T) {
155 f := newFixture(t)
156 f.commit(map[string]string{"a.go": "package a\n\nfunc One() {}\n"})
157 first := f.sweep(false)
158
159 f.commit(nil) // same tree, new commit
160 same := f.sweep(false)
161 if same.ID != first.ID || same.Commit != first.Commit {
162 t.Fatalf("unchanged tree was reindexed: %+v then %+v", first, same)
163 }
164
165 f.commit(map[string]string{"a.go": "package a\n\nfunc Two() {}\n"})
166 changed := f.sweep(false)
167 if changed.ID == first.ID || changed.Tree == first.Tree {
168 t.Fatalf("changed tree kept the old index: %+v", changed)
169 }
170 if got := f.names(changed); len(got) != 1 || got[0] != "a.go:Two" {
171 t.Fatalf("symbols = %v, want only the new tree's", got)
172 }
173 var n int
174 f.st.DB.QueryRow("SELECT COUNT(*) FROM symbol_indexes WHERE repo_id = ?", f.repo.ID).Scan(&n)
175 if n != 1 {
176 t.Fatalf("%d indexes for one repository, want 1", n)
177 }
178
179 forced := f.sweep(true)
180 if forced.ID == changed.ID || forced.Tree != changed.Tree {
181 t.Fatalf("force did not rebuild: %+v then %+v", changed, forced)
182 }
183}
184
185func TestWorkerSymbolCap(t *testing.T) {
186 f := newFixture(t)
187 f.commit(map[string]string{"a.go": "package a\n\nfunc A() {}\nfunc B() {}\nfunc C() {}\n"})
188 f.w.MaxSymbols = 2
189 x := f.sweep(false)
190 if x.State != "partial" || x.Symbols != 2 || !strings.Contains(x.Note, "2 symbols") {
191 t.Fatalf("index = %+v, want partial with 2 symbols", x)
192 }
193}
194
195func TestWorkerTimeBound(t *testing.T) {
196 f := newFixture(t)
197 f.commit(map[string]string{"a.go": "package a\n\nfunc A() {}\n"})
198 f.w.MaxTime = time.Nanosecond
199 x := f.sweep(false)
200 if x.State != "partial" || !strings.Contains(x.Note, "stopped after") {
201 t.Fatalf("index = %+v, want partial on the time bound", x)
202 }
203}
204
205// A tree that cannot be read is recorded as failed, and the record stands
206// for that tree: asking again does not retry it.
207func TestWorkerFailureIsRecordedNotRetried(t *testing.T) {
208 f := newFixture(t)
209 f.commit(map[string]string{"a.go": "package a\n\nfunc A() {}\n"})
210 blob := f.git(f.bare, "rev-parse", "refs/heads/"+f.repo.DefaultBranch+":a.go")
211 if err := os.Remove(filepath.Join(f.bare, "objects", blob[:2], blob[2:])); err != nil {
212 t.Fatal(err)
213 }
214 x := f.sweep(false)
215 if x.State != "failed" || x.Note == "" || x.Symbols != 0 {
216 t.Fatalf("index = %+v, want failed with a note", x)
217 }
218 again := f.sweep(false)
219 if again.ID != x.ID {
220 t.Fatalf("a failed tree was retried: %+v then %+v", x, again)
221 }
222}
223
224func TestWorkerIgnoresEmptyRepository(t *testing.T) {
225 f := newFixture(t)
226 f.st.RequestSymbolIndex(f.repo.ID, false)
227 f.w.Sweep(context.Background())
228 if _, err := f.st.SymbolIndexFor(f.repo.ID); err != store.ErrNotFound {
229 t.Fatalf("an empty repository has an index: %v", err)
230 }
231 if reqs, _ := f.st.SymbolRequests(); len(reqs) != 0 {
232 t.Fatalf("request left: %+v", reqs)
233 }
234}