Vectrum
Sign In
429 lines · 14.6 KB · Go
// Package diag is what checks a file the agent has just written.
//
// The agent edits a file and, today, learns that it broke the build three tool
// calls later, when it runs the tests or the user complains. The compiler
// already knew at the moment of the edit. This package runs whatever this
// machine has for the language — a language server if one is installed, a
// linter otherwise — against the file that just changed, and hands what it
// said back to the edit and write tools so it lands in the result the model is
// already reading. Nothing new is installed and nothing leaves the machine.
//
// It is one package rather than part of internal/tools for the same reason
// websearch is: the tools call Report and everything about which programs are
// run, how long they may take and how their output is read lives here, where
// it can be read in one sitting. It imports no other internal package.
package diag

import (
	"context"
	"fmt"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"
)

// checkTimeout is how long one check may take, start to finish, including a
// language server that is still starting up.
//
// The model waits through this on every edit, so it is a fraction of the bash
// tool's minute rather than a multiple of it: two seconds of latency per edit
// is the price of the compiler's opinion, and a check that has not finished by
// then is a check to abandon. It is short enough that gopls on a cold cache
// will usually miss it on the very first edit of a session — that is
// accepted, because the server keeps loading in the background and every edit
// after that gets an answer in well under a second.
const checkTimeout = 2 * time.Second

// settleWindow is how long, after the first batch of diagnostics for the
// changed file arrives, a check goes on listening for more.
//
// A language server does not answer a didOpen with one tidy response. gopls
// type-checks the package and publishes a batch per file in it, and some
// servers publish an empty batch first to clear stale marks and the real one a
// moment later. Returning on the first batch would report a clean file that is
// about to be reported broken; waiting for the deadline would make every clean
// edit cost the full timeout. A short window after the first batch collects
// the neighbours and the correction without paying for silence.
const settleWindow = 250 * time.Millisecond

// startCooldown is how long a server that failed to start is left alone. Long
// enough that a broken install is not retried on every edit; short enough
// that fixing the install mid-session takes effect without a restart.
const startCooldown = 5 * time.Minute

// maxReported is how many diagnostics are shown to the model. Ten covers a
// real mistake in full; a hundred is a file so broken that the model needs to
// read it again rather than a list.
const maxReported = 10

// Severity is how serious a diagnostic is, using the LSP's four levels.
type Severity int

// Severities, from worst to least. The numbering is the LSP's so that a
// server's value can be used as it stands.
const (
	SeverityError   Severity = 1
	SeverityWarning Severity = 2
	SeverityInfo    Severity = 3
	SeverityHint    Severity = 4
)

// String returns the lower-case name a diagnostic line carries.
func (s Severity) String() string {
	switch s {
	case SeverityError:
		return "error"
	case SeverityWarning:
		return "warning"
	case SeverityInfo:
		return "info"
	case SeverityHint:
		return "hint"
	}
	return fmt.Sprintf("severity(%d)", int(s))
}

// Diagnostic is one thing a checker had to say about a file. It is the common
// shape every backend is parsed into, so the formatting and the cap are written
// once.
type Diagnostic struct {
	// File is the absolute path the diagnostic is about. It is usually the
	// file that was checked, but a package-level tool reports neighbours too.
	File string
	// Line and Col are 1-based. Col is 0 when the tool did not say.
	Line, Col int
	Severity  Severity
	Message   string
	// Source is which program said it — "gopls", "ruff" — so the model knows
	// how much to trust it and the user knows what to install or uninstall.
	Source string
}

// Checker is one way of checking a file. The two implementations are a
// language server and a linter command; the interface exists so the Runner
// picks between them without caring which it got, and so tests can hand it a
// fake.
type Checker interface {
	// Check returns what the backend has to say about the file at path. A nil
	// slice with a nil error is a clean file. An error means the backend could
	// not run — not installed, timed out, unparseable output — and is never
	// shown to the model; it is silence with a reason.
	Check(ctx context.Context, path string) ([]Diagnostic, error)
}

// Runner is the one object the tools hold. It owns the language servers that
// stay running between edits and chooses, per file, between a server and a
// linter. A nil *Runner is valid and does nothing, which is how the feature is
// turned off: every method tolerates a nil receiver so the call sites need no
// switch of their own.
type Runner struct {
	mu      sync.Mutex
	servers map[string]*session
	// cooldown records, per server key, when it may next be started. A
	// server that failed to start is not tried again for a while, and in the
	// meantime the language's linter is used instead; without this a broken
	// gopls would be respawned on every Go edit and each edit would wait the
	// full deadline for it.
	cooldown map[string]time.Time
	closed   bool

	// lookPath is exec.LookPath unless a test has replaced it, so the choice
	// between server and linter can be exercised on a machine with neither.
	lookPath func(string) (string, error)
	// idle is how long a server may sit unused before it is shut down.
	idle time.Duration
}

// NewRunner returns a Runner with no servers started. Servers are started on
// the first file that needs one, so a session that never touches Go never pays
// for gopls.
func NewRunner() *Runner {
	return &Runner{
		servers:  map[string]*session{},
		cooldown: map[string]time.Time{},
		lookPath: exec.LookPath,
		idle:     idleTimeout,
	}
}

// Report checks path and returns the block of text to append to a tool result.
// root is what the file names in that text are made relative to — the working
// root the model already thinks in.
//
// It returns "" when there is nothing to say, and that covers both a clean
// file and a check that could not run. Silence is the good news, and a "0
// problems" line on every edit would be pure token cost, paid on the great
// majority of edits, for information the absence of a block already carries.
// A backend failure is silent for the same reason a missing backend is: the
// edit succeeded, and the model has nothing to do about a linter that did not
// start.
func (r *Runner) Report(ctx context.Context, root, path string) (out string) {
	if r == nil {
		return ""
	}
	// The edit has already been applied by the time this runs. A bug in here
	// must not turn a successful edit into a crashed session, so this is the
	// one place in the harness a panic is caught rather than propagated.
	defer func() {
		if rec := recover(); rec != nil {
			out = ""
		}
	}()
	ctx, cancel := context.WithTimeout(ctx, checkTimeout)
	defer cancel()
	ds, err := r.Check(ctx, path)
	if err != nil || len(ds) == 0 {
		return ""
	}
	return Format(ds, root, path)
}

// Check runs the best available checker for path. It picks a language server
// when one for the file's language is on PATH, otherwise the first installed
// linter for its extension. It satisfies Checker so a Runner can stand in
// wherever one is wanted.
func (r *Runner) Check(ctx context.Context, path string) ([]Diagnostic, error) {
	if r == nil {
		return nil, nil
	}
	path, err := filepath.Abs(path)
	if err != nil {
		return nil, err
	}
	if s := r.server(path); s != nil {
		return s.Check(ctx, path)
	}
	if l := r.linter(path); l != nil {
		return l.Check(ctx, path)
	}
	return nil, errNoChecker
}

// Close shuts down every language server the Runner has started. It is safe to
// call on a nil Runner and more than once. Servers are shut down concurrently
// with a short deadline each, because a server that ignores shutdown must not
// hold up exit.
func (r *Runner) Close() {
	if r == nil {
		return
	}
	r.mu.Lock()
	r.closed = true
	servers := r.servers
	r.servers = map[string]*session{}
	r.mu.Unlock()

	var wg sync.WaitGroup
	for _, s := range servers {
		wg.Add(1)
		go func(s *session) {
			defer wg.Done()
			s.Close()
		}(s)
	}
	wg.Wait()
}

// server returns the running or starting language server for path's language
// and workspace, starting one if the program is installed and none is running.
// It returns nil when no server applies.
func (r *Runner) server(path string) *session {
	s, _ := r.lookup(path)
	return s
}

// lookup is server with the reason for a nil. Check has no use for the reason
// — a file it cannot check is silence either way — but a query the model
// made by name deserves to hear whether the answer is "install gopls" or
// "this is a Markdown file", and those are different things to do next.
func (r *Runner) lookup(path string) (*session, error) {
	spec, ok := serverFor(path)
	if !ok {
		what := strings.ToLower(filepath.Ext(path))
		if what == "" {
			what = filepath.Base(path)
		}
		return nil, fmt.Errorf("%w (%s)", ErrNoServer, what)
	}
	bin, err := r.lookPath(spec.Bin)
	if err != nil {
		return nil, fmt.Errorf("%w: %s handles %s files but is not on PATH", ErrNotInstalled, spec.Bin, strings.ToLower(filepath.Ext(path)))
	}
	root := workspaceRoot(path, spec.Markers)
	key := root + "\x00" + spec.Bin

	r.mu.Lock()
	defer r.mu.Unlock()
	if r.closed {
		return nil, fmt.Errorf("%s: %w", spec.Bin, errServerDead)
	}
	if s, ok := r.servers[key]; ok && !s.Dead() {
		return s, nil
	}
	if until, ok := r.cooldown[key]; ok && time.Now().Before(until) {
		return nil, fmt.Errorf("%s: %w; not retried for another %s", spec.Bin, ErrStartFailed, time.Until(until).Round(time.Second))
	}
	s := startServer(bin, spec, root, r.idle, func(s *session) { r.exited(key, s) })
	if s == nil {
		r.cooldown[key] = time.Now().Add(startCooldown)
		return nil, fmt.Errorf("%s: %w", spec.Bin, ErrStartFailed)
	}
	r.servers[key] = s
	return s, nil
}

// exited is called once a server's process has gone — an idle shutdown, a
// crash, a refusal to start. It drops the server from the table so the next
// file in that language starts a fresh one rather than talking to a corpse,
// and if this one never got as far as answering initialize, puts the key on
// cooldown. The identity check matters: a replacement may already be
// registered under the same key, and forgetting that one would leak it.
func (r *Runner) exited(key string, s *session) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.servers[key] == s {
		delete(r.servers, key)
	}
	if s.failedToStart() {
		r.cooldown[key] = time.Now().Add(startCooldown)
	}
}

// linter returns the first installed linter for path's extension, or nil.
func (r *Runner) linter(path string) Checker {
	for _, l := range lintersFor(path) {
		if _, err := r.lookPath(l.bin); err == nil {
			return l
		}
	}
	return nil
}

// errNoChecker is what Check returns when nothing on this machine can check
// the file. It is an error rather than an empty result so a caller that wants
// to tell "clean" from "unchecked" can.
var errNoChecker = fmt.Errorf("no language server or linter installed for this file")

// Format renders diagnostics for the model, or "" when there are none. File
// names are made relative to root; path is the file that was checked, whose
// diagnostics are listed first so the cap drops a neighbour's problems before
// the model's own.
func Format(ds []Diagnostic, root, path string) string {
	ds = keep(ds)
	if len(ds) == 0 {
		return ""
	}
	sortForReport(ds, path)

	source := ds[0].Source
	for _, d := range ds[1:] {
		if d.Source != source {
			source = "mixed"
			break
		}
	}
	var b strings.Builder
	fmt.Fprintf(&b, "diagnostics (%s): %s", source, plural(len(ds), "problem"))
	shown := ds
	if len(shown) > maxReported {
		shown = shown[:maxReported]
	}
	for _, d := range shown {
		b.WriteString("\n  ")
		b.WriteString(relative(root, d.File))
		if d.Line > 0 {
			fmt.Fprintf(&b, ":%d", d.Line)
			if d.Col > 0 {
				fmt.Fprintf(&b, ":%d", d.Col)
			}
		}
		b.WriteString(": ")
		// Errors are the default reading of a diagnostic line, so only the
		// lesser levels are labelled; the label is what tells the model it
		// may leave the line alone.
		if d.Severity != SeverityError && d.Severity != 0 {
			b.WriteString(d.Severity.String())
			b.WriteString(": ")
		}
		b.WriteString(oneLine(d.Message))
	}
	if dropped := len(ds) - len(shown); dropped > 0 {
		fmt.Fprintf(&b, "\n  … and %d more not shown", dropped)
	}
	return b.String()
}

// keep drops the levels not worth the model's attention. Hints are a server's
// stylistic asides — gopls's "could be simplified", pyright's greyed-out
// unused import — and a model that fixes every hint it is shown rewrites code
// it was not asked to touch.
func keep(ds []Diagnostic) []Diagnostic {
	out := make([]Diagnostic, 0, len(ds))
	for _, d := range ds {
		if d.Severity == SeverityHint {
			continue
		}
		if strings.TrimSpace(d.Message) == "" {
			continue
		}
		out = append(out, d)
	}
	return out
}

// sortForReport orders the changed file first, then by severity, then by
// position, so the cap cuts from the least important end.
func sortForReport(ds []Diagnostic, path string) {
	sort.SliceStable(ds, func(i, j int) bool {
		a, b := ds[i], ds[j]
		if (a.File == path) != (b.File == path) {
			return a.File == path
		}
		if a.File != b.File {
			return a.File < b.File
		}
		if a.Severity != b.Severity {
			return a.Severity < b.Severity
		}
		if a.Line != b.Line {
			return a.Line < b.Line
		}
		return a.Col < b.Col
	})
}

// relative is path as the model would write it: relative to root when it is
// under root, and as it stands otherwise. A neighbour above the working root
// — a go.mod one directory up — is shown with its ../ rather than hidden.
func relative(root, path string) string {
	if root == "" {
		return path
	}
	rel, err := filepath.Rel(root, path)
	if err != nil {
		return path
	}
	return filepath.ToSlash(rel)
}

// oneLine folds a multi-line message onto one line. Compiler messages are
// almost always one line; the ones that are not (rustc's, with its notes and
// help) would otherwise break the one-problem-per-line shape the model is
// parsing by eye.
func oneLine(s string) string {
	s = strings.TrimSpace(s)
	if !strings.ContainsAny(s, "\r\n") {
		return s
	}
	return strings.Join(strings.Fields(s), " ")
}

func plural(n int, noun string) string {
	if n == 1 {
		return fmt.Sprintf("1 %s", noun)
	}
	return fmt.Sprintf("%d %ss", n, noun)
}