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)
}
}