review

review patchsets using your default editor
Log | Files | Refs

check.odin (9552B)


      1 /*
      2 Package check is the deterministic readings: what a change can be measured
      3 for without a model, from the commit message to the code it adds — its
      4 names, tests, bodies, comments and leftovers — and the repository around
      5 it. A check that passes says nothing. Each is independent of the others,
      6 and every finding cites a rule the catalogue describes.
      7 */
      8 package check
      9 
     10 import "base:runtime"
     11 import "core:fmt"
     12 import "core:slice"
     13 import "core:strings"
     14 import "core:sync"
     15 import "core:text/regex"
     16 import "core:unicode"
     17 
     18 import "../change"
     19 import "../finding"
     20 import "../tree"
     21 
     22 // Scope is what a check reads: the change, and the repository at its end.
     23 Scope :: struct {
     24 	c:       ^change.Change,
     25 	t:       tree.Tree,
     26 	files:   []string,
     27 	sources: map[string][]byte,
     28 }
     29 
     30 // Check measures a change and appends what it noticed.
     31 Check :: #type proc(s: Scope, out: ^[dynamic]finding.Finding)
     32 
     33 // checks are the deterministic readings in the order the catalogue lists
     34 // them: the message, the history, the review's own mechanisms, and then
     35 // the code the change adds.
     36 checks :: proc() -> []Check {
     37 	@(static) all := []Check {
     38 		check_entropy,
     39 		check_compressibility,
     40 		check_common,
     41 		check_venting,
     42 		check_mood,
     43 		check_body,
     44 		check_formatting,
     45 		check_names_unknown,
     46 		check_temporal,
     47 		check_suppression_added,
     48 		check_deleted_tests,
     49 		check_names,
     50 		check_test_assertions,
     51 		check_tautologies,
     52 		check_clones,
     53 		check_shape,
     54 		check_restating,
     55 		check_todos,
     56 		check_commented_code,
     57 		check_debug_leftovers,
     58 		check_swallowed_errors,
     59 		check_unreferenced,
     60 		check_code_without_tests,
     61 	}
     62 	return all
     63 }
     64 
     65 // scope_of reads the repository once for every check that needs it.
     66 scope_of :: proc(c: ^change.Change, t: tree.Tree, allocator := context.allocator) -> Scope {
     67 	s := Scope {
     68 		c = c,
     69 		t = t,
     70 	}
     71 	s.files, _ = tree.files(t, allocator)
     72 	s.sources, _ = tree.sources(t, allocator)
     73 	return s
     74 }
     75 
     76 // run applies every check. Everything a finding holds is allocated from
     77 // allocator.
     78 run :: proc(s: Scope, allocator := context.allocator) -> []finding.Finding {
     79 	context.allocator = allocator
     80 	sides(s)
     81 	out := make([dynamic]finding.Finding)
     82 	for check in checks() {
     83 		check(s, &out)
     84 	}
     85 	return out[:]
     86 }
     87 
     88 // collect is one check's findings, for a test of it alone.
     89 collect :: proc(s: Scope, check: Check, allocator := context.allocator) -> []finding.Finding {
     90 	context.allocator = allocator
     91 	sides(s)
     92 	out := make([dynamic]finding.Finding)
     93 	check(s, &out)
     94 	return out[:]
     95 }
     96 
     97 // sides splits the diff a change was handed without going through gather,
     98 // so that a check reading the added lines reads them.
     99 sides :: proc(s: Scope) {
    100 	if len(s.c.added) == 0 && len(s.c.removed) == 0 && s.c.diff != "" {
    101 		s.c.added, s.c.removed = change.diff_sides(s.c.diff)
    102 	}
    103 }
    104 
    105 // static is a finding a deterministic check made, which verifies itself:
    106 // what it reports was measured, not read once.
    107 static :: proc(
    108 	rule: string,
    109 	severity: finding.Severity,
    110 	message, fix: string,
    111 	file := "",
    112 	line := 0,
    113 	symbol := "",
    114 ) -> finding.Finding {
    115 	return finding.Finding {
    116 		job = "static",
    117 		rule = rule,
    118 		severity = severity,
    119 		message = message,
    120 		fix = fix,
    121 		file = file,
    122 		line = line,
    123 		symbol = symbol,
    124 		verified = true,
    125 	}
    126 }
    127 
    128 // plural is the s a count takes when it is not one.
    129 plural :: proc(n: int) -> string {
    130 	return "" if n == 1 else "s"
    131 }
    132 
    133 // first_line is the first line of a text, cut short past 140 characters.
    134 first_line :: proc(s: string, allocator := context.allocator) -> string {
    135 	line := s
    136 	if i := strings.index_byte(line, '\n'); i >= 0 {
    137 		line = line[:i]
    138 	}
    139 	if len(line) > 140 {
    140 		return strings.concatenate({line[:140], "…"}, allocator)
    141 	}
    142 	return line
    143 }
    144 
    145 // quoted joins names for a message, each in quotes.
    146 quoted :: proc(names: []string, allocator := context.allocator) -> string {
    147 	out := make([]string, len(names), context.temp_allocator)
    148 	for n, i in names {
    149 		out[i] = fmt.tprintf("%q", n)
    150 	}
    151 	return strings.join(out, ", ", allocator)
    152 }
    153 
    154 // sorted_keys is a map's keys in order, so that a check reports in an
    155 // order a reader can follow.
    156 sorted_keys :: proc(m: map[$K]$V, allocator := context.temp_allocator) -> []K {
    157 	keys, _ := slice.map_keys(m, allocator)
    158 	slice.sort(keys)
    159 	return keys
    160 }
    161 
    162 // set reads a space-separated list of words into a set, allocated to live
    163 // for the program: the lists are the checks' vocabulary.
    164 set :: proc(words: string) -> map[string]bool {
    165 	out := make(map[string]bool, runtime.heap_allocator())
    166 	for w in strings.fields(words, context.temp_allocator) {
    167 		out[strings.clone(w, runtime.heap_allocator())] = true
    168 	}
    169 	return out
    170 }
    171 
    172 // fields splits on anything that is not a letter or a digit.
    173 fields :: proc(s: string, allocator := context.allocator) -> []string {
    174 	out := make([dynamic]string, allocator)
    175 	start := -1
    176 	for r, i in s {
    177 		if unicode.is_letter(r) || unicode.is_digit(r) {
    178 			if start < 0 {
    179 				start = i
    180 			}
    181 			continue
    182 		}
    183 		if start >= 0 {
    184 			append(&out, s[start:i])
    185 			start = -1
    186 		}
    187 	}
    188 	if start >= 0 {
    189 		append(&out, s[start:])
    190 	}
    191 	return out[:]
    192 }
    193 
    194 // has_suffix reports whether a path ends in any of the suffixes.
    195 has_suffix :: proc(path: string, suffixes: []string) -> bool {
    196 	for s in suffixes {
    197 		if strings.has_suffix(path, s) {
    198 			return true
    199 		}
    200 	}
    201 	return false
    202 }
    203 
    204 // is_test_file reports whether a path is one a test runner reads, in any
    205 // of the naming habits the tool's languages have: a marker in the name,
    206 // or a tests directory. A Rust unit test sits in the source file beside
    207 // the code, and that file is not a test file.
    208 is_test_file :: proc(path: string) -> bool {
    209 	base := path
    210 	if i := strings.last_index_byte(path, '/'); i >= 0 {
    211 		base = path[i + 1:]
    212 	}
    213 	for marker in ([]string{"_test.", ".test.", ".spec."}) {
    214 		if strings.contains(base, marker) {
    215 			return true
    216 		}
    217 	}
    218 	if strings.has_suffix(base, ".py") &&
    219 	   (strings.has_prefix(base, "test_") || strings.has_suffix(base, "_test.py")) {
    220 		return true
    221 	}
    222 	dir := path[:max(0, len(path) - len(base))]
    223 	for part in strings.split(dir, "/", context.temp_allocator) {
    224 		if part == "tests" || part == "__tests__" || part == "test" {
    225 			return true
    226 		}
    227 	}
    228 	return false
    229 }
    230 
    231 // grammar_of is the ast-grep grammar a TypeScript or JavaScript file is
    232 // read with, or nothing for any other language.
    233 grammar_of :: proc(path: string) -> string {
    234 	switch {
    235 	case strings.has_suffix(path, ".ts"):
    236 		return "ts"
    237 	case strings.has_suffix(path, ".tsx"):
    238 		return "tsx"
    239 	case has_suffix(path, {".js", ".jsx", ".mjs", ".cjs"}):
    240 		return "js"
    241 	}
    242 	return ""
    243 }
    244 
    245 // The patterns the checks match are compiled once and kept for the
    246 // program; a check runs over every line of a change, and a pattern is not
    247 // worth compiling per line. The cache is shared between threads, as the
    248 // test runner's are, so it is locked.
    249 @(private)
    250 patterns: map[string]regex.Regular_Expression
    251 @(private)
    252 patterns_lock: sync.Mutex
    253 
    254 @(init)
    255 init_patterns :: proc "contextless" () {
    256 	context = runtime.default_context()
    257 	patterns = make(map[string]regex.Regular_Expression, runtime.heap_allocator())
    258 }
    259 
    260 // rx is the compiled form of a pattern, in Go's syntax as far as the two
    261 // engines share it.
    262 rx :: proc(pattern: string) -> regex.Regular_Expression {
    263 	sync.mutex_lock(&patterns_lock)
    264 	defer sync.mutex_unlock(&patterns_lock)
    265 	if re, ok := patterns[pattern]; ok {
    266 		return re
    267 	}
    268 	re, err := regex.create(pattern, {}, runtime.heap_allocator())
    269 	if err != nil {
    270 		panic(fmt.tprintf("check: pattern %q: %v", pattern, err))
    271 	}
    272 	patterns[strings.clone(pattern, runtime.heap_allocator())] = re
    273 	return re
    274 }
    275 
    276 // matches reports whether a pattern matches anywhere in the text.
    277 matches :: proc(pattern, text: string) -> bool {
    278 	_, ok := regex.match(rx(pattern), text, context.temp_allocator)
    279 	return ok
    280 }
    281 
    282 // capture is the groups of the first match: the whole match first, then
    283 // each group, empty where a group did not take part.
    284 capture :: proc(
    285 	pattern, text: string,
    286 	allocator := context.temp_allocator,
    287 ) -> (
    288 	groups: []string,
    289 	ok: bool,
    290 ) {
    291 	cap: regex.Capture
    292 	cap, ok = regex.match(rx(pattern), text, allocator)
    293 	if !ok {
    294 		return nil, false
    295 	}
    296 	return cap.groups, true
    297 }
    298 
    299 // capture_end is where the first match ends, for a reader that continues
    300 // from there.
    301 capture_end :: proc(pattern, text: string) -> (end: int, ok: bool) {
    302 	cap: regex.Capture
    303 	cap, ok = regex.match(rx(pattern), text, context.temp_allocator)
    304 	if !ok {
    305 		return 0, false
    306 	}
    307 	return cap.pos[0][1], true
    308 }
    309 
    310 // find_all is every match of a pattern in the text, whole.
    311 find_all :: proc(pattern, text: string, allocator := context.temp_allocator) -> []string {
    312 	out := make([dynamic]string, allocator)
    313 	it, err := regex.create_iterator(text, pattern, {}, context.temp_allocator)
    314 	if err != nil {
    315 		return out[:]
    316 	}
    317 	defer regex.destroy_iterator(it, context.temp_allocator)
    318 	for {
    319 		cap, _, ok := regex.match_iterator(&it)
    320 		if !ok {
    321 			break
    322 		}
    323 		append(&out, cap.groups[0])
    324 	}
    325 	return out[:]
    326 }
    327 
    328 // remove_all is the text with every match of a pattern replaced by a
    329 // space.
    330 remove_all :: proc(pattern, text: string, allocator := context.allocator) -> string {
    331 	b := strings.builder_make(allocator)
    332 	it, err := regex.create_iterator(text, pattern, {}, context.temp_allocator)
    333 	if err != nil {
    334 		return strings.clone(text, allocator)
    335 	}
    336 	defer regex.destroy_iterator(it, context.temp_allocator)
    337 	last := 0
    338 	for {
    339 		cap, _, ok := regex.match_iterator(&it)
    340 		if !ok {
    341 			break
    342 		}
    343 		strings.write_string(&b, text[last:cap.pos[0][0]])
    344 		strings.write_string(&b, " ")
    345 		last = cap.pos[0][1]
    346 	}
    347 	strings.write_string(&b, text[last:])
    348 	return strings.to_string(b)
    349 }