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 }