grep.odin (15185B)
1 package frontend 2 3 // TypeScript, JavaScript, Python and Rust are read through ast-grep, which 4 // matches patterns and node kinds against the tree-sitter syntax tree 5 // rather than against lines. A pattern over lines misses an indented 6 // constant inside a block; a query over syntax does not. The answer is 7 // shaped like a sidecar's, so the change reads every language one way. 8 9 import "core:encoding/json" 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 import "core:text/regex" 17 import "jm:sh" 18 19 // Pattern is one top-level declaration shape a TypeScript grammar binds. 20 // Export status is part of the pattern so a symbol's audience is known; 21 // the plain forms also match the inner node of an export, which the 22 // reader resolves by preferring the exported match. 23 Pattern :: struct { 24 kind: string, 25 exported: bool, 26 pattern: string, 27 } 28 29 @(private = "file") 30 ts_patterns := []Pattern { 31 {"value", false, "const $N = $$$V"}, 32 {"value", true, "export const $N = $$$V"}, 33 {"value", false, "const $N: $$$T = $$$V"}, 34 {"value", true, "export const $N: $$$T = $$$V"}, 35 {"value", false, "let $N = $$$V"}, 36 {"value", true, "export let $N = $$$V"}, 37 {"value", true, "export let $N: $$$T = $$$V"}, 38 {"value", false, "var $N = $$$V"}, 39 {"value", true, "export var $N = $$$V"}, 40 {"type", false, "type $N = $$$B"}, 41 {"type", true, "export type $N = $$$B"}, 42 {"type", false, "interface $N { $$$B }"}, 43 {"type", true, "export interface $N { $$$B }"}, 44 {"type", false, "enum $N { $$$V }"}, 45 {"type", true, "export enum $N { $$$V }"}, 46 {"type", false, "class $N { $$$B }"}, 47 {"type", true, "export class $N { $$$B }"}, 48 {"func", false, "function $N($$$P) { $$$B }"}, 49 {"func", true, "export function $N($$$P) { $$$B }"}, 50 {"func", true, "export function $N($$$P): $$$R { $$$B }"}, 51 {"func", false, "async function $N($$$P) { $$$B }"}, 52 {"func", true, "export async function $N($$$P) { $$$B }"}, 53 {"func", true, "export async function $N($$$P): $$$R { $$$B }"}, 54 {"func", true, "export default function $N($$$P) { $$$B }"}, 55 {"func", true, "export default function $N($$$P): $$$R { $$$B }"}, 56 } 57 58 // ts_test_patterns bind the runner's calls, in the shapes Node's 59 // node:test, Bun's bun:test, Deno's Deno.test and the Jest family write 60 // them. A test inside a describe block is still a test, so these carry no 61 // top-level constraint. The name is $S when the test is named by a string 62 // and $N when by a function. 63 @(private = "file") 64 ts_test_patterns := []string { 65 "test($S, $$$B)", 66 "it($S, $$$B)", 67 "test.$M($S, $$$B)", 68 "it.$M($S, $$$B)", 69 "test.$M($$$T)($S, $$$B)", 70 "it.$M($$$T)($S, $$$B)", 71 "Deno.test($S, $$$B)", 72 "Deno.test.$M($S, $$$B)", 73 "Deno.test({ name: $S, $$$R })", 74 "Deno.test(function $N($$$P) { $$$B })", 75 } 76 77 // Kind_Rule is one declaration shape read by node kind: the ast-grep rule 78 // that matches it, the kind the tool reports it as, and the expression 79 // that reads its name out of the match text. 80 Kind_Rule :: struct { 81 id: string, 82 kind: string, 83 rule: string, 84 name: string, 85 test: bool, 86 } 87 88 @(private = "file") 89 py_top :: " not:\n inside:\n any:\n - kind: function_definition\n - kind: class_definition\n stopBy: end\n" 90 91 // Python: module-level functions, classes and assignments, and any 92 // function named test_. A leading underscore is the language's whole 93 // notion of private. 94 @(private = "file") 95 py_rules := []Kind_Rule { 96 { 97 "py-func", 98 "func", 99 " kind: function_definition\n" + py_top, 100 `^\s*(?:async\s+)?def\s+(\w+)`, 101 false, 102 }, 103 {"py-class", "type", " kind: class_definition\n" + py_top, `^\s*class\s+(\w+)`, false}, 104 { 105 "py-value", 106 "value", 107 " kind: assignment\n inside:\n kind: expression_statement\n inside:\n kind: module\n", 108 `^\s*(\w+)\s*(?::[^=]*)?=`, 109 false, 110 }, 111 { 112 "py-test", 113 "func", 114 " kind: function_definition\n has:\n field: name\n regex: ^test_\n", 115 `^\s*(?:async\s+)?def\s+(\w+)`, 116 true, 117 }, 118 } 119 120 // Rust: functions wherever they are declared, impl methods included, the 121 // type items, constants and statics, and any function a test attribute 122 // precedes. pub is the whole notion of exported. 123 @(private = "file") 124 rs_rules := []Kind_Rule { 125 {"rs-func", "func", " kind: function_item\n", `\bfn\s+(\w+)`, false}, 126 { 127 "rs-type", 128 "type", 129 " any:\n - kind: struct_item\n - kind: enum_item\n - kind: type_item\n - kind: trait_item\n", 130 `\b(?:struct|enum|type|trait)\s+(\w+)`, 131 false, 132 }, 133 { 134 "rs-value", 135 "value", 136 " any:\n - kind: const_item\n - kind: static_item\n", 137 `\b(?:const|static)\s+(?:mut\s+)?(\w+)`, 138 false, 139 }, 140 { 141 "rs-test", 142 "func", 143 " kind: function_item\n follows:\n kind: attribute_item\n regex: '^#\\[[\\w:]*test(\\(|\\])'\n", 144 `\bfn\s+(\w+)`, 145 true, 146 }, 147 } 148 149 // grammar_of is the ast-grep grammar a path is parsed with, or empty 150 // where none of the pattern grammars reads it. 151 grammar_of :: proc(path: string) -> string { 152 switch { 153 case strings.has_suffix(path, ".ts"): 154 return "ts" 155 case strings.has_suffix(path, ".tsx"): 156 return "tsx" 157 case strings.has_suffix(path, ".js") || 158 strings.has_suffix(path, ".jsx") || 159 strings.has_suffix(path, ".mjs") || 160 strings.has_suffix(path, ".cjs"): 161 return "js" 162 } 163 return "" 164 } 165 166 // scratch_ext is the extension a file is materialised under, which is 167 // what ast-grep infers the grammar from. The module variants of 168 // JavaScript are the same grammar under another name. 169 scratch_ext :: proc(path: string) -> string { 170 ext := filepath.ext(path) 171 if ext == ".mjs" || ext == ".cjs" { 172 return ".js" 173 } 174 return ext 175 } 176 177 // typed is whether a pattern needs TypeScript's grammar: an annotation, 178 // an interface, a type alias or an enum is not JavaScript. 179 typed :: proc(pattern: string) -> bool { 180 bare := strings.trim_prefix(pattern, "export ") 181 return( 182 strings.contains(pattern, ": $$$") || 183 strings.has_prefix(bare, "type ") || 184 strings.contains(pattern, "interface ") || 185 strings.contains(pattern, "enum ") \ 186 ) 187 } 188 189 // write_rules emits the query set for a language into the rules 190 // directory. The top-level rule keeps TypeScript declarations out of 191 // function bodies; the test rules go anywhere. 192 write_rules :: proc(sidecar: Sidecar, dir: string) -> bool { 193 switch sidecar { 194 case .Script: 195 Table :: struct { 196 prefix, language: string, 197 } 198 for table in ([]Table{{"ts", "TypeScript"}, {"tsx", "TSX"}, {"js", "JavaScript"}}) { 199 for p, i in ts_patterns { 200 if table.prefix == "js" && typed(p.pattern) { 201 continue 202 } 203 status := "export" if p.exported else "plain" 204 id := fmt.tprintf("%s-%s-%s-%d", table.prefix, p.kind, status, i) 205 body := fmt.tprintf( 206 "id: %s\nlanguage: %s\nseverity: info\nrule:\n pattern: %q\n not:\n inside:\n kind: statement_block\n stopBy: end\n", 207 id, 208 table.language, 209 p.pattern, 210 ) 211 write_rule(dir, id, body) or_return 212 } 213 for pattern, i in ts_test_patterns { 214 id := fmt.tprintf("%s-test-%d", table.prefix, i) 215 write_rule( 216 dir, 217 id, 218 fmt.tprintf( 219 "id: %s\nlanguage: %s\nseverity: info\nrule:\n pattern: %q\n", 220 id, 221 table.language, 222 pattern, 223 ), 224 ) or_return 225 } 226 } 227 case .Python, .Rust: 228 language := "Python" if sidecar == .Python else "Rust" 229 for r in (py_rules if sidecar == .Python else rs_rules) { 230 write_rule( 231 dir, 232 r.id, 233 fmt.tprintf( 234 "id: %s\nlanguage: %s\nseverity: info\nrule:\n%s", 235 r.id, 236 language, 237 r.rule, 238 ), 239 ) or_return 240 } 241 case .Go, .Odin: 242 return false 243 } 244 return true 245 } 246 247 write_rule :: proc(dir, id, body: string) -> bool { 248 path := 249 filepath.join( 250 {dir, strings.concatenate({id, ".yml"}, context.temp_allocator)}, 251 context.temp_allocator, 252 ) or_else id 253 return os.write_entire_file(path, transmute([]byte)body) == nil 254 } 255 256 // Grep_Match is one ast-grep finding, as its --json prints it. 257 Grep_Match :: struct { 258 rule: string `json:"ruleId"`, 259 text: string `json:"text"`, 260 file: string `json:"file"`, 261 range: struct { 262 start: struct { 263 line: int `json:"line"`, 264 } `json:"start"`, 265 } `json:"range"`, 266 meta: struct { 267 single: map[string]struct { 268 text: string `json:"text"`, 269 } `json:"single"`, 270 } `json:"metaVariables"`, 271 } 272 273 // grep_scan materialises the files into a scratch directory under 274 // numbered names, writes the rules, asks ast-grep for the matches, and 275 // reads them back into the sidecar shape, one File per path given. 276 grep_scan :: proc( 277 sidecar: Sidecar, 278 files: []string, 279 allocator := context.allocator, 280 ) -> ( 281 out: Output, 282 err: Scan_Error, 283 ) { 284 temp := os.temp_directory(context.temp_allocator) or_else "" 285 dir, made := os.make_directory_temp(temp, "review-grep-*", context.temp_allocator) 286 if made != nil { 287 return out, .Failed 288 } 289 defer os.remove_all(dir) 290 rules := filepath.join({dir, "rules"}, context.temp_allocator) or_else "" 291 if os.make_directory_all(rules) != nil { 292 return out, .Failed 293 } 294 config := filepath.join({dir, "sgconfig.yml"}, context.temp_allocator) or_else "" 295 if os.write_entire_file(config, transmute([]byte)string("ruleDirs:\n - rules\n")) != nil { 296 return out, .Failed 297 } 298 if !write_rules(sidecar, rules) { 299 return out, .Failed 300 } 301 argv := make([dynamic]string, context.temp_allocator) 302 append(&argv, "ast-grep", "scan", "-c", config, "--json") 303 sources := make([][]byte, len(files), context.temp_allocator) 304 for name, i in files { 305 source, read_err := os.read_entire_file_from_path(name, allocator) 306 if read_err != nil { 307 continue 308 } 309 sources[i] = source 310 scratch := 311 filepath.join( 312 {dir, fmt.tprintf("%04d%s", i, scratch_ext(name))}, 313 context.temp_allocator, 314 ) or_else "" 315 if os.write_entire_file(scratch, source) != nil { 316 return out, .Failed 317 } 318 append(&argv, scratch) 319 } 320 r := sh.exec(argv[:], allocator = context.temp_allocator) 321 if !r.ok && len(r.stdout) == 0 { 322 return out, .Failed 323 } 324 matches: []Grep_Match 325 if json.unmarshal_string(r.stdout, &matches, allocator = context.temp_allocator) != nil { 326 return out, .Unreadable 327 } 328 out.files = make([]File, len(files), allocator) 329 for name, i in files { 330 out.files[i] = File { 331 name = strings.clone(name, allocator), 332 imports = {}, 333 decls = {}, 334 comments = {}, 335 } 336 } 337 // One declaration, several matches: the plain forms see the inner 338 // node of an export, and a test function is also a function. Keep the 339 // exported reading, and the test reading, of each line. 340 best := make(map[string]Grep_Match, context.temp_allocator) 341 for m in matches { 342 base := strings.trim_suffix(filepath.base(m.file), filepath.ext(m.file)) 343 index, numbered := strconv.parse_int(base) 344 if !numbered || index < 0 || index >= len(files) { 345 continue 346 } 347 key := fmt.tprintf("%d:%d", index, m.range.start.line) 348 if old, seen := best[key]; seen && !prefer(m.rule, old.rule) { 349 continue 350 } 351 best[strings.clone(key, context.temp_allocator)] = m 352 } 353 decls := make([][dynamic]Decl, len(files), context.temp_allocator) 354 for i in 0 ..< len(files) { 355 decls[i] = make([dynamic]Decl, allocator) 356 } 357 for key, m in best { 358 index, _ := strconv.parse_int(key[:strings.index_byte(key, ':')]) 359 lines := strings.split_lines(string(sources[index]), context.temp_allocator) 360 if decl, ok := read_match(sidecar, m, lines, allocator); ok { 361 append(&decls[index], decl) 362 } 363 } 364 for i in 0 ..< len(files) { 365 slice.sort_by_cmp(decls[i][:], proc(a, b: Decl) -> slice.Ordering { 366 return .Less if a.line < b.line else (.Greater if a.line > b.line else .Equal) 367 }) 368 out.files[i].decls = decls[i][:] 369 } 370 return out, .None 371 } 372 373 // prefer is whether a new match outranks the one already kept for a 374 // line: an export over a plain form, a test over the function it also is. 375 prefer :: proc(rule, old: string) -> bool { 376 if strings.contains(rule, "export") && !strings.contains(old, "export") { 377 return true 378 } 379 return strings.contains(rule, "test") && !strings.contains(old, "test") 380 } 381 382 // read_match reads one declaration out of a match: its name from the 383 // bound metavariable or the match text, its kind from the rule, and its 384 // audience from the language's notion of it. 385 read_match :: proc( 386 sidecar: Sidecar, 387 m: Grep_Match, 388 lines: []string, 389 allocator := context.allocator, 390 ) -> ( 391 decl: Decl, 392 ok: bool, 393 ) { 394 line := m.range.start.line + 1 395 decl.line = line 396 decl.end_line = line + strings.count(m.text, "\n") 397 decl.test = strings.contains(m.rule, "test") 398 switch sidecar { 399 case .Script: 400 name := "" 401 for key in ([]string{"N", "S"}) { 402 if bound, found := m.meta.single[key]; found { 403 name = bound.text 404 break 405 } 406 } 407 if decl.test { 408 name = strings.trim(name, "\"'`") 409 } 410 if name == "" { 411 return decl, false 412 } 413 decl.name = strings.clone(name, allocator) 414 decl.kind = rule_kind(m.rule) 415 decl.exported = strings.contains(m.rule, "export") 416 case .Python, .Rust: 417 rule, known := kind_rule(sidecar, m.rule) 418 if !known { 419 return decl, false 420 } 421 re, err := regex.create(rule.name, {}, context.temp_allocator, context.temp_allocator) 422 if err != nil { 423 return decl, false 424 } 425 cap, matched := regex.match(re, m.text, context.temp_allocator) 426 if !matched || len(cap.groups) < 2 { 427 return decl, false 428 } 429 decl.name = strings.clone(cap.groups[1], allocator) 430 decl.kind = rule.kind 431 decl.exported = 432 !strings.has_prefix(decl.name, "_") if sidecar == .Python else strings.has_prefix(strings.trim_space(m.text), "pub") 433 case .Go, .Odin: 434 return decl, false 435 } 436 if line - 1 < len(lines) { 437 decl.text = strings.clone(strings.trim_space(lines[line - 1]), allocator) 438 } 439 decl.doc = doc_above(lines, line, allocator) 440 decl.body = strings.clone(m.text, allocator) 441 return decl, true 442 } 443 444 kind_rule :: proc(sidecar: Sidecar, id: string) -> (Kind_Rule, bool) { 445 for r in (py_rules if sidecar == .Python else rs_rules) { 446 if r.id == id { 447 return r, true 448 } 449 } 450 return {}, false 451 } 452 453 // rule_kind turns a rule id back into the shape the findings report. 454 rule_kind :: proc(rule: string) -> string { 455 switch { 456 case strings.contains(rule, "-func-"), strings.contains(rule, "-test-"): 457 return "func" 458 case strings.contains(rule, "-type-"): 459 return "type" 460 } 461 return "value" 462 } 463 464 // doc_above collects the comment block ending on the line before the 465 // declaration, as the doc a reader would attach to it, in the C-family 466 // shapes and Python's. 467 doc_above :: proc(lines: []string, line: int, allocator := context.allocator) -> string { 468 parts := make([dynamic]string, context.temp_allocator) 469 for i := line - 2; i >= 0; i -= 1 { 470 trimmed := strings.trim_space(lines[i]) 471 if !strings.has_prefix(trimmed, "//") && 472 !strings.has_prefix(trimmed, "*") && 473 !strings.has_prefix(trimmed, "/*") && 474 (!strings.has_prefix(trimmed, "#") || strings.has_prefix(trimmed, "#!")) { 475 break 476 } 477 trimmed = strings.trim_prefix(trimmed, "#") 478 trimmed = strings.trim_prefix(trimmed, "///") 479 trimmed = strings.trim_prefix(trimmed, "//") 480 trimmed = strings.trim_prefix(trimmed, "/**") 481 trimmed = strings.trim_prefix(trimmed, "/*") 482 trimmed = strings.trim_suffix(trimmed, "*/") 483 part := strings.trim_space(strings.trim_prefix(trimmed, "*")) 484 if part != "" { 485 inject_at(&parts, 0, part) 486 } 487 } 488 return strings.join(parts[:], " ", allocator) 489 }