review

review patchsets using your default editor
Log | Files | Refs

go.odin (10495B)


      1 package analyser
      2 
      3 import "core:encoding/json"
      4 import "core:os"
      5 import "core:slice"
      6 import "core:strings"
      7 import "jm:sh"
      8 
      9 import "../finding"
     10 
     11 is_go :: proc(path: string) -> bool {
     12 	return strings.has_suffix(path, ".go")
     13 }
     14 
     15 // go_ready is whether a Go tool can run: the binary, and a module at the
     16 // tree's root.
     17 go_ready :: proc(binary: string, tree_dir: string) -> (bool, string) {
     18 	if !on_path(binary) {
     19 		return false, ""
     20 	}
     21 	if !os.is_file(join(tree_dir, "go.mod")) {
     22 		return false, "no go.mod at the repository root"
     23 	}
     24 	return true, ""
     25 }
     26 
     27 // go_packages names the packages of the files, as patterns the go command
     28 // takes. The leading ./ is load-bearing: without it a directory reads as a
     29 // module path and matches nothing.
     30 go_packages :: proc(tree_dir: string, files: []string) -> []string {
     31 	dirs := make(map[string]bool, context.temp_allocator)
     32 	for f in files {
     33 		dir := dir_of(f)
     34 		if dir == "vendor" || strings.has_prefix(dir, "vendor/") {
     35 			continue
     36 		}
     37 		if !os.is_dir(join(tree_dir, dir)) {
     38 			continue
     39 		}
     40 		// A directory under a go.mod of its own is another module's, and
     41 		// the go command run at the root cannot name it.
     42 		if nested_module(tree_dir, dir) {
     43 			continue
     44 		}
     45 		dirs[dir] = true
     46 	}
     47 	out := make([dynamic]string, context.temp_allocator)
     48 	for dir in dirs {
     49 		append(&out, strings.concatenate({"./", dir}, context.temp_allocator))
     50 	}
     51 	slice.sort(out[:])
     52 	return out[:]
     53 }
     54 
     55 // nested_module is whether a directory sits under a go.mod below the
     56 // tree's root, which makes it another module's package.
     57 nested_module :: proc(tree_dir, dir: string) -> bool {
     58 	d := dir
     59 	for d != "." && d != "" && d != "/" {
     60 		if os.is_file(join(tree_dir, join(d, "go.mod"))) {
     61 			return true
     62 		}
     63 		d = dir_of(d)
     64 	}
     65 	return false
     66 }
     67 
     68 // go_build compiles the packages the change touched. What does not
     69 // compile is the change's wherever the error lands.
     70 go_build := Analyser {
     71 	name = "go-build",
     72 	covers = is_go,
     73 	ready = proc(tree_dir: string) -> (bool, string) {return go_ready("go", tree_dir)},
     74 	run = proc(tree_dir, root: string, files: []string) -> ([]Diagnostic, string) {
     75 		pkgs := go_packages(tree_dir, files)
     76 		if len(pkgs) == 0 {
     77 			return nil, ""
     78 		}
     79 		args := make([dynamic]string, context.temp_allocator)
     80 		append(&args, "build", "-json", "-o", "/dev/null")
     81 		append(&args, ..pkgs)
     82 		out, err := execute(tree_dir, "go", args[:], context.temp_allocator)
     83 		if err != "" {
     84 			return nil, err
     85 		}
     86 		return parse_go_build(tree_dir, out), ""
     87 	},
     88 }
     89 
     90 // parse_go_build reads the compiler's errors out of go build -json: build
     91 // events whose output lines are file:line:col: message, relative to the
     92 // tree.
     93 parse_go_build :: proc(tree_dir, out: string, allocator := context.allocator) -> []Diagnostic {
     94 	Event :: struct {
     95 		action: string `json:"Action"`,
     96 		output: string `json:"Output"`,
     97 	}
     98 	found := make([dynamic]Diagnostic, allocator)
     99 	for line in lines_of(out) {
    100 		event: Event
    101 		if json.unmarshal_string(line, &event, allocator = context.temp_allocator) != nil ||
    102 		   event.action != "build-output" {
    103 			continue
    104 		}
    105 		for text in lines_of(event.output) {
    106 			if strings.has_prefix(text, "#") {
    107 				continue
    108 			}
    109 			file, number, message, ok := position(strings.trim_space(text))
    110 			if !ok {
    111 				continue
    112 			}
    113 			append(
    114 				&found,
    115 				Diagnostic {
    116 					file = strings.clone(relative(tree_dir, file), allocator),
    117 					line = number,
    118 					message = strings.clone(message, allocator),
    119 					severity = .Must_Fix,
    120 					fault = true,
    121 				},
    122 			)
    123 		}
    124 	}
    125 	return found[:]
    126 }
    127 
    128 // vet_tool is the multichecker built from sidecar/govet: vet's own
    129 // analysers and the ones from golang.org/x/tools it leaves out. When it
    130 // is on the path, vet runs it instead of its default set.
    131 vet_tool :: "review-vet"
    132 
    133 // modernizers are the names of the modernize suite's analysers, which
    134 // report an older idiom where a newer one exists.
    135 modernizers :: `any atomictypes embedlit errorsastype forvar importcomment mapsloop minmax newexpr
    136 	omitzero plusbuild rangeint reflecttypeassert reflecttypefor slicesbackward slicesclip slicescontains slicessort
    137 	stditerators stringscut stringscutprefix stringsseq stringsbuilder testingcontext unsafefuncs waitgroup`
    138 
    139 // vet_severity is how seriously to take one of vet's analysers. Vet's
    140 // default set and the bug-finding extras are faults the analyser argues
    141 // for; shadow and unusedwrite are judgement; modernize is taste.
    142 vet_severity :: proc(analyzer: string) -> finding.Severity {
    143 	switch analyzer {
    144 	case "shadow", "unusedwrite":
    145 		return .Consider
    146 	}
    147 	if strings.has_prefix(analyzer, "modernize") {
    148 		return .Note
    149 	}
    150 	for name in strings.fields(modernizers, context.temp_allocator) {
    151 		if name == analyzer {
    152 			return .Note
    153 		}
    154 	}
    155 	return .Must_Fix
    156 }
    157 
    158 // go_vet runs vet over the packages the change touched — with review-vet
    159 // where it is installed — and reads its JSON.
    160 go_vet := Analyser {
    161 	name = "go-vet",
    162 	covers = is_go,
    163 	ready = proc(tree_dir: string) -> (bool, string) {return go_ready("go", tree_dir)},
    164 	run = proc(tree_dir, root: string, files: []string) -> ([]Diagnostic, string) {
    165 		pkgs := go_packages(tree_dir, files)
    166 		if len(pkgs) == 0 {
    167 			return nil, ""
    168 		}
    169 		args := make([dynamic]string, context.temp_allocator)
    170 		append(&args, "vet", "-json")
    171 		if tool, found := sh.which(vet_tool, context.temp_allocator); found {
    172 			append(&args, strings.concatenate({"-vettool=", tool}, context.temp_allocator))
    173 		}
    174 		append(&args, ..pkgs)
    175 		out, err := execute(tree_dir, "go", args[:], context.temp_allocator)
    176 		if err != "" {
    177 			return nil, err
    178 		}
    179 		return parse_go_vet(tree_dir, out), ""
    180 	},
    181 }
    182 
    183 // Vet_Finding is one thing one of vet's analysers said.
    184 Vet_Finding :: struct {
    185 	posn:    string `json:"posn"`,
    186 	message: string `json:"message"`,
    187 }
    188 
    189 // parse_go_vet reads vet's JSON: one object per package, one list per
    190 // analyser. The JSON is preceded by a comment line naming the package, and
    191 // a package that fails to type-check is reported in prose rather than
    192 // JSON; both are skipped, since the build has already said what does not
    193 // compile.
    194 parse_go_vet :: proc(tree_dir, out: string, allocator := context.allocator) -> []Diagnostic {
    195 	found := make([dynamic]Diagnostic, allocator)
    196 	for chunk in split_json_objects(out, context.temp_allocator) {
    197 		report: map[string]map[string][]Vet_Finding
    198 		if json.unmarshal_string(chunk, &report, allocator = context.temp_allocator) != nil {
    199 			continue
    200 		}
    201 		for _, analysers in report {
    202 			for analyzer in sorted(analysers) {
    203 				for f in analysers[analyzer] {
    204 					file, line := split_position(f.posn)
    205 					if file == "" {
    206 						continue
    207 					}
    208 					append(
    209 						&found,
    210 						Diagnostic {
    211 							file = strings.clone(relative(tree_dir, file), allocator),
    212 							line = line,
    213 							code = strings.clone(analyzer, allocator),
    214 							message = strings.clone(f.message, allocator),
    215 							severity = vet_severity(analyzer),
    216 						},
    217 					)
    218 				}
    219 			}
    220 		}
    221 	}
    222 	return found[:]
    223 }
    224 
    225 // sorted is a map's keys in order.
    226 sorted :: proc(m: map[string]$V) -> []string {
    227 	keys, _ := slice.map_keys(m, context.temp_allocator)
    228 	slice.sort(keys)
    229 	return keys
    230 }
    231 
    232 // split_position reads file and line out of file:line:col.
    233 split_position :: proc(posn: string) -> (file: string, line: int) {
    234 	rest := posn
    235 	// The column, then the line, are the last two colon-separated fields.
    236 	last := strings.last_index_byte(rest, ':')
    237 	if last < 0 {
    238 		return "", 0
    239 	}
    240 	if _, is_number := parse_number(rest[last + 1:]); is_number {
    241 		prev := strings.last_index_byte(rest[:last], ':')
    242 		if prev >= 0 {
    243 			if n, ok := parse_number(rest[prev + 1:last]); ok {
    244 				return rest[:prev], n
    245 			}
    246 		}
    247 		n, _ := parse_number(rest[last + 1:])
    248 		return rest[:last], n
    249 	}
    250 	return "", 0
    251 }
    252 
    253 parse_number :: proc(s: string) -> (int, bool) {
    254 	if len(s) == 0 {
    255 		return 0, false
    256 	}
    257 	n := 0
    258 	for i in 0 ..< len(s) {
    259 		if s[i] < '0' || s[i] > '9' {
    260 			return 0, false
    261 		}
    262 		n = n * 10 + int(s[i] - '0')
    263 	}
    264 	return n, true
    265 }
    266 
    267 // staticcheck is the Go analyser beyond vet, run with JSON output over
    268 // the packages the change touched. Only a finding code — SA4006, S1002 —
    269 // is a finding about the code; a compile error the build has already
    270 // reported.
    271 staticcheck := Analyser {
    272 	name = "staticcheck",
    273 	covers = is_go,
    274 	ready = proc(tree_dir: string) -> (bool, string) {return go_ready("staticcheck", tree_dir)},
    275 	run = proc(tree_dir, root: string, files: []string) -> ([]Diagnostic, string) {
    276 		pkgs := go_packages(tree_dir, files)
    277 		if len(pkgs) == 0 {
    278 			return nil, ""
    279 		}
    280 		args := make([dynamic]string, context.temp_allocator)
    281 		append(&args, "-f", "json")
    282 		append(&args, ..pkgs)
    283 		out, err := execute(tree_dir, "staticcheck", args[:], context.temp_allocator)
    284 		if err != "" {
    285 			return nil, err
    286 		}
    287 		return parse_staticcheck(tree_dir, out), ""
    288 	},
    289 }
    290 
    291 // Problem is one finding in staticcheck's -f json output, one object per
    292 // line.
    293 Problem :: struct {
    294 	code:     string `json:"code"`,
    295 	location: struct {
    296 		file: string `json:"file"`,
    297 		line: int `json:"line"`,
    298 	} `json:"location"`,
    299 	message:  string `json:"message"`,
    300 }
    301 
    302 parse_staticcheck :: proc(tree_dir, out: string, allocator := context.allocator) -> []Diagnostic {
    303 	found := make([dynamic]Diagnostic, allocator)
    304 	for line in lines_of(out) {
    305 		p: Problem
    306 		if json.unmarshal_string(line, &p, allocator = context.temp_allocator) != nil ||
    307 		   !is_check_code(p.code) {
    308 			continue
    309 		}
    310 		append(
    311 			&found,
    312 			Diagnostic {
    313 				file = strings.clone(relative(tree_dir, p.location.file), allocator),
    314 				line = p.location.line,
    315 				code = strings.clone(p.code, allocator),
    316 				message = strings.clone(p.message, allocator),
    317 				severity = staticcheck_severity(p.code),
    318 			},
    319 		)
    320 	}
    321 	return found[:]
    322 }
    323 
    324 // is_check_code is whether a code is a staticcheck finding code: capitals
    325 // then digits.
    326 is_check_code :: proc(code: string) -> bool {
    327 	i := 0
    328 	for i < len(code) && code[i] >= 'A' && code[i] <= 'Z' {
    329 		i += 1
    330 	}
    331 	if i == 0 || i == len(code) {
    332 		return false
    333 	}
    334 	for j in i ..< len(code) {
    335 		if code[j] < '0' || code[j] > '9' {
    336 			return false
    337 		}
    338 	}
    339 	return true
    340 }
    341 
    342 // staticcheck_severity maps a staticcheck category onto the report's
    343 // severities. SA is a fault the analyser argues for; U is code that serves
    344 // nobody, and S a simplification; ST and QF are style.
    345 staticcheck_severity :: proc(code: string) -> finding.Severity {
    346 	switch {
    347 	case strings.has_prefix(code, "SA"):
    348 		return .Must_Fix
    349 	case (strings.has_prefix(code, "S") && !strings.has_prefix(code, "ST")) ||
    350 	     strings.has_prefix(code, "U"):
    351 		return .Consider
    352 	}
    353 	return .Note
    354 }