review

review patchsets using your default editor
Log | Files | Refs

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 }