package diag import ( "context" "os" "path/filepath" "reflect" "strings" "testing" "time" ) func fixture(t *testing.T, name string) []byte { t.Helper() b, err := os.ReadFile(filepath.Join("testdata", name)) if err != nil { t.Fatal(err) } return b } // The parsers are tested against captured output rather than by running the // tools, so the tests pass on a machine with none of them installed and keep // passing when a tool's next version is not on PATH yet. go_build.txt and // go_vet.txt are real output from this machine's toolchain; the others follow // each tool's documented format. func TestParseGoBuild(t *testing.T) { ds := parseGoOutput(nil, fixture(t, "go_build.txt"), "/proj") want := []Diagnostic{ {File: "/proj/main.go", Line: 6, Col: 2, Severity: SeverityError, Message: "declared and not used: n"}, {File: "/proj/main.go", Line: 7, Col: 6, Severity: SeverityError, Message: "undefined: fmt.Printl"}, } if !reflect.DeepEqual(ds, want) { t.Errorf("got %+v\nwant %+v", ds, want) } } func TestParseGoVetStripsThePrefixAndHeaders(t *testing.T) { ds := parseGoOutput(nil, fixture(t, "go_vet.txt"), "/proj") want := []Diagnostic{ {File: "/proj/main.go", Line: 7, Col: 6, Severity: SeverityError, Message: "undefined: fmt.Printl"}, {File: "/proj/other.go", Line: 6, Col: 14, Severity: SeverityError, Message: `fmt.Printf format %d has arg "s" of wrong type string`}, } if !reflect.DeepEqual(ds, want) { t.Errorf("got %+v\nwant %+v", ds, want) } } func TestParseRuff(t *testing.T) { ds := parseRuff(fixture(t, "ruff.json"), nil, "/tmp/proj") if len(ds) != 3 { t.Fatalf("got %d diagnostics: %+v", len(ds), ds) } if ds[0].File != "/tmp/proj/app.py" || ds[0].Line != 1 || ds[0].Col != 8 || ds[0].Message != "F401 `os` imported but unused" { t.Errorf("first: %+v", ds[0]) } if ds[1].Severity != SeverityError || ds[1].Line != 4 { t.Errorf("undefined name should be an error: %+v", ds[1]) } if ds[2].Severity != SeverityWarning { t.Errorf("a style rule should be a warning: %+v", ds[2]) } if parseRuff([]byte("not json"), nil, "/") != nil { t.Error("garbage should parse to nothing, not panic or invent") } } func TestParsePyflakes(t *testing.T) { ds := pyflakes.parse(fixture(t, "pyflakes.txt"), nil, "/tmp/proj") want := []Diagnostic{ {File: "/tmp/proj/app.py", Line: 1, Col: 1, Severity: SeverityError, Message: "'os' imported but unused"}, {File: "/tmp/proj/app.py", Line: 4, Col: 12, Severity: SeverityError, Message: "undefined name 'missing'"}, } if !reflect.DeepEqual(ds, want) { t.Errorf("got %+v\nwant %+v", ds, want) } // Older pyflakes has no column. old := pyflakes.parse([]byte("app.py:2: invalid syntax\n"), nil, "/p") if len(old) != 1 || old[0].Line != 2 || old[0].Col != 0 { t.Errorf("column-less line: %+v", old) } } func TestParseESLint(t *testing.T) { ds := parseESLint(fixture(t, "eslint.json"), nil, "/tmp/proj") if len(ds) != 2 { t.Fatalf("got %+v", ds) } if ds[0].Severity != SeverityError || ds[0].Line != 3 || ds[0].Col != 7 || !strings.Contains(ds[0].Message, "(@typescript-eslint/no-unused-vars)") { t.Errorf("first: %+v", ds[0]) } if ds[1].Severity != SeverityWarning || ds[1].File != "/tmp/proj/src/index.ts" { t.Errorf("second: %+v", ds[1]) } } func TestParseTSC(t *testing.T) { ds := parseTSC(fixture(t, "tsc.txt"), nil, "/tmp/proj") want := []Diagnostic{ {File: "/tmp/proj/src/index.ts", Line: 3, Col: 7, Severity: SeverityError, Message: "TS2322: Type 'string' is not assignable to type 'number'."}, {File: "/tmp/proj/src/other.ts", Line: 10, Col: 3, Severity: SeverityError, Message: "TS2339: Property 'foo' does not exist on type 'Bar'."}, } if !reflect.DeepEqual(ds, want) { t.Errorf("got %+v\nwant %+v", ds, want) } } func TestParseShellcheck(t *testing.T) { ds := parseShellcheck(fixture(t, "shellcheck.json"), nil, "/tmp/proj") if len(ds) != 3 { t.Fatalf("got %+v", ds) } if ds[0].Severity != SeverityWarning || ds[0].Line != 3 || ds[0].Col != 6 || !strings.HasPrefix(ds[0].Message, "SC2034 ") { t.Errorf("first: %+v", ds[0]) } if ds[1].Severity != SeverityError { t.Errorf("level error: %+v", ds[1]) } if ds[2].Severity != SeverityHint { t.Errorf("level style should be a hint, so Format drops it: %+v", ds[2]) } } func TestLintersForPicksByExtensionAndTestFiles(t *testing.T) { if ls := lintersFor("/p/main.go"); len(ls) != 1 || ls[0] != goBuild { t.Errorf(".go → %v", ls) } if ls := lintersFor("/p/main_test.go"); len(ls) != 1 || ls[0] != goVet { t.Errorf("_test.go → %v", ls) } if ls := lintersFor("/p/x.PY"); len(ls) != 2 || ls[0] != ruff { t.Errorf(".PY → %v", ls) } if ls := lintersFor("/p/Cargo.rs"); ls != nil { t.Errorf("rust should have no linter: %v", ls) } if ls := lintersFor("/p/README.md"); ls != nil { t.Errorf("markdown → %v", ls) } } func TestTSCDoesNotApplyWithoutATsconfig(t *testing.T) { dir := t.TempDir() if _, _, ok := tsc.invoke(filepath.Join(dir, "a.ts")); ok { t.Error("tsc offered itself with no tsconfig.json") } if err := os.WriteFile(filepath.Join(dir, "tsconfig.json"), []byte("{}"), 0o644); err != nil { t.Fatal(err) } sub := filepath.Join(dir, "src") _ = os.Mkdir(sub, 0o755) runDir, argv, ok := tsc.invoke(filepath.Join(sub, "a.ts")) if !ok || runDir != dir || argv[0] != "tsc" { t.Errorf("got %q %v %v", runDir, argv, ok) } } func TestGoInvokeUsesThePackageInsideAModuleAndTheFileOutside(t *testing.T) { mod := t.TempDir() if err := os.WriteFile(filepath.Join(mod, "go.mod"), []byte("module m\n"), 0o644); err != nil { t.Fatal(err) } pkg := filepath.Join(mod, "pkg") _ = os.Mkdir(pkg, 0o755) dir, argv, _ := goVet.invoke(filepath.Join(pkg, "a.go")) if dir != pkg || argv[len(argv)-1] != "." { t.Errorf("inside a module: %s %v", dir, argv) } loose := t.TempDir() dir, argv, _ = goVet.invoke(filepath.Join(loose, "a.go")) if dir != loose || argv[len(argv)-1] != "a.go" { t.Errorf("outside a module: %s %v", dir, argv) } } // The Go toolchain is the one linter guaranteed to be present wherever these // tests run, so it is the one exercised for real. func TestGoLinterEndToEnd(t *testing.T) { if testing.Short() { t.Skip("runs the go tool") } dir := t.TempDir() write := func(name, content string) { if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } } write("go.mod", "module scratch\n\ngo 1.22\n") write("main.go", "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tn := 3\n\tfmt.Printl(\"hi\")\n}\n") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ds, err := goBuild.Check(ctx, filepath.Join(dir, "main.go")) if err != nil { t.Fatalf("go build linter: %v", err) } var msgs []string for _, d := range ds { msgs = append(msgs, d.Message) if d.Source != "go build" || d.File != filepath.Join(dir, "main.go") { t.Errorf("stamped wrong: %+v", d) } } joined := strings.Join(msgs, "|") if !strings.Contains(joined, "undefined: fmt.Printl") || !strings.Contains(joined, "declared and not used") { t.Errorf("compiler errors missing: %v", msgs) } // Compiles, but vet has something to say: the fallthrough to vet. write("main.go", "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Printf(\"%d\", \"s\")\n}\n") ds, err = goBuild.Check(ctx, filepath.Join(dir, "main.go")) if err != nil { t.Fatalf("go vet fallthrough: %v", err) } if len(ds) != 1 || ds[0].Source != "go vet" || !strings.Contains(ds[0].Message, "Printf format") { t.Errorf("vet finding: %+v", ds) } // Clean: nothing, and no error. write("main.go", "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n") ds, err = goBuild.Check(ctx, filepath.Join(dir, "main.go")) if err != nil || len(ds) != 0 { t.Errorf("clean file: %v, %+v", err, ds) } } func TestLinterTimeoutIsAnErrorNotAHang(t *testing.T) { sleeper := &linter{ name: "sleeper", bin: "sleep", invoke: func(path string) (string, []string, bool) { return filepath.Dir(path), []string{"sleep", "10"}, true }, parse: func(_, _ []byte, _ string) []Diagnostic { return nil }, } ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() start := time.Now() _, err := sleeper.Check(ctx, filepath.Join(t.TempDir(), "x")) if err == nil { t.Error("a linter that outlived the deadline returned success") } if took := time.Since(start); took > 2*time.Second { t.Errorf("Check took %s past a 100ms deadline", took) } }