review

review patchsets using your default editor
Log | Files | Refs

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 }