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