Vectrum
Sign In
247 lines · 8.3 KB · Go
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)
	}
}