change.odin (27663B)
1 /* 2 Package change gathers the change under review — the staged change, or a 3 revision range — and reads what it adds: the declarations, tests and 4 comments on the lines the diff introduces, through the language sidecars, 5 and the repository's own declarations at the end of the change, which is 6 what new work is judged against. 7 */ 8 package change 9 10 import "core:fmt" 11 import "core:os" 12 import "core:path/filepath" 13 import "core:slice" 14 import "core:strconv" 15 import "core:strings" 16 17 import "../frontend" 18 import "../git" 19 import "../tree" 20 21 // Symbol is a declaration the change adds. 22 Symbol :: struct { 23 name: string, 24 kind: string, 25 doc: string, 26 file: string, 27 line: int, 28 exported: bool, 29 signature: string, 30 body: string, 31 pkg: string, 32 } 33 34 // Function is a test the change adds or touches, whole. skips is the 35 // line the test skips itself on, or zero, read from the body by shape. 36 Function :: struct { 37 name: string, 38 file: string, 39 line: int, 40 body: string, 41 skips: int, 42 } 43 44 // Located is a comment the change adds, with the code it sits above, a 45 // couple of lines of it, so that a claim about behaviour can be read 46 // beside the behaviour. 47 Located :: struct { 48 text: string, 49 file: string, 50 line: int, 51 below: string, 52 } 53 54 // Temporal is what the repository's history counts about the files the 55 // change touches: how many commits touch each, and which other files 56 // those commits also touched, nearest first. 57 Temporal :: struct { 58 commits: map[string]int, 59 partners: map[string][]Partner, 60 } 61 62 // Partner is another file that history shows changing with a changed one. 63 Partner :: struct { 64 name: string, 65 shared: int, 66 } 67 68 // Declared is one declaration somewhere in the repository, which a new 69 // name might turn out to duplicate. 70 Declared :: struct { 71 name: string, 72 kind: string, 73 file: string, 74 line: int, 75 text: string, 76 body: string, 77 } 78 79 // Gap is a file the deterministic side could not read, and why. 80 Gap :: struct { 81 file: string `json:"file"`, 82 reason: string `json:"reason"`, 83 } 84 85 // Diff_Line is one added line and the number it lands on. 86 Diff_Line :: struct { 87 line: int, 88 text: string, 89 } 90 91 // Change is what is under review. 92 Change :: struct { 93 // diff is the change itself, capped at max_diff. 94 diff: string, 95 truncated: bool, 96 // files are the paths it touches. 97 files: []string, 98 // message is the commit message: empty for a staged change. 99 message: string, 100 stat: string, 101 // added and removed are the diff's two sides, per file. 102 added: map[string][dynamic]Diff_Line, 103 removed: map[string][dynamic]string, 104 // What the sidecars read on the added lines. 105 symbols: [dynamic]Symbol, 106 tests: [dynamic]Function, 107 comments: [dynamic]Located, 108 imports: map[string][]string, 109 // uncovered are the code files no sidecar read, with a reason each. 110 uncovered: [dynamic]Gap, 111 // convention is the recent commit subjects, so a check can read the 112 // local habit; history is the last thousand, the word frequencies the 113 // message is measured against. 114 convention: []string, 115 history: []string, 116 // temporal is the counted history of the files the change touches, 117 // or nothing when the history says nothing. 118 temporal: Maybe(Temporal), 119 // changed is how many lines the diff adds and removes, counted by git 120 // over the whole change rather than the capped diff; whitespace is how 121 // many of them change nothing but whitespace. 122 changed: int, 123 whitespace: int, 124 // index is every declaration in the repository at the end of the 125 // change, kept for the checks that judge new work against it. 126 index: []Declared, 127 // candidates are existing names that resemble each new one, gathered 128 // by search rather than by the model; twins are the existing 129 // declarations holding the same literal as a new one, the shortest 130 // list worth reading. 131 candidates: map[string][]string, 132 twins: map[string][]string, 133 } 134 135 max_diff :: 60000 136 137 // gather collects the change at a revision range, or the staged change 138 // when the range is empty. Everything is allocated from allocator. 139 gather :: proc(rev, root: string, allocator := context.allocator) -> (c: Change, ok: bool) { 140 context.allocator = allocator 141 diff_args, name_args, stat_args: []string 142 if rev == "" { 143 diff_args = {"diff", "--cached", "-U3", "--src-prefix=a/", "--dst-prefix=b/"} 144 name_args = {"diff", "--cached", "--name-only"} 145 stat_args = {"diff", "--cached", "--stat"} 146 } else { 147 diff_args = {"diff", rev, "-U3", "--src-prefix=a/", "--dst-prefix=b/"} 148 name_args = {"diff", rev, "--name-only"} 149 stat_args = {"diff", rev, "--stat"} 150 } 151 c.diff = git.raw(root, diff_args) or_return 152 if len(c.diff) > max_diff { 153 c.diff = strings.concatenate({c.diff[:max_diff], "\n… diff truncated\n"}) 154 c.truncated = true 155 } 156 c.files = git.lines(root, name_args) or_return 157 c.stat = git.raw(root, stat_args) or_return 158 if rev != "" { 159 c.message, _ = git.raw(root, {"log", "-1", "--format=%B", strings.trim_suffix(rev, "^")}) 160 } 161 // The whitespace-only lines are what the diff loses when git is asked 162 // to ignore whitespace; the difference is the formatting mixed in. 163 count := 164 []string{"diff", "--cached", "--numstat"} if rev == "" else []string{"diff", rev, "--numstat"} 165 loose := 166 []string{"diff", "--cached", "-w", "--numstat"} if rev == "" else []string{"diff", rev, "-w", "--numstat"} 167 if plain, counted := git.run(root, count, context.temp_allocator); counted { 168 if without, counted_loose := git.run(root, loose, context.temp_allocator); counted_loose { 169 c.changed = count_numstat(plain) 170 c.whitespace = c.changed - count_numstat(without) 171 } 172 } 173 c.convention, _ = git.lines(root, {"log", "-12", "--format=%s"}) 174 c.history, _ = git.lines(root, {"log", "-1000", "--format=%s"}) 175 c.added, c.removed = diff_sides(c.diff) 176 c.imports = make(map[string][]string) 177 read_temporal(&c, root, rev) 178 return c, true 179 } 180 181 // read_message reads a commit message the way git will: the lines its 182 // comment character opens are the template's, not the author's, and a 183 // message file a hook is given is full of them. 184 read_message :: proc(path: string, allocator := context.allocator) -> (message: string, ok: bool) { 185 data, err := os.read_entire_file_from_path(path, context.temp_allocator) 186 if err != nil { 187 return "", false 188 } 189 kept := make([dynamic]string, context.temp_allocator) 190 rest := string(data) 191 for line in strings.split_lines_iterator(&rest) { 192 if strings.has_prefix(line, "#") { 193 continue 194 } 195 append(&kept, line) 196 } 197 return strings.clone( 198 strings.trim_space(strings.join(kept[:], "\n", context.temp_allocator)), 199 allocator, 200 ), 201 true 202 } 203 204 // count_numstat sums the lines added and removed over git's --numstat 205 // output. A binary file's counts are dashes and count nothing. 206 count_numstat :: proc(out: string) -> int { 207 n := 0 208 rest := out 209 for line in strings.split_lines_iterator(&rest) { 210 parts := strings.fields(line, context.temp_allocator) 211 if len(parts) < 3 { 212 continue 213 } 214 a, _ := strconv.parse_int(parts[0]) 215 b, _ := strconv.parse_int(parts[1]) 216 n += a + b 217 } 218 return n 219 } 220 221 // The counting window and width: the coupling is counted over the last 222 // thousand commits before the change, and a commit listing more than a 223 // hundred files is left out of the count — a sweep touching everything 224 // once says nothing about any pair. partner_list is the fewest-nearest 225 // partners kept per changed file. 226 temporal_window :: 1000 227 temporal_width :: 100 228 partner_list :: 20 229 230 // read_temporal counts, over the last thousand commits before the change, 231 // how often each changed file is touched by a commit that also touches 232 // another file. The history starts at the commit the change begins at, so 233 // the change under review is never counted against itself. 234 read_temporal :: proc(c: ^Change, root, rev: string) { 235 if len(c.files) == 0 { 236 return 237 } 238 start := "" 239 switch { 240 case rev == "": 241 case strings.contains(rev, ".."): 242 start = rev[:strings.index(rev, "..")] 243 case strings.has_suffix(rev, "^"): 244 start = rev 245 case: 246 start = strings.concatenate({rev, "^"}, context.temp_allocator) 247 } 248 args := make([dynamic]string, context.temp_allocator) 249 append(&args, "log", fmt.tprintf("-%d", temporal_window), "--format=%x00", "--name-only") 250 if start != "" { 251 append(&args, start) 252 } 253 logs, ok := git.run(root, args[:], context.temp_allocator) 254 if !ok { 255 return // No history, or a bare first commit: nothing to count. 256 } 257 changed := make(map[string]bool, context.temp_allocator) 258 for f in c.files { 259 changed[f] = true 260 } 261 commits := make(map[string]int) 262 pairs := make(map[string]map[string]int, context.temp_allocator) 263 for chunk in strings.split(logs, "\x00", context.temp_allocator) { 264 files := make([dynamic]string, context.temp_allocator) 265 rest := chunk 266 for line in strings.split_lines_iterator(&rest) { 267 if trimmed := strings.trim_space(line); trimmed != "" { 268 append(&files, trimmed) 269 } 270 } 271 if len(files) > temporal_width { 272 continue 273 } 274 for f in files { 275 commits[strings.clone(f)] += 1 276 } 277 for f in files { 278 if !changed[f] { 279 continue 280 } 281 if f not_in pairs { 282 pairs[f] = make(map[string]int, context.temp_allocator) 283 } 284 counts := &pairs[f] 285 for g in files { 286 if g != f { 287 counts[g] += 1 288 } 289 } 290 } 291 } 292 t := Temporal { 293 commits = commits, 294 partners = make(map[string][]Partner), 295 } 296 Named :: struct { 297 name: string, 298 j: f64, 299 } 300 for f, ps in pairs { 301 list := make([dynamic]Named, context.temp_allocator) 302 for name, shared in ps { 303 joint := commits[f] + commits[name] - shared 304 if joint <= 0 { 305 continue 306 } 307 append(&list, Named{name, f64(shared) / f64(joint)}) 308 } 309 slice.sort_by_cmp(list[:], proc(a, b: Named) -> slice.Ordering { 310 if a.j != b.j { 311 return .Less if a.j > b.j else .Greater 312 } 313 return .Less if a.name < b.name else (.Greater if a.name > b.name else .Equal) 314 }) 315 partners := make([dynamic]Partner) 316 for e in list { 317 if len(partners) == partner_list { 318 break 319 } 320 // A partner the tree no longer holds is history's partner, not 321 // this change's. 322 if remaining(root, rev, e.name) { 323 append(&partners, Partner{strings.clone(e.name), ps[e.name]}) 324 } 325 } 326 if len(partners) > 0 { 327 t.partners[strings.clone(f)] = partners[:] 328 } 329 } 330 if len(t.partners) > 0 { 331 c.temporal = t 332 } 333 } 334 335 // remaining reports whether a partner path is still a file at the end of 336 // the change. 337 remaining :: proc(root, rev, name: string) -> bool { 338 after, ranged := tree.ends(rev) 339 if !ranged { 340 return os.is_file(filepath.join({root, name}, context.temp_allocator) or_else name) 341 } 342 _, ok := git.run( 343 root, 344 {"cat-file", "-e", strings.concatenate({after, ":", name}, context.temp_allocator)}, 345 context.temp_allocator, 346 ) 347 return ok 348 } 349 350 // diff_sides splits a diff into the added and removed lines of each file. 351 // The header lines are read wherever they sit, as they sit between hunks; 352 // content is read only inside a hunk, so that a removed line beginning 353 // "+++ " is not mistaken for the header it resembles. 354 diff_sides :: proc( 355 diff: string, 356 allocator := context.allocator, 357 ) -> ( 358 added: map[string][dynamic]Diff_Line, 359 removed: map[string][dynamic]string, 360 ) { 361 context.allocator = allocator 362 added = make(map[string][dynamic]Diff_Line) 363 removed = make(map[string][dynamic]string) 364 added_file, removed_file: string 365 line: int 366 in_hunk: bool 367 for text in strings.split_lines(diff, context.temp_allocator) { 368 switch { 369 case strings.has_prefix(text, "+++ "): 370 added_file = side(text[4:], "b/") 371 in_hunk = false 372 case strings.has_prefix(text, "--- "): 373 removed_file = side(text[4:], "a/") 374 in_hunk = false 375 case strings.has_prefix(text, "\\ "): 376 // The no-newline marker annotates the line above it. 377 case strings.has_prefix(text, "@@ "): 378 if start, found := hunk_start(text); found { 379 in_hunk = true 380 line = start 381 } 382 case strings.has_prefix(text, "+") && in_hunk: 383 if added_file != "" { 384 lines := added[added_file] 385 append(&lines, Diff_Line{line, strings.clone(text[1:])}) 386 added[added_file] = lines 387 } 388 line += 1 389 case strings.has_prefix(text, "-") && in_hunk: 390 if removed_file != "" { 391 lines := removed[removed_file] 392 append(&lines, strings.clone(text[1:])) 393 removed[removed_file] = lines 394 } 395 case: 396 if in_hunk { 397 line += 1 398 } 399 } 400 } 401 return 402 } 403 404 // hunk_start reads the line a hunk's added side begins on, out of its 405 // "@@ -a,b +c,d @@" header. 406 hunk_start :: proc(text: string) -> (line: int, ok: bool) { 407 i := strings.index(text, " +") 408 if i < 0 { 409 return 0, false 410 } 411 rest := text[i + 2:] 412 end := 0 413 for end < len(rest) && rest[end] >= '0' && rest[end] <= '9' { 414 end += 1 415 } 416 if end == 0 { 417 return 0, false 418 } 419 return strconv.parse_int(rest[:end]) 420 } 421 422 // side strips a diff's prefix from a header path, and a deletion's 423 // /dev/null with it. 424 side :: proc(path, prefix: string) -> string { 425 stripped := strings.trim_prefix(path, prefix) 426 if stripped == "/dev/null" { 427 return "" 428 } 429 return strings.clone(stripped) 430 } 431 432 // read fills the change's symbols, tests, comments and imports from the 433 // sidecars, over the files at the end of the change. Only what the diff 434 // added is reported, so a job sees new work rather than the file it 435 // landed in: a declaration is new when its line is, a test when any line 436 // of it is, a comment when it sits on an added line. 437 read :: proc(c: ^Change, t: tree.Tree, allocator := context.allocator) -> bool { 438 context.allocator = allocator 439 ok := true 440 for name in c.files { 441 if _, covered := frontend.sidecar_for(name); covered || !tree.exists(t, name) { 442 continue 443 } 444 // A language no parser covers still has its comments read, by 445 // the prefixes the C-family shares; the jobs that need 446 // declarations or test bodies are named as unread for it. 447 if heuristic_covers(name) { 448 if source, readable := tree.read(t, name, context.temp_allocator); readable { 449 append(&c.comments, ..comment_prose(source, name, c.added[name])[:]) 450 } 451 append( 452 &c.uncovered, 453 Gap{name, "no duplication, namer parser"}, 454 Gap{name, "no tests parser"}, 455 ) 456 continue 457 } 458 // A file no reader could plausibly exist for — prose, data, 459 // configuration — is not a promise the review broke; a code file 460 // with no reader is, because a job that would have read it read 461 // nothing. 462 if is_code_file(name) { 463 append(&c.uncovered, Gap{name, "no reader"}) 464 } 465 } 466 for sidecar in frontend.Sidecar { 467 names := make([dynamic]string, context.temp_allocator) 468 for name in c.files { 469 if s, covered := frontend.sidecar_for(name); 470 covered && s == sidecar && tree.exists(t, name) { 471 append(&names, name) 472 } 473 } 474 if len(names) == 0 { 475 continue 476 } 477 answers, err := scan(sidecar, t, names[:], context.temp_allocator) 478 if err != .None { 479 why := fmt.aprintf("%s: %s", frontend.binary(sidecar), scan_error(err)) 480 for name in names { 481 append(&c.uncovered, Gap{name, why}) 482 } 483 ok = false 484 continue 485 } 486 for name in names { 487 file, answered := answers[name] 488 if !answered { 489 append(&c.uncovered, Gap{name, "no answer from the sidecar"}) 490 continue 491 } 492 if file.error != "" { 493 append(&c.uncovered, Gap{name, strings.clone(file.error)}) 494 continue 495 } 496 read_file(c, t, name, file) 497 } 498 } 499 annotate(c, t) 500 order(c) 501 return ok 502 } 503 504 // order puts what the sidecars read into the diff's own order — file as 505 // git lists it, then line — so that a subject renders the same on every 506 // run whichever sidecar answered first, and the answer cache is hit. 507 order :: proc(c: ^Change) { 508 position := make(map[string]int, context.temp_allocator) 509 for f, i in c.files { 510 position[f] = i 511 } 512 positions = &position 513 defer positions = nil 514 slice.stable_sort_by_cmp(c.symbols[:], proc(a, b: Symbol) -> slice.Ordering { 515 return before(a.file, a.line, b.file, b.line) 516 }) 517 slice.stable_sort_by_cmp(c.tests[:], proc(a, b: Function) -> slice.Ordering { 518 return before(a.file, a.line, b.file, b.line) 519 }) 520 slice.stable_sort_by_cmp(c.comments[:], proc(a, b: Located) -> slice.Ordering { 521 return before(a.file, a.line, b.file, b.line) 522 }) 523 } 524 525 // positions is the file order under sort, reachable from the comparators, 526 // which cannot capture it. 527 @(private) 528 positions: ^map[string]int 529 530 @(private) 531 before :: proc(file_a: string, line_a: int, file_b: string, line_b: int) -> slice.Ordering { 532 pa, pb := positions[file_a], positions[file_b] 533 if pa != pb { 534 return .Less if pa < pb else .Greater 535 } 536 if line_a != line_b { 537 return .Less if line_a < line_b else .Greater 538 } 539 return .Equal 540 } 541 542 // annotate adds to what the sidecars read the parts every language 543 // shares: the code below each comment, and the line a test skips itself 544 // on. Both are read by shape from the source. 545 annotate :: proc(c: ^Change, t: tree.Tree) { 546 lines := make(map[string][]string, context.temp_allocator) 547 for &comment in c.comments { 548 if comment.file not_in lines { 549 source, readable := tree.read(t, comment.file, context.temp_allocator) 550 lines[comment.file] = 551 strings.split_lines(string(source), context.temp_allocator) if readable else nil 552 } 553 comment.below = code_below(lines[comment.file], comment.line) 554 } 555 for &test in c.tests { 556 test.skips = skip_line(test) 557 } 558 } 559 560 // below_lines is how much code a comment is shown beside. 561 below_lines :: 2 562 563 // code_below is the code that follows a comment: the first lines after it 564 // that are neither blank nor comment, up to below_lines of them. 565 code_below :: proc(lines: []string, comment: int, allocator := context.allocator) -> string { 566 out := make([dynamic]string, context.temp_allocator) 567 for i := comment; i < len(lines) && len(out) < below_lines; i += 1 { 568 trimmed := strings.trim_space(lines[i]) 569 if trimmed == "" || is_comment_line(trimmed) { 570 if len(out) > 0 { 571 break 572 } 573 continue 574 } 575 append(&out, trimmed) 576 } 577 return strings.join(out[:], "\n", allocator) 578 } 579 580 // is_comment_line is whether a trimmed line is a comment by the shapes 581 // the tool's languages share. 582 is_comment_line :: proc(trimmed: string) -> bool { 583 if strings.has_prefix(trimmed, "//") || 584 strings.has_prefix(trimmed, "/*") || 585 strings.has_prefix(trimmed, "*") { 586 return true 587 } 588 return strings.has_prefix(trimmed, "#") && !strings.has_prefix(trimmed, "#!") 589 } 590 591 // skip_line is the line a test skips itself on, or zero. 592 skip_line :: proc(t: Function) -> int { 593 rest := t.body 594 i := 0 595 for line in strings.split_lines_iterator(&rest) { 596 trimmed := strings.trim_space(line) 597 for shape in ([]string{"t.Skip(", "t.Skipf(", "t.SkipNow(", "test.skip(", "it.skip(", "describe.skip(", "this.skip(", "testing.skip(", "t.skip(", "pytest.skip(", "pytest.mark.skip", "self.skipTest(", "unittest.skip"}) { 598 if strings.contains(trimmed, shape) { 599 return t.line + i 600 } 601 } 602 i += 1 603 } 604 return 0 605 } 606 607 // heuristic_covers reports the languages whose comment shape is known 608 // without a parser. Prose-only formats are left out: their text is not a 609 // comment. 610 heuristic_covers :: proc(path: string) -> bool { 611 for ext in ([]string{".py", ".rb", ".rs", ".c", ".h", ".cc", ".cpp", ".hpp", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".lua", ".zig", ".swift", ".kt", ".java", ".php", ".scala", ".cs"}) { 612 if strings.has_suffix(path, ext) { 613 return true 614 } 615 } 616 return false 617 } 618 619 // comment_prose lifts the comment lines among the added ones out of a 620 // source file by the prefixes the C-family shares: // and # for line 621 // comments, an open /* kept whole until its close. The shebang is not a 622 // comment. 623 comment_prose :: proc( 624 source: []byte, 625 name: string, 626 touched: [dynamic]Diff_Line, 627 allocator := context.allocator, 628 ) -> [dynamic]Located { 629 out := make([dynamic]Located, allocator) 630 if len(touched) == 0 { 631 return out 632 } 633 is_touched := make(map[int]bool, context.temp_allocator) 634 for l in touched { 635 is_touched[l.line] = true 636 } 637 in_block := false 638 rest := string(source) 639 number := 0 640 for line in strings.split_lines_iterator(&rest) { 641 number += 1 642 trimmed := strings.trim_space(line) 643 switch { 644 case in_block: 645 if is_touched[number] { 646 t := strings.trim_suffix(trimmed, "*/") 647 t = strings.trim_space(strings.trim_prefix(t, "*")) 648 if t != "" { 649 append( 650 &out, 651 Located{text = strings.clone(t, allocator), file = name, line = number}, 652 ) 653 } 654 } 655 if strings.contains(trimmed, "*/") { 656 in_block = false 657 } 658 case strings.has_prefix(trimmed, "/*"): 659 t := trimmed[2:] 660 if end := strings.index(t, "*/"); end >= 0 { 661 t = t[:end] 662 } else { 663 in_block = true 664 } 665 if is_touched[number] && strings.trim_space(t) != "" { 666 text := strings.trim_space(strings.trim_prefix(strings.trim_space(t), "*")) 667 append( 668 &out, 669 Located{text = strings.clone(text, allocator), file = name, line = number}, 670 ) 671 } 672 case strings.has_prefix(trimmed, "//"): 673 if is_touched[number] { 674 append( 675 &out, 676 Located { 677 text = strings.clone( 678 strings.trim_space(strings.trim_prefix(trimmed, "//")), 679 allocator, 680 ), 681 file = name, 682 line = number, 683 }, 684 ) 685 } 686 case strings.has_prefix(trimmed, "#") && !strings.has_prefix(trimmed, "#!"): 687 if is_touched[number] { 688 append( 689 &out, 690 Located { 691 text = strings.clone( 692 strings.trim_space(strings.trim_prefix(trimmed, "#")), 693 allocator, 694 ), 695 file = name, 696 line = number, 697 }, 698 ) 699 } 700 } 701 } 702 return out 703 } 704 705 // read_file keeps what one file's answer says about the added lines. 706 read_file :: proc(c: ^Change, t: tree.Tree, name: string, file: frontend.File) { 707 source, readable := tree.read(t, name, context.temp_allocator) 708 if !readable { 709 return 710 } 711 lines := strings.split_lines(string(source), context.temp_allocator) 712 touched := make(map[int]bool, context.temp_allocator) 713 // The lookup is bound before the loop: ranging over a map index with 714 // a missing key reads through a nil entry. 715 added := c.added[name] 716 for l in added { 717 touched[l.line] = true 718 } 719 c.imports[name] = clone_all(file.imports) 720 for decl in file.decls { 721 if decl.kind == "field" { 722 continue 723 } 724 if decl.test { 725 if touched_between(touched, decl.line, decl.end_line) { 726 append( 727 &c.tests, 728 Function { 729 name = strings.clone(decl.name), 730 file = name, 731 line = decl.line, 732 body = body_of(decl, lines), 733 }, 734 ) 735 } 736 continue 737 } 738 if !touched[decl.line] { 739 continue 740 } 741 symbol := Symbol { 742 name = strings.clone(decl.name), 743 kind = strings.clone(decl.kind), 744 doc = strings.clone(decl.doc), 745 file = name, 746 line = decl.line, 747 exported = decl.exported, 748 signature = strings.clone(decl.text), 749 pkg = strings.clone(file.pkg), 750 } 751 if decl.kind == "func" { 752 symbol.body = body_of(decl, lines) 753 } 754 append(&c.symbols, symbol) 755 } 756 // Go's sidecar reads comments with its parser; every other language's 757 // are read by shape, the same way for a parsed file and an unparsed 758 // one. 759 if s, _ := frontend.sidecar_for(name); s == .Go { 760 for comment in file.comments { 761 if touched[comment.line] { 762 append( 763 &c.comments, 764 Located{text = strings.clone(comment.text), file = name, line = comment.line}, 765 ) 766 } 767 } 768 } else { 769 append(&c.comments, ..comment_prose(source, name, added)[:]) 770 } 771 } 772 773 // index is every declaration in the repository at the end of the change, 774 // tests and locals left out: a test is not a fact with two owners, and a 775 // local is nobody else's to duplicate. 776 index :: proc(t: tree.Tree, allocator := context.allocator) -> (out: []Declared, ok: bool) { 777 context.allocator = allocator 778 tracked := tree.files(t, context.temp_allocator) or_return 779 declared := make([dynamic]Declared) 780 ok = true 781 for sidecar in frontend.Sidecar { 782 names := make([dynamic]string, context.temp_allocator) 783 for name in tracked { 784 if s, covered := frontend.sidecar_for(name); 785 covered && s == sidecar && !is_test_file(name) { 786 append(&names, name) 787 } 788 } 789 if len(names) == 0 { 790 continue 791 } 792 answers, err := scan(sidecar, t, names[:], context.temp_allocator) 793 if err != .None { 794 ok = false 795 continue 796 } 797 for name in names { 798 file, answered := answers[name] 799 if !answered || file.error != "" { 800 continue 801 } 802 lines: []string 803 if source, readable := tree.read(t, name, context.temp_allocator); readable { 804 lines = strings.split_lines(string(source), context.temp_allocator) 805 } 806 for decl in file.decls { 807 if decl.test || decl.local { 808 continue 809 } 810 entry := Declared { 811 name = strings.clone(decl.name), 812 kind = strings.clone(decl.kind), 813 file = name, 814 line = decl.line, 815 text = strings.clone(decl.text), 816 } 817 if decl.kind == "func" { 818 entry.body = body_of(decl, lines) 819 } 820 append(&declared, entry) 821 } 822 } 823 } 824 return declared[:], ok 825 } 826 827 // body_of is a declaration's whole text: the reader's own match where it 828 // has one, else the file's lines from its first to its last. 829 body_of :: proc(decl: frontend.Decl, lines: []string, allocator := context.allocator) -> string { 830 if decl.body != "" { 831 return strings.clone(decl.body, allocator) 832 } 833 return text(lines, decl.line, decl.end_line, allocator) 834 } 835 836 // scan asks a sidecar about tracked files, handing it their paths in the 837 // tree, and returns each answer under the tracked name it was asked for. 838 scan :: proc( 839 sidecar: frontend.Sidecar, 840 t: tree.Tree, 841 names: []string, 842 allocator := context.allocator, 843 ) -> ( 844 answers: map[string]frontend.File, 845 err: frontend.Scan_Error, 846 ) { 847 paths := make([]string, len(names), allocator) 848 by_path := make(map[string]string, allocator) 849 for name, i in names { 850 paths[i] = tree.path(t, name, allocator) 851 by_path[paths[i]] = name 852 } 853 out := frontend.scan(sidecar, paths, allocator) or_return 854 answers = make(map[string]frontend.File, allocator) 855 for file in out.files { 856 if name, known := by_path[file.name]; known { 857 answers[name] = file 858 } 859 } 860 return answers, .None 861 } 862 863 // scan_error says what a sidecar failure was. 864 scan_error :: proc(err: frontend.Scan_Error) -> string { 865 switch err { 866 case .None: 867 return "" 868 case .Not_Installed: 869 return "not on the path" 870 case .Failed: 871 return "gave no answer" 872 case .Unreadable: 873 return "gave an answer that is not its JSON" 874 } 875 return "" 876 } 877 878 // is_code_file reports a file a reader could plausibly exist for: not 879 // prose, data, configuration or the files a repository keeps for git. 880 is_code_file :: proc(path: string) -> bool { 881 base := filepath.base(path) 882 switch base { 883 case ".gitignore", 884 ".gitattributes", 885 ".gitmodules", 886 ".editorconfig", 887 "Makefile", 888 "Dockerfile", 889 "LICENSE", 890 "CODEOWNERS": 891 return false 892 } 893 for ext in ([]string{".md", ".mdx", ".txt", ".rst", ".adoc", ".json", ".yaml", ".yml", ".toml", ".lock", ".sum", ".mod", ".html", ".htm", ".css", ".svg", ".xml"}) { 894 if strings.has_suffix(base, ext) { 895 return false 896 } 897 } 898 return true 899 } 900 901 // is_test_file reports a file the test runner reads, in Go's habit. 902 is_test_file :: proc(name: string) -> bool { 903 return strings.has_suffix(name, "_test.go") 904 } 905 906 touched_between :: proc(touched: map[int]bool, from, to: int) -> bool { 907 for line in from ..= to { 908 if touched[line] { 909 return true 910 } 911 } 912 return false 913 } 914 915 // text is the lines from one number to another, inclusive, or nothing 916 // when the range falls outside the file. 917 text :: proc(lines: []string, from, to: int, allocator := context.allocator) -> string { 918 if from < 1 || to > len(lines) || to < from { 919 return "" 920 } 921 return strings.join(lines[from - 1:to], "\n", allocator) 922 } 923 924 clone_all :: proc(items: []string, allocator := context.allocator) -> []string { 925 out := make([]string, len(items), allocator) 926 for item, i in items { 927 out[i] = strings.clone(item, allocator) 928 } 929 return out 930 }