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