Vectrum
Sign In
457 lines · 13.9 KB · Go
package diag

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// killGrace is how long a linter that has outlived the deadline gets between
// SIGKILL and having its pipes torn down. It exists so a child that has
// already been killed cannot keep Run blocked on an inherited pipe.
const killGrace = 200 * time.Millisecond

// linter is one command-line checker: how to run it on a file and how to read
// what it printed. It is the fallback for a language with no server installed,
// and for the ones whose linter is the better tool anyway.
type linter struct {
	// name is what the model sees as the Source.
	name string
	// bin is the program looked up on PATH.
	bin string
	// invoke returns the directory to run in and the full argv, or ok=false
	// when this linter does not apply to the file after all — tsc without a
	// tsconfig.json, for instance.
	invoke func(path string) (dir string, argv []string, ok bool)
	// parse turns the program's output into diagnostics. Relative file names
	// in the output are relative to dir. It receives both streams because
	// compilers write to stderr and linters to stdout, and which is which is
	// not worth remembering per tool.
	parse func(stdout, stderr []byte, dir string) []Diagnostic
	// then, if set, is run only when this one found nothing. It is how Go gets
	// both the compiler's errors and vet's findings without paying for vet on
	// a file that does not compile.
	then *linter
}

// lintersFor returns the linters that could check path, best first.
func lintersFor(path string) []*linter {
	if strings.HasSuffix(path, "_test.go") {
		// go build ignores test files, so the compiler cannot be asked about
		// one; vet type-checks them and reports the first error, which is
		// better than nothing and all that is on offer without a server.
		return []*linter{goVet}
	}
	return linters[strings.ToLower(filepath.Ext(path))]
}

// linters is the table of language → command, keyed by extension, in order of
// preference. Only the first entry whose program is installed runs.
//
// Rust is deliberately absent. `cargo check --message-format=json` is the
// right tool, but it checks the whole crate and its dependencies, and on a
// cold target directory that is tens of seconds to minutes — far past
// checkTimeout on every edit until the cache is warm, and several seconds
// after. A linter that always times out is a linter that only adds latency.
// Rust is covered by rust-analyzer when it is installed, which does the same
// work once and keeps it in memory.
var linters = map[string][]*linter{
	".go":   {goBuild},
	".py":   {ruff, pyflakes},
	".ts":   {eslint, tsc},
	".tsx":  {eslint, tsc},
	".mts":  {eslint, tsc},
	".cts":  {eslint, tsc},
	".js":   {eslint},
	".jsx":  {eslint},
	".mjs":  {eslint},
	".cjs":  {eslint},
	".sh":   {shellcheck},
	".bash": {shellcheck},
}

// Check runs the linter and parses its output. A non-zero exit is not an
// error — every linter exits non-zero when it finds something — so failure is
// judged by whether the output could be read: nothing parseable plus a
// non-zero exit is a linter that did not run.
func (l *linter) Check(ctx context.Context, path string) ([]Diagnostic, error) {
	dir, argv, ok := l.invoke(path)
	if !ok {
		return nil, fmt.Errorf("%s does not apply to %s", l.name, path)
	}
	// No shell, and the path goes to execve as it stands. It was jailed by the
	// tool before it got here, but a file name containing a space or a
	// semicolon is still a file name and not two arguments.
	cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
	cmd.Dir = dir
	var stdout, stderr bytes.Buffer
	cmd.Stdout, cmd.Stderr = &stdout, &stderr
	// A linter that has outlived the deadline is killed, not waited for. Go's
	// default on cancel is SIGKILL, and a child that ignores it is not
	// something waiting longer would fix.
	cmd.WaitDelay = killGrace
	runErr := cmd.Run()
	if ctx.Err() != nil {
		return nil, fmt.Errorf("%s: %w", l.name, ctx.Err())
	}
	ds := l.parse(stdout.Bytes(), stderr.Bytes(), dir)
	for i := range ds {
		if ds[i].Source == "" {
			ds[i].Source = l.name
		}
	}
	if len(ds) == 0 {
		if runErr != nil {
			var exit *exec.ExitError
			if errors.As(runErr, &exit) {
				return nil, fmt.Errorf("%s exited %d without a readable diagnostic: %s", l.name, exit.ExitCode(), preview(stderr.Bytes(), stdout.Bytes()))
			}
			return nil, fmt.Errorf("%s: %w", l.name, runErr)
		}
		if l.then != nil {
			return l.then.Check(ctx, path)
		}
	}
	return ds, nil
}

// Go.
//
// The compiler is run first because vet stops at the first type error and the
// compiler reports them all; vet is run only on a file that compiles, for the
// findings the compiler does not make (Printf verbs, unreachable code). Both
// take well under a second on a warm build cache, which the agent's own bash
// calls keep warm. gofmt was considered and rejected: it reports files, not
// lines, and a formatting difference is not a broken build.
var goBuild = &linter{
	name:   "go build",
	bin:    "go",
	invoke: goInvoke("build", "-gcflags=-e", "-o", os.DevNull),
	parse:  parseGoOutput,
	then:   goVet,
}

var goVet = &linter{
	name:   "go vet",
	bin:    "go",
	invoke: goInvoke("vet"),
	parse:  parseGoOutput,
}

// goInvoke runs a go subcommand on the file's package when it is in a module,
// and on the file alone when it is not — go build with no go.mod in sight
// refuses the package form but accepts a list of files.
func goInvoke(sub string, flags ...string) func(string) (string, []string, bool) {
	return func(path string) (string, []string, bool) {
		dir := filepath.Dir(path)
		argv := append([]string{"go", sub}, flags...)
		if ancestorWith(dir, []string{"go.mod"}) != "" {
			return dir, append(argv, "."), true
		}
		return dir, append(argv, filepath.Base(path)), true
	}
}

// goLine matches the compiler's and vet's one line per problem:
//
//	./main.go:7:6: undefined: fmt.Printl
//	vet: ./main.go:7:6: undefined: fmt.Printl
//	other.go:6:14: fmt.Printf format %d has arg "s" of wrong type string
//
// The column is optional because some vet analyzers omit it. Lines beginning
// with # are package headers and are skipped, as is "too many errors".
var goLine = regexp.MustCompile(`^(?:vet: )?(.+?\.go):(\d+)(?::(\d+))?: (.+)$`)

func parseGoOutput(stdout, stderr []byte, dir string) []Diagnostic {
	var out []Diagnostic
	for _, line := range strings.Split(string(stderr)+"\n"+string(stdout), "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		m := goLine.FindStringSubmatch(line)
		if m == nil {
			continue
		}
		out = append(out, Diagnostic{
			File:     absIn(dir, m[1]),
			Line:     atoi(m[2]),
			Col:      atoi(m[3]),
			Severity: SeverityError,
			Message:  m[4],
		})
	}
	return out
}

// Python. ruff when present because it is fast and prints JSON; pyflakes
// otherwise, whose one-line-per-problem output is stable enough to match.
var ruff = &linter{
	name: "ruff",
	bin:  "ruff",
	invoke: func(path string) (string, []string, bool) {
		return filepath.Dir(path), []string{"ruff", "check", "--output-format=json", "--exit-zero", path}, true
	},
	parse: parseRuff,
}

// parseRuff reads `ruff check --output-format=json`: an array of findings,
// each with a location and a rule code.
func parseRuff(stdout, _ []byte, dir string) []Diagnostic {
	var items []struct {
		Code     string `json:"code"`
		Filename string `json:"filename"`
		Message  string `json:"message"`
		Location struct {
			Row    int `json:"row"`
			Column int `json:"column"`
		} `json:"location"`
	}
	if err := json.Unmarshal(bytes.TrimSpace(stdout), &items); err != nil {
		return nil
	}
	out := make([]Diagnostic, 0, len(items))
	for _, it := range items {
		msg := it.Message
		if it.Code != "" {
			msg = it.Code + " " + msg
		}
		out = append(out, Diagnostic{
			File:     absIn(dir, it.Filename),
			Line:     it.Location.Row,
			Col:      it.Location.Column,
			Severity: ruffSeverity(it.Code),
			Message:  msg,
		})
	}
	return out
}

// ruffSeverity: ruff has no severity of its own, so syntax errors and the
// pyflakes family (F…, which includes undefined names) are errors and every
// style rule is a warning.
func ruffSeverity(code string) Severity {
	if code == "" || strings.HasPrefix(code, "F") || strings.HasPrefix(code, "E9") {
		return SeverityError
	}
	return SeverityWarning
}

var pyflakes = &linter{
	name: "pyflakes",
	bin:  "pyflakes",
	invoke: func(path string) (string, []string, bool) {
		return filepath.Dir(path), []string{"pyflakes", path}, true
	},
	parse: parseColonLines("pyflakes", SeverityError),
}

// colonLine matches the file:line:col: message convention shared by pyflakes,
// flake8, gcc and most tools written by people who have used gcc. The column
// is optional because older pyflakes did not print one.
var colonLine = regexp.MustCompile(`^(.+?):(\d+)(?::(\d+))?:\s*(.+)$`)

func parseColonLines(_ string, sev Severity) func(stdout, stderr []byte, dir string) []Diagnostic {
	return func(stdout, stderr []byte, dir string) []Diagnostic {
		var out []Diagnostic
		for _, line := range strings.Split(string(stdout)+"\n"+string(stderr), "\n") {
			m := colonLine.FindStringSubmatch(strings.TrimSpace(line))
			if m == nil {
				continue
			}
			out = append(out, Diagnostic{
				File:     absIn(dir, m[1]),
				Line:     atoi(m[2]),
				Col:      atoi(m[3]),
				Severity: sev,
				Message:  m[4],
			})
		}
		return out
	}
}

// TypeScript and JavaScript. eslint on the one file when it is installed; tsc
// on the project otherwise, and only when a tsconfig.json is in reach, because
// tsc without one type-checks against defaults nobody's code was written for
// and reports a wall of noise.
var eslint = &linter{
	name: "eslint",
	bin:  "eslint",
	invoke: func(path string) (string, []string, bool) {
		dir := ancestorWith(filepath.Dir(path), []string{"package.json"})
		if dir == "" {
			dir = filepath.Dir(path)
		}
		return dir, []string{"eslint", "--format", "json", "--no-error-on-unmatched-pattern", path}, true
	},
	parse: parseESLint,
}

// parseESLint reads `eslint --format json`: an array of files, each with an
// array of messages. severity 2 is an error, 1 a warning.
func parseESLint(stdout, _ []byte, dir string) []Diagnostic {
	var files []struct {
		FilePath string `json:"filePath"`
		Messages []struct {
			RuleID   string `json:"ruleId"`
			Severity int    `json:"severity"`
			Message  string `json:"message"`
			Line     int    `json:"line"`
			Column   int    `json:"column"`
		} `json:"messages"`
	}
	if err := json.Unmarshal(bytes.TrimSpace(stdout), &files); err != nil {
		return nil
	}
	var out []Diagnostic
	for _, f := range files {
		for _, m := range f.Messages {
			sev := SeverityWarning
			if m.Severity == 2 {
				sev = SeverityError
			}
			msg := m.Message
			if m.RuleID != "" {
				msg += " (" + m.RuleID + ")"
			}
			out = append(out, Diagnostic{
				File:     absIn(dir, f.FilePath),
				Line:     m.Line,
				Col:      m.Column,
				Severity: sev,
				Message:  msg,
			})
		}
	}
	return out
}

var tsc = &linter{
	name: "tsc",
	bin:  "tsc",
	invoke: func(path string) (string, []string, bool) {
		dir := ancestorWith(filepath.Dir(path), []string{"tsconfig.json"})
		if dir == "" {
			return "", nil, false
		}
		return dir, []string{"tsc", "--noEmit", "--pretty", "false", "-p", "tsconfig.json"}, true
	},
	parse: parseTSC,
}

// tscLine matches tsc's non-pretty output:
//
//	src/index.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'.
var tscLine = regexp.MustCompile(`^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$`)

func parseTSC(stdout, stderr []byte, dir string) []Diagnostic {
	var out []Diagnostic
	for _, line := range strings.Split(string(stdout)+"\n"+string(stderr), "\n") {
		m := tscLine.FindStringSubmatch(strings.TrimSpace(line))
		if m == nil {
			continue
		}
		sev := SeverityError
		if m[4] == "warning" {
			sev = SeverityWarning
		}
		out = append(out, Diagnostic{
			File:     absIn(dir, m[1]),
			Line:     atoi(m[2]),
			Col:      atoi(m[3]),
			Severity: sev,
			Message:  m[5] + ": " + m[6],
		})
	}
	return out
}

// Shell.
var shellcheck = &linter{
	name: "shellcheck",
	bin:  "shellcheck",
	invoke: func(path string) (string, []string, bool) {
		return filepath.Dir(path), []string{"shellcheck", "--format=json", path}, true
	},
	parse: parseShellcheck,
}

// parseShellcheck reads `shellcheck --format=json`: a flat array with a level
// of error, warning, info or style.
func parseShellcheck(stdout, _ []byte, dir string) []Diagnostic {
	var items []struct {
		File    string `json:"file"`
		Line    int    `json:"line"`
		Column  int    `json:"column"`
		Level   string `json:"level"`
		Code    int    `json:"code"`
		Message string `json:"message"`
	}
	if err := json.Unmarshal(bytes.TrimSpace(stdout), &items); err != nil {
		return nil
	}
	out := make([]Diagnostic, 0, len(items))
	for _, it := range items {
		sev := SeverityWarning
		switch it.Level {
		case "error":
			sev = SeverityError
		case "info":
			sev = SeverityInfo
		case "style":
			sev = SeverityHint
		}
		out = append(out, Diagnostic{
			File:     absIn(dir, it.File),
			Line:     it.Line,
			Col:      it.Column,
			Severity: sev,
			Message:  fmt.Sprintf("SC%d %s", it.Code, it.Message),
		})
	}
	return out
}

// absIn resolves a file name printed by a tool run in dir.
func absIn(dir, name string) string {
	name = filepath.FromSlash(name)
	if filepath.IsAbs(name) {
		return filepath.Clean(name)
	}
	return filepath.Join(dir, name)
}

func atoi(s string) int {
	n, _ := strconv.Atoi(s)
	return n
}

// preview is the first part of what the command printed, for the error a
// failed run carries. It is never shown to the model, but a test or a future
// debug log that says "exited 2" without the output sends someone to run the
// command by hand to learn what this process already knew.
func preview(streams ...[]byte) string {
	const max = 200
	for _, s := range streams {
		t := strings.TrimSpace(string(s))
		if t == "" {
			continue
		}
		t = strings.Join(strings.Fields(t), " ")
		if len(t) > max {
			t = t[:max] + "…"
		}
		return t
	}
	return "(nothing)"
}