Diagnosticsimplementation

Suggested Fixes

A diagnostic that carries an edit an editor can apply, and the discipline that keeps it honest: "did you mean" by edit distance, a confidence label on every suggestion, and a budget past which no suggestion is better than a confident wrong one.

The question

When should a compiler propose a fix, and when is proposing one actively harmful?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A suggestion is a list of (span, replacement) pairs attached to a diagnostic, plus a confidence label. The span may be zero-width, which is what makes a pure insertion expressible; the replacement may be empty, which is what makes a deletion expressible. That representation is deliberately the same shape as a refactoring edit, which is why the same machinery serves quick fixes in an editor, --fix on a command line, and a codemod over a whole repository.

What this phase may assume or do

A suggestion may be applied automatically only if it is known to produce a program that both compiles and means what the user intended. Those are two separate obligations and the second is the one that gets skipped. Compiling is checkable; intent is not, so the confidence label exists to say which of the two the compiler is claiming. Anything below full confidence must be presented to a human rather than applied, and a suggestion whose edits overlap another suggestion's must not be applied in the same pass, because the second edit's spans are computed against a buffer the first one changed.

Key points

  • A suggestion is (span, replacement) pairs plus a confidence label — the same shape as a refactoring edit, which is why one mechanism serves quick fixes, --fix and codemods.
  • Zero-width spans make insertions expressible, which is only possible because spans are half-open.
  • The confidence label answers a specific question: may a tool apply this without a human looking at it.
  • A wrong fix that compiles is the worst outcome, because it removes the diagnostic that would have caused someone to check.
  • "Did you mean" is edit distance plus a threshold, and the threshold is the whole design — there is always a nearest candidate.
  • Our checker budgets one edit for short names and a third of the length for longer ones, and returns nothing beyond that.
  • Applying several fixes in one pass is unsound when spans overlap; apply backwards or re-run the compiler between rounds.
  • A compiler that emits machine-applicable fixes can migrate its own users through breaking changes, which is why deprecations ship with them.

Machine-applicable, and everything below it

A fixit is only interesting if something can apply it without a human. That raises an obligation most compilers state explicitly: how confident is this suggestion, and may a tool act on it unattended. rustc encodes the answer in the diagnostic as an applicability level, and the levels are worth learning because they are the right taxonomy regardless of compiler.

Machine-applicable means the edit is known to be correct and can be applied by cargo fix without review — adding a missing mut, removing an unused import, replacing a deprecated call with its documented successor. Maybe-incorrect means the compiler has a plausible guess that a human should look at: a "did you mean" name, a suggested trait import when several would work. Has-placeholders means the suggestion contains text the user must fill in, so it is a template rather than an edit. Unspecified means no claim is made at all.

The distinction is load-bearing because the failure mode of an automatic fix is worse than the failure mode of a bad message. A wrong message wastes a minute. A wrong fix that *compiles* removes the diagnostic, changes the program's meaning, and leaves no trace that anything was guessed — the error is gone, the behavior is different, and nothing will point at the moment it changed.

The mechanical requirement underneath is the span convention. An insertion is a replacement over a zero-width range, which is only representable because spans are half-open — [[spans-and-ranges]] is where that pays off. A compiler whose spans are closed cannot express "insert a semicolon here" as data at all, and is limited to suggestions phrased as prose.

A machine-applicable fix, expressed as data rather than as advice
Before
diagnostic: expected `;` after this expression
  span:        [42, 42)        ← zero-width: an insertion point
  replacement: ";"
  applicability: machine-applicable

source:  let total = a + b
                          ^ insertion point at offset 42
After
source:  let total = a + b;

(applied by `cargo fix` / an editor quick fix, with no human review)
Legal only when

Applying it unattended is legal only when the edit produces a program that both compiles and means what the user intended. For a missing statement terminator the grammar admits exactly one repair at exactly one position, so both obligations are discharged by the parser's own knowledge — which is what earns the machine-applicable label. The zero-width span is what makes the edit expressible as data rather than as prose.

Illegal when

The compiler is guessing at intent. foo is undefined and foa and fob are both in scope; a missing trait import where three candidates exist; an ambiguous numeric type where several annotations would compile. Each of those produces a program that builds, so the diagnostic disappears and nothing records that a guess was made. Those must be labelled maybe-incorrect and shown to a person, never applied by a tool.

"Did you mean", and the budget that keeps it honest

The most common suggestion in any compiler is a name. A user writes lenght, the checker fails to resolve it, and the useful message is not "cannot find lenght" but "cannot find lenght — did you mean length?". The mechanism is edit distance: compute the Levenshtein distance from the unresolved name to every name in scope and propose the closest.

The whole difficulty is the threshold. There is always a closest candidate — a set of names is never empty in practice — so an unbudgeted implementation will suggest something for every typo, including for names that are not typos at all. Suggesting x for configuration is not a helpful message; it is noise that trains users to ignore the helpful ones.

Our checker caps it explicitly. suggest() finds the nearest candidate by editDistance and then applies a budget of one edit for short names and one third of the length for longer ones, returning nothing when the nearest candidate is further away than that. The comment in the source says why in one line: beyond that a suggestion is worse than none. rustc uses a comparable length-relative threshold, and both fall back to case-insensitive and substring heuristics for the specific cases edit distance handles badly.

Edit distance is the standard tool because it is cheap and it models the actual mistakes: a transposition, a doubled letter, a dropped one. It is a dynamic-programming computation over the two strings — the same algorithm DSA teaches as edit distance — and running it against every name in scope is affordable precisely because it only runs on the error path, which by definition is not the hot path.

The suggestion budget in `src/compilers/sim/check.ts`
1/** "did you mean" by edit distance, capped so it never suggests nonsense. */
2function suggest(name: string, candidates: string[]): string | undefined {
3 let best: string | undefined
4 let bestDistance = Infinity
5 for (const c of candidates) {
6 const d = editDistance(name, c)
7 if (d < bestDistance) {
8 bestDistance = d
9 best = c
10 }
11 }
12 if (best === undefined) return undefined
13
14 // One edit for short names, a third of the length for longer ones. Beyond
15 // that a suggestion is worse than none: a confidently wrong fix that
16 // compiles is the failure mode this lesson is about.
17 const budget = Math.max(1, Math.floor(name.length / 3))
18 return bestDistance <= budget ? `Did you mean \`${best}\`?` : undefined
19}

The budget is length-relative rather than absolute for a reason worth stating: one edit in a three-character name is a different kind of mistake from one edit in a twenty-character name, and an absolute threshold gets one of the two wrong. Note also that the closest candidate is always found — the budget is the only thing standing between the user and a suggestion for every unresolved name in the program.

Where suggestions do damage

implementationThe four applicability levels are rustc's Applicability enum and what cargo fix keys off; Clang expresses fixits as FixItHint attached to a diagnostic with no explicit confidence level, leaving the judgement to whoever emits it, and clang-tidy --fix applies them all — which is why some clang-tidy checks are documented as unsafe to auto-fix. TypeScript exposes code fixes through its language service with a separate list of fixes considered safe to apply across a whole file. The taxonomy generalises; the enum and the tooling contract do not.

Three failure modes, and only the first is obvious.

The confidently wrong fix that compiles. A suggestion that resolves the error and changes the meaning is the worst outcome available, because the diagnostic that would have prompted a human to look is now gone. This is the entire argument for confidence labels, and for the rule that anything short of machine-applicable is shown rather than applied.

The cascade of fixes. Applying several suggestions in one pass is unsound if their spans overlap or if one's span was computed against text another one changed. Tools handle this by applying non-overlapping edits from the end of the file backwards, or by applying one round and re-running the compiler — cargo fix re-runs, which is slower and is the correct choice.

The suggestion that trains the user. A quick fix that silences a lint by adding an allow attribute, or resolves a type error by inserting a cast, teaches a pattern rather than solving a problem. This is a design decision about which suggestions to offer at all: the question is not only "would this edit compile" but "is this what a reviewer would want to see".

There is also a quiet architectural benefit worth naming. Because a suggestion is a (span, replacement) list, the same representation drives a quick fix in an editor via LSP CodeAction, a batch --fix on the command line, and a repository-wide codemod. A compiler that can emit machine-applicable suggestions can migrate its own users through a breaking change, which is why deprecations in Rust and in modern C++ toolchains ship with fixits — the suggestion mechanism is the migration tool.

Confidence, and what a tool may do with itimplementation
ApplicabilityExampleMay a tool apply it unattended?
Machine-applicableInsert a missing ;; remove an unused import; rename a deprecated call to its documented successorYes — this is what cargo fix and editor quick-fix-all act on
Maybe-incorrect"Did you mean length?"; add one of several candidate importsNo — present it, let a human choose
Has-placeholdersimpl Trait for Type { /* fill in */ }No — it is a template, not an edit
UnspecifiedA prose help with no attached editNo claim is made; there is nothing to apply

How it works

The steps, in the order the compiler takes them.

  • A phase raises a diagnostic and, where it knows a repair, attaches one or more (span, replacement) edits to it.
  • It labels the suggestion with a confidence level stating whether the edit is known correct or merely plausible.
  • For an unresolved name, the checker computes edit distance from the name to every candidate in scope and keeps the nearest.
  • It compares that distance against a length-relative budget and discards the suggestion entirely if the nearest candidate is further away.
  • The diagnostic is serialised with its suggestions — to JSON for a command-line fixer, or to an LSP CodeAction for an editor.
  • A tool filters to the confidence level it is willing to apply automatically, and rejects any set of edits whose spans overlap.
  • Edits are applied from the end of the buffer backwards so earlier offsets remain valid, or one round is applied and the compiler is re-run.
  • Anything below full confidence is rendered for a human, who chooses.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • An automatic fix resolves the error, compiles, and changes what the program does — and because the diagnostic is gone, nothing indicates that a guess was made.
  • A "did you mean" suggests an unrelated short name because no budget was applied, and users learn to ignore the suggestion line entirely.
  • Two fixes are applied in one pass with overlapping spans, and the resulting file is syntactically broken in a way neither fix would have caused alone.
  • Edits are applied front to back without adjusting offsets, so every fix after the first lands in the wrong place.
  • A quick fix silences a lint with an allow attribute, and the underlying problem is now invisible and marked as reviewed.
  • A rename suggestion is applied to a file containing non-ASCII text and corrupts it, because the byte ranges were computed against a normalised buffer.
  • A deprecation ships without a fixit, and every consuming project migrates by hand — the same edit, made a thousand times, with a thousand chances of error.

When it helps

  • Mechanical mistakes with exactly one repair: a missing terminator, an unused import, a missing mut, a misspelled name with one plausible candidate.
  • Migrating users through a breaking change, where a machine-applicable fixit turns a rewrite into a command.
  • Onboarding, where the fix teaches the language faster than the message does.
  • Large codebases, where a lint with an auto-fix is adoptable and the same lint without one is a backlog item that never gets done.

When it hurts

  • Where the compiler is guessing at intent. Anything with several plausible repairs must be offered rather than applied, and a tool that applies everything is a tool that will eventually change meaning silently.
  • Where the suggestion treats the symptom. A cast that resolves a type error and a suppression attribute that silences a lint both make the message go away and leave the problem in place.

What it costs

Every one of these is paid by something.

  • Machine-applicable fixes buy migrations, adoptable lints and quick fixes, and pay a real correctness obligation: the compiler is now claiming an edit is safe to apply unattended, and being wrong changes user code silently.
  • A generous suggestion threshold buys help for more typos and pays credibility — noisy suggestions train users to skip the line where the good ones appear.
  • A strict threshold buys trust and pays coverage, staying silent on names a human would have recognised as a typo.
  • Structured suggestions buy one representation shared by the terminal, LSP and a batch fixer, and pay the diagnostic type's complexity plus an overlap-detection obligation in every consumer.
  • Re-running the compiler between fix rounds buys soundness against overlapping and offset-shifting edits, and pays wall-clock time proportional to the number of rounds.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Prose help with no attached edit — "consider adding a semicolon" — which is cheap, safe and leaves the user to make the change.
  • Interactive fixes only, presented in an editor and never applied in batch, which removes the worst failure mode at the cost of the migration use case.
  • A separate migration tool, as several ecosystems ship, which can be more aggressive than a compiler because it is invoked deliberately and its output is expected to be reviewed.
  • Trie-based or phonetic candidate search instead of pairwise edit distance, which scales better when the candidate set is very large, as a trie does — at the cost of a less faithful model of the mistakes people make.
  • Type-directed candidate filtering: rank candidates by whether they would type-check in position, which is much more precise than string distance and needs the checker to be re-entrant.

See it for yourself

The flag, dump or tool that shows you this directly.

  • rustc: --error-format=json shows suggested_replacement and applicability on each suggestion, which is the structured form the rendered message is a view of.
  • cargo fix applies only machine-applicable suggestions and re-runs the compiler between rounds; running it on a crate with warnings shows exactly which levels it trusts.
  • Clang: -fdiagnostics-parseable-fixits prints fixits as machine-readable fix-it:"file":{line:col-line:col}:"text" lines, and clang-apply-replacements consumes them.
  • clang-tidy --fix versus --fix-notes, and the per-check documentation noting which fixes are unsafe — a direct illustration of what a missing confidence level costs.
  • Misspell a name in our AtlasLang playground and watch the "Did you mean" appear and then disappear as the name gets further from anything in scope — the budget, visible.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "If the compiler suggests it, it is right." It may be labelled maybe-incorrect, and the ones that are wrong are exactly the ones that still compile.
  • "Auto-fix everything the compiler offers." Only the machine-applicable ones. Tools that apply every suggestion are how a codebase acquires changes nobody made deliberately.
  • "Did you mean is just a string comparison." It is a candidate search plus a threshold, and the threshold is the design. Without one, every unresolved name gets a suggestion.
  • "Suggestions are a nicety." They are the migration mechanism for a language's own breaking changes, which is the difference between a deprecation that lands and one that sits in a backlog.

Misconceptions

The claim, and what is actually true.

A suggestion is just a friendlier error message.
It is data an editor or a command-line tool applies. That is why it needs a confidence level and why its spans must index the file exactly.
The closest name is the one the user meant.
There is always a closest name. Without a distance budget, every unresolved identifier gets a suggestion, most of which are noise.
A fix that makes the program compile is a good fix.
That is the dangerous case. Compiling removes the diagnostic, so a wrong fix that compiles leaves nothing to indicate that a guess was made.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Some diagnostics can carry the repair with them: the exact text to insert or replace, and where. An editor turns that into a one-click fix and a command-line tool applies it in bulk. The catch is that the compiler must say how sure it is — a guessed fix that happens to compile removes the error message and quietly changes what your program does.

practical

Attach edits only where the repair is unambiguous, and label everything else as a guess for a human to look at. Budget your "did you mean" by name length and stay silent past it, because a nearest candidate always exists. When applying fixes in bulk, either apply non-overlapping edits back to front or apply one round and re-run — never front to back with stale offsets. And when you deprecate something, ship the fixit with it: that is what turns a migration into a command instead of a backlog item.

advanced

The most interesting direction is replacing string distance with type-directed candidate ranking. Edit distance models the keyboard; it does not know that only one of the two nearby names would type-check in this position. A checker that can be re-entered — ask "would this candidate make this expression well-typed" for each candidate — produces suggestions that are both more precise and safely promotable to machine-applicable, because the compiler has verified the repair rather than guessing at it. The obstacle is architectural: most type checkers are not built to be re-entered on hypothetical programs, and making them so is the same capability a language server needs for completion ranking. That convergence is the general pattern in this module — the machinery that makes diagnostics good is the machinery that makes tooling possible, and building either one gets you most of the other.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationThe applicability levels and the cargo fix contract are rustc's; Clang emits FixItHints with no confidence level and leaves the judgement to the emitter, which is why clang-tidy documents certain checks as unsafe to auto-fix. TypeScript's language service separates fixes that are safe to apply file-wide from those that are not. The taxonomy transfers; the specific names, enums and tool guarantees do not.
simplifiedOur suggest() compares against every visible name with plain Levenshtein distance and a length-relative budget, and returns a string rather than a structured edit — so AtlasLang has "did you mean" but no machine-applicable fixits at all. Production implementations add case-insensitive matching, substring and prefix heuristics, type-directed filtering, and a structured (span, replacement) payload that a tool can apply.
typicalMainstream fixers apply non-overlapping edits back to front, or apply one round and re-run the compiler. Neither is standardised, and a tool that applies edits front to back without adjusting offsets will corrupt files as soon as two suggestions appear in one file — which is why this is the first thing to check when a --fix run produces nonsense.

If you were asked this in an interview

  • When may a tool apply a compiler suggestion without a human looking at it?
  • How would you implement "did you mean", and what stops it suggesting nonsense?
  • Two suggested fixes overlap in the same file. What does your fixer do?

Connections

Domains that do not exist yet
  • DevOps / Production Engineering — Automated codemods across a repository
    Machine-applicable fixits are the compiler's contribution to large-scale automated migration: the same (span, replacement) data that drives an editor quick fix drives a repository-wide change. Running, reviewing and landing such a change safely across many services is owned there.