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 }