Commit fee1e1f177

fee1e1f1777795a692a4f4d3a0130855cb38fb16

parent: d2563ecad0

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-14 05:47 UTC

repo definitions: where the names a file uses are defined

For each name in the file that the symbol index defines: the first
definition and how many there are, the lookup the blob page links names
with. For a client that draws the file itself. Names are every
identifier in the file, since a command has no highlighter; keywords
fall out because nothing defines them.

Closes #333

Layout: unified · split

.gitbay/wiki/Parity.org +1 −1
@@ -189,7 +189,7 @@ there shows the app's rendering rather than the one the web page shows.
189189| blame | yes | yes | yes |
190190| search file contents | yes | yes | yes |
191191| find a definition (symbols) | yes | yes | no |
192| jump to definition from a file | n/a | yes | no |
192| jump to definition from a file | yes | yes | no |
193193| compare two refs | yes | yes | yes |
194194| branches and tags | yes | yes | yes |
195195| wiki (read) | yes | yes | yes |
.gitbay/wiki/Users.org +5
@@ -261,6 +261,11 @@ line; =--limit= and =--cursor= page it, and without them it stops at
261261ref only when its tree is the indexed one. The index follows read
262262access: a repository you cannot read has no symbols either.
263263
264=repo definitions <owner/name> <path>= goes the other way: for each
265name the file uses that the index defines, the first definition and how
266many there are. It is what the blob page links names from, for a client
267that draws the file itself.
268
264269What is indexed:
265270
266271| language | kinds |
cmd/gitbay/main.go +1
@@ -567,6 +567,7 @@ func repoCmd() *cobra.Command {
567567 pass("search", passOpts{server: []string{"repo", "search"}}),
568568 pass("grep", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
569569 pass("symbols", passOpts{server: []string{"repo", "symbols"}, needsRepo: true}),
570 pass("definitions", passOpts{server: []string{"repo", "definitions"}, needsRepo: true}),
570571 pass("diff", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
571572 pass("tree", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
572573 pass("cat", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
cmd/gitbay/summaries_gen.go +1
@@ -160,6 +160,7 @@ var summaries = map[string]string{
160160 "repo commit": "show one commit with its patch",
161161 "repo commit-file": "write a file and commit it",
162162 "repo create": "create a repository",
163 "repo definitions": "where each name a file uses is defined, from the symbol index",
163164 "repo delete": "delete a repository",
164165 "repo deploy-key add": "bind a read-only (or --rw) key to one repository",
165166 "repo deploy-key list": "list deploy keys",
e2e/readonly_test.go +1
@@ -132,6 +132,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
132132 "repo blame": {"alice/app", "f.go"},
133133 "repo grep": {"alice/app", "hello"},
134134 "repo symbols": {"alice/app", "app"},
135 "repo definitions": {"alice/app", "f.go"},
135136 "repo diff": {"alice/app", "main", "feat"},
136137 "repo commit": {"alice/app", sha},
137138 "repo download": {"alice/app"},
internal/control/definitions_test.go added +112
@@ -0,0 +1,112 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// definitionsFixture is a repository whose default branch holds use.go,
16// which calls names defined in lib.go, and a symbol index of that tree.
17func definitionsFixture(t *testing.T) (*store.Store, store.Repo, store.User, string) {
18 t.Helper()
19 st, repo, uid := newQueueTestRepo(t)
20 root := t.TempDir()
21 git := gitRunner(t)
22 src := filepath.Join(root, "src")
23 git(root, "init", "-q", "-b", "main", "src")
24 os.WriteFile(filepath.Join(src, "lib.go"), []byte("package app\n\nfunc Parse() {}\nfunc Run() {}\nfunc Run2() {}\n"), 0o644)
25 os.WriteFile(filepath.Join(src, "use.go"), []byte("package app\n\nfunc main() {\n\tParse()\n\tRun()\n\tunknown()\n}\n"), 0o644)
26 git(src, "add", ".")
27 git(src, "commit", "-q", "-m", "base")
28 dir := RepoDir(root, repo.OwnerName, repo.Name)
29 os.MkdirAll(filepath.Dir(dir), 0o755)
30 git(root, "clone", "-q", "--bare", src, dir)
31 head := strings.TrimSpace(git(dir, "rev-parse", "main"))
32 tree := strings.TrimSpace(git(dir, "rev-parse", "main^{tree}"))
33 rows := []store.SymbolRow{
34 {Name: "Parse", Key: "Parse", Kind: "function", Path: "lib.go", Line: 3},
35 {Name: "Run", Key: "Run", Kind: "function", Path: "lib.go", Line: 4},
36 {Name: "Server.Run", Key: "Run", Kind: "method", Path: "srv.go", Line: 9},
37 {Name: "main", Key: "main", Kind: "function", Path: "use.go", Line: 3},
38 }
39 if _, err := st.ReplaceSymbolIndex(store.SymbolIndex{RepoID: repo.ID, Commit: head, Tree: tree, State: "ok"}, rows); err != nil {
40 t.Fatal(err)
41 }
42 return st, repo, store.User{ID: uid, Username: "alice"}, root
43}
44
45func runDefinitions(t *testing.T, st *store.Store, u store.User, root string, argv ...string) (int, string) {
46 t.Helper()
47 var out, errOut bytes.Buffer
48 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: &out, Stderr: &errOut, JSON: true}
49 c.Cfg.Server.Root = root
50 c.Cfg.Limits.MaxBlobBytes = 1 << 20
51 code := Dispatch(c, argv)
52 return code, out.String() + errOut.String()
53}
54
55// repo definitions says where each name a file uses is defined: the
56// first definition and how many there are, so a client links a name
57// defined once to its line and one defined more often to a search, the
58// way the blob page does. Names the index does not hold are left out.
59func TestRepoDefinitionsListsTheNamesAFileUses(t *testing.T) {
60 st, repo, alice, root := definitionsFixture(t)
61
62 code, out := runDefinitions(t, st, alice, root, "repo", "definitions", repo.Path(), "use.go")
63 if code != protocol.ExitOK {
64 t.Fatalf("exit %d: %s", code, out)
65 }
66 var env struct {
67 Data []definitionOut `json:"data"`
68 }
69 if err := json.Unmarshal([]byte(out), &env); err != nil {
70 t.Fatal(err)
71 }
72 want := []definitionOut{
73 {Name: "Parse", Count: 1, Path: "lib.go", Line: 3},
74 {Name: "Run", Count: 2, Path: "lib.go", Line: 4},
75 {Name: "main", Count: 1, Path: "use.go", Line: 3},
76 }
77 if len(env.Data) != len(want) {
78 t.Fatalf("got %+v", env.Data)
79 }
80 for i := range want {
81 if env.Data[i] != want[i] {
82 t.Errorf("row %d: got %+v, want %+v", i, env.Data[i], want[i])
83 }
84 }
85}
86
87func TestRepoDefinitionsRefusals(t *testing.T) {
88 st, repo, alice, root := definitionsFixture(t)
89 for _, tc := range []struct {
90 argv []string
91 code int
92 want string
93 }{
94 {[]string{"repo", "definitions", repo.Path(), "nope.go"}, protocol.ExitNotFound, "no such file"},
95 {[]string{"repo", "definitions", repo.Path(), "../x"}, protocol.ExitUsage, "inside the repository"},
96 {[]string{"repo", "definitions", repo.Path(), "use.go", "--ref", "nope"}, protocol.ExitNotFound, "not at the indexed tree"},
97 } {
98 code, out := runDefinitions(t, st, alice, root, tc.argv...)
99 if code != tc.code || !strings.Contains(out, tc.want) {
100 t.Errorf("%v: exit %d, %s", tc.argv, code, out)
101 }
102 }
103}
104
105func TestRepoDefinitionsScreen(t *testing.T) {
106 st, repo, alice, root := definitionsFixture(t)
107 out := atTerminalIn(t, st, alice, root, "repo", "definitions", repo.Path(), "use.go")
108 if !strings.Contains(out, "Parse") || !strings.Contains(out, "lib.go:4") {
109 t.Fatalf("screen:\n%s", out)
110 }
111 checkLegend(t, out)
112}
internal/control/symbols.go +105
@@ -4,6 +4,8 @@ import (
44 "errors"
55 "fmt"
66 "io"
7 "regexp"
8 "sort"
79 "strconv"
810 "strings"
911
@@ -29,6 +31,14 @@ func init() {
2931 "repo symbols krz/gitbay --kind method Ctx.",
3032 },
3133 ReadOnly: true, Run: runRepoSymbols})
34 register(Command{Path: []string{"repo", "definitions"},
35 Summary: "where each name a file uses is defined, from the symbol index",
36 Usage: "repo definitions <owner/name> <path> [--ref <ref>]",
37 Flags: []Flag{
38 {"--ref", "<ref>", "a ref whose tree is the indexed one; only the default branch is indexed", "the default branch"},
39 },
40 Examples: []string{"repo definitions krz/gitbay internal/control/control.go"},
41 ReadOnly: true, Run: runRepoDefinitions})
3242 register(Command{Path: []string{"admin", "symbols", "reindex"},
3343 Summary: "rebuild a repository's symbol index, even when its tree is already indexed (instance admins)",
3444 Usage: "admin symbols reindex <owner/name>",
@@ -204,3 +214,98 @@ func runAdminSymbolsReindex(c *Ctx, args []string) int {
204214 fmt.Fprintf(w, "queued a rebuild of the symbol index of %s\n", repo.Path())
205215 })
206216}
217
218// definitionOut is one name a file uses and where it is defined: the
219// first definition, and Count of them. A client links a name defined once
220// to its line and one defined more often to repo symbols, as the blob page
221// does.
222type definitionOut struct {
223 Name string `json:"name"`
224 Count int `json:"count"`
225 Path string `json:"path"`
226 Line int `json:"line"`
227}
228
229// identifier is a name as the index keys it. The blob page takes names
230// from the highlighter's spans; a command has no highlighter, so it takes
231// every identifier, and keywords fall out because nothing defines them.
232var identifier = regexp.MustCompile(`[\p{L}_$][\p{L}\p{N}_$]*`)
233
234// maxDefinitionNames bounds the lookup for one file.
235const maxDefinitionNames = 5000
236
237func runRepoDefinitions(c *Ctx, args []string) int {
238 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: c.Cmd.Usage})
239 if err != nil {
240 return c.fail(protocol.ExitUsage, "%v", err)
241 }
242 path, file, ref := f.pos(0), f.pos(1), f.Value("--ref")
243 if path == "" || file == "" {
244 return c.usage()
245 }
246 filePath, ok := cleanRepoPath(file)
247 if !ok || filePath == "" {
248 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
249 }
250 repo, code := resolveRepo(c, path, policy.CanRead)
251 if code >= 0 {
252 return code
253 }
254 idx, err := c.Store.SymbolIndexFor(repo.ID)
255 if errors.Is(err, store.ErrNotFound) {
256 return c.fail(protocol.ExitNotFound, "%s has no symbol index yet; one is built after a push to %s", repo.Path(), repo.DefaultBranch)
257 } else if err != nil {
258 return c.fail(protocol.ExitFailure, "%v", err)
259 }
260 if ref == "" {
261 ref = repo.DefaultBranch
262 }
263 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
264 sha, err := gitutil.ResolveRef(dir, ref)
265 if err != nil || !IndexedTree(dir, sha, idx) {
266 return c.fail(protocol.ExitNotFound, "only the default branch, %s, is indexed; %s is not at the indexed tree", repo.DefaultBranch, ref)
267 }
268 data, err := gitutil.ReadBlob(dir, sha, filePath, c.Cfg.Limits.MaxBlobBytes)
269 if err != nil {
270 return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
271 }
272 var keys []string
273 if !gitutil.IsBinary(data) {
274 seen := map[string]bool{}
275 for _, name := range identifier.FindAllString(string(data), -1) {
276 if !seen[name] && len(keys) < maxDefinitionNames {
277 seen[name] = true
278 keys = append(keys, name)
279 }
280 }
281 }
282 targets, err := c.Store.SymbolTargets(idx.ID, keys)
283 if err != nil {
284 return c.fail(protocol.ExitFailure, "%v", err)
285 }
286 ds := make([]definitionOut, 0, len(targets))
287 for name, t := range targets {
288 ds = append(ds, definitionOut{name, t.Count, t.Path, t.Line})
289 }
290 sort.Slice(ds, func(i, j int) bool { return ds[i].Name < ds[j].Name })
291 rowsOf := func() []row {
292 rows := make([]row, len(ds))
293 for i, d := range ds {
294 rows[i] = rowOf(cRef(d.Name), cText(strconv.Itoa(d.Count)), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line)))
295 }
296 return rows
297 }
298 return c.emitView(ds, func(w io.Writer) {
299 tb := c.table(w, "NAME", "DEFINED", "FIRST")
300 for _, d := range ds {
301 tb.row(cRef(d.Name), cText(strconv.Itoa(d.Count)), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line)))
302 }
303 tb.flush()
304 }, func() screen {
305 s := listScreen("Definitions used in "+filePath, rowsOf())
306 if len(ds) > 0 {
307 s.actions = []action{{"Find", []string{"repo", "symbols", repo.Path(), ds[0].Name}}}
308 }
309 return s
310 })
311}