Commit fee1e1f177
Verified · cmc ci/build: success ci/test: success
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. | ||
| 189 | 189 | | blame | yes | yes | yes | |
| 190 | 190 | | search file contents | yes | yes | yes | |
| 191 | 191 | | 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 | | |
| 193 | 193 | | compare two refs | yes | yes | yes | |
| 194 | 194 | | branches and tags | yes | yes | yes | |
| 195 | 195 | | 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 | ||
| 261 | 261 | ref only when its tree is the indexed one. The index follows read |
| 262 | 262 | access: a repository you cannot read has no symbols either. |
| 263 | 263 | |
| 264 | =repo definitions <owner/name> <path>= goes the other way: for each | |
| 265 | name the file uses that the index defines, the first definition and how | |
| 266 | many there are. It is what the blob page links names from, for a client | |
| 267 | that draws the file itself. | |
| 268 | ||
| 264 | 269 | What is indexed: |
| 265 | 270 | |
| 266 | 271 | | language | kinds | |
cmd/gitbay/main.go +1
| @@ -567,6 +567,7 @@ func repoCmd() *cobra.Command { | ||
| 567 | 567 | pass("search", passOpts{server: []string{"repo", "search"}}), |
| 568 | 568 | pass("grep", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), |
| 569 | 569 | pass("symbols", passOpts{server: []string{"repo", "symbols"}, needsRepo: true}), |
| 570 | pass("definitions", passOpts{server: []string{"repo", "definitions"}, needsRepo: true}), | |
| 570 | 571 | pass("diff", passOpts{server: []string{"repo", "diff"}, needsRepo: true}), |
| 571 | 572 | pass("tree", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), |
| 572 | 573 | pass("cat", passOpts{server: []string{"repo", "cat"}, needsRepo: true}), |
cmd/gitbay/summaries_gen.go +1
| @@ -160,6 +160,7 @@ var summaries = map[string]string{ | ||
| 160 | 160 | "repo commit": "show one commit with its patch", |
| 161 | 161 | "repo commit-file": "write a file and commit it", |
| 162 | 162 | "repo create": "create a repository", |
| 163 | "repo definitions": "where each name a file uses is defined, from the symbol index", | |
| 163 | 164 | "repo delete": "delete a repository", |
| 164 | 165 | "repo deploy-key add": "bind a read-only (or --rw) key to one repository", |
| 165 | 166 | "repo deploy-key list": "list deploy keys", |
e2e/readonly_test.go +1
| @@ -132,6 +132,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | ||
| 132 | 132 | "repo blame": {"alice/app", "f.go"}, |
| 133 | 133 | "repo grep": {"alice/app", "hello"}, |
| 134 | 134 | "repo symbols": {"alice/app", "app"}, |
| 135 | "repo definitions": {"alice/app", "f.go"}, | |
| 135 | 136 | "repo diff": {"alice/app", "main", "feat"}, |
| 136 | 137 | "repo commit": {"alice/app", sha}, |
| 137 | 138 | "repo download": {"alice/app"}, |
internal/control/definitions_test.go added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 17 | func 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 | ||
| 45 | func 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. | |
| 59 | func 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 | ||
| 87 | func 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 | ||
| 105 | func 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 ( | ||
| 4 | 4 | "errors" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | "regexp" | |
| 8 | "sort" | |
| 7 | 9 | "strconv" |
| 8 | 10 | "strings" |
| 9 | 11 | |
| @@ -29,6 +31,14 @@ func init() { | ||
| 29 | 31 | "repo symbols krz/gitbay --kind method Ctx.", |
| 30 | 32 | }, |
| 31 | 33 | 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}) | |
| 32 | 42 | register(Command{Path: []string{"admin", "symbols", "reindex"}, |
| 33 | 43 | Summary: "rebuild a repository's symbol index, even when its tree is already indexed (instance admins)", |
| 34 | 44 | Usage: "admin symbols reindex <owner/name>", |
| @@ -204,3 +214,98 @@ func runAdminSymbolsReindex(c *Ctx, args []string) int { | ||
| 204 | 214 | fmt.Fprintf(w, "queued a rebuild of the symbol index of %s\n", repo.Path()) |
| 205 | 215 | }) |
| 206 | 216 | } |
| 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. | |
| 222 | type 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. | |
| 232 | var identifier = regexp.MustCompile(`[\p{L}_$][\p{L}\p{N}_$]*`) | |
| 233 | ||
| 234 | // maxDefinitionNames bounds the lookup for one file. | |
| 235 | const maxDefinitionNames = 5000 | |
| 236 | ||
| 237 | func 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 | } | |