Compile CLI — fix mode
Note
Status: implemented. ddown compile --fix applies the preferred fix a
diagnostic already carries, corrects the script in place, and recompiles it.
The fix model and the editor's one-click repair are the
Diagnostic Quick Fixes note;
this note covers the CLI.
Table of contents
- Goal and scope
- Prior art
- CLI surface
- Behavior
- Applying fixes
- Report and exit codes
- Demonstrable runs
- Key design decisions
- Error and boundary cases
- Integration
- Testability
- Deferred work
- Alternatives not chosen
Goal and scope
The report can repair a fixable diagnostic in one click; the CLI can only
describe it. A writer compiling in a terminal reads "escape the arrow:
\=>" and retypes the remedy by hand. Fix mode closes that gap with the same
data the editor uses, so no new compiler surface is needed.
In scope:
--fixoncompile: apply the preferred fix of every diagnostic that carries one, and correct the script in place.- The applier: preferred-fix selection, ascending candidate order, descending application, whole-fix atomicity, overlap skip.
- The report: the diagnostics exactly as a plain compile prints them, then a fix section with a note and diff hunk per repair and a write notice naming the corrected file and what remains.
- The plain-compile hint
N fixable with --fix(the feature's discovery path, and the precondition for silence). - Exit codes, BOM and line-ending preservation, and a help example.
Out of scope (see Deferred work):
- Selecting fixes by diagnostic code.
- A CI check or dry-run mode, and a patch-output mode.
- Writing the corrected script anywhere but the script itself.
- Fix-safety tiers and multi-pass fixing.
- The editor and report surfaces, already shipped.
Prior art
How established fixers behave, including what each one prints — the column this design follows most closely:
| Tool | --fix applies |
Selection | Safety | Reports |
|---|---|---|---|---|
| ESLint | every fixable problem | --fix-type by fix kind |
none | remaining only; potentially fixable with --fix hint; silent when clean |
| Ruff | safe fixes | select / fixable per rule |
--unsafe-fixes opts in |
remaining only; Found N (M fixed, K remaining); --show-fixes lists detail |
| cargo fix | machine-applicable rustc suggestions | by package or target, not code | suggestions only | as found; per-file Fixed <file> (N fix) |
| dotnet format | every formatting diagnostic | --diagnostics <IDs>, --severity |
none | changed-file summary; --verify-no-changes for CI |
| clang-tidy | fixes of enabled checks | by check (-checks=) |
--fix-errors extends |
as found, note: FIX-IT applied suggested code changes; applied N of M suggested fixes. |
Adopted: the diagnostics as found, unchanged from a plain compile; a separate
fix section carrying a note and a diff hunk per repair (clang-tidy's as-found
diagnostics and attached fix notes); the file-level Fixed <file> (N fix)
notice (cargo fix) with the remaining count rolled in; silence when there is
nothing to do (ESLint and this CLI already); and the fixable-count hint on
non-fix runs (ESLint and Ruff). Skipped: per-code selection (always a separate filter
option, never a --fix value), safety tiers (the only fix is mechanically
derived), a dry-run or patch mode, and the CI postures.
CLI surface
# Correct the script in place; the report goes to stderr
ddown compile scene.dialogue.md --fix
| Invocation | Writes | Exit |
|---|---|---|
--fix |
the script, corrected in place | 0, or 65 when errors remain |
--fix with --emit or -o |
nothing | 64 |
Fix mode's only output is the corrected script. --emit and -o both
describe an emission, and fix mode emits nothing, so either alongside
--fix is a usage error naming the offending option. --config and the
script argument behave exactly as in a plain compile; --mode still selects
how far the pipeline runs, which bounds which fixes can be discovered (see
Key design decisions).
Behavior
flowchart TD
A["read script"] --> B["compile"]
B --> C{"any diagnostic<br/>carries a fix?"}
C -- "no" --> D["print exactly what a<br/>plain compile prints"]
C -- "yes" --> E["apply the preferred fix<br/>of each, ascending pick,<br/>descending splice"]
E --> F["write corrected script"]
F --> G["recompile to verify"]
G --> H["print the diagnostics as found,<br/>then the fix section:<br/>notice, note and hunk per fix<br/>exit by the recompiled result"]
Applying fixes
LocatedDiagnostic.Fixes — absolute offsets into the source text — is the
whole input; the applier is a pure text-to-text function that lives in the
CLI, next to the command that uses it.
Fixesis a list of alternatives, not a to-do list. The editor renders one action per element, and the fix model records it as an ordered list; the first element is therefore the preferred, auto-applicable repair.--fixapplies exactly one fix per diagnostic — the first — and leaves any siblings for the writer. The contract lives onDiagnostic.FixesandLocatedDiagnostic.Fixes, mirroring ESLint's split between one automaticfixand a list ofsuggestions, and LSP'sisPreferredflag.- Build the candidate set, then order it deterministically. Sort candidates ascending by start offset with a stable secondary key (code, then fix title); the compiler's emission order is not a contract.
- Keep the first fix of each overlapping cluster. Walk the ascending order, keeping a fix only when none of its edits intersects a kept range. The earliest fix in the file wins, as in ESLint and clang-tidy; a skipped fix is reported, never half applied.
- Apply the kept set descending. Every edit is a splice into the original text, so descending application keeps every pending offset valid without rebasing.
- Skip an edit that falls outside the text. Defensive: a stale offset is dropped with its fix and reported, and the remaining fixes still apply.
The only producer, DLG1113, carries one single-edit fix (insert \ before
the arrow), so overlap cannot occur and step 3 is a no-op. The policy exists so
that a producer offering alternatives, or a multi-edit fix, cannot interleave
silently. The CLI's ordering policy is normative for batch repair; the editor
applies one chosen fix at a time, so it needs no ordering.
Report and exit codes
The report is the errata stream: same console, same style, same destination (stderr). It prints two things in order: the diagnostics exactly as a plain compile prints them — so every line and column refers to the script as read, and the fixable-count hint is already there — then a separate fix section.
scene.dialogue.md(3,27): warning DLG1113: `=>` makes a jump only when a link follows it. …
for more information, see https://…/error-codes.html#dlg1113
scene.dialogue.md(5,1): warning DLG1107: This line looks like a speaker prefix …
for more information, see https://…/error-codes.html#dlg1107
2 warnings
1 fixable with --fix
Fixed scene.dialogue.md (1 fix; 1 warning remains)
1. Applied Fix: Escape as literal text
2 |
3 | -Alice: The rule is simple => the lever opens the door.
3 | +Alice: The rule is simple \=> the lever opens the door.
4 |
- The fix section opens with the write notice,
Fixed <script> (<N> fix), suffixed with the corrected script's state when anything remains:; 1 warning remains,; 1 error, 1 warning remain. It prints only after the write returns, so the report can never claim a repair the run did not make; a run that wrote nothing has no notice. - Each repair is a numbered item,
1. Applied Fix: <title>, followed by its hunk: the changed line as git-style-and+rows with one line of context either side, every row carrying the line number of its own side in a gutter (3 | -…on a plain console,3 │ -…on a terminal), so the rows stay greppable. On a terminal the rows are red and green, and the changed words are emphasized from DiffPlex's word-level sub-pieces. - A skipped fix is
2. Skipped Fix: <title> (<reason>)in the same numbering, with no hunk. The reason wording mirrors clang-tidy'snote: this fix will not be applied because it overlaps with another fix. - Nothing applicable: the section is absent and the run prints exactly what a plain compile prints, on both streams and in exit code. A clean script stays silent, as it already is; a script whose diagnostics carry no fix keeps its ordinary errata. Silence is for the no-write case only.
- After the tally, whenever a diagnostic still carries an unapplied fix, the
report appends
N fixable with --fix: every such diagnostic on a plain compile, and whatever was skipped after a fix run. The wording follows ESLint'spotentially fixable with the --fix optionand Ruff's[*] 1 fixable with the --fix option, without Ruff's inline[*]marker, which would break the greppablefile(line,col):line. This is the discovery path that makes fix mode's silence safe. - On an interactive terminal the diagnostics render as rich blocks and the fix section follows them unchanged; the hunk rows are colored, not boxed.
| Outcome | Exit |
|---|---|
| Corrected script has no errors | 0 |
| Errors remain in the corrected script | 65 |
| Nothing applicable, and the plain compile errored | 65 |
--fix combined with --emit or -o |
64 |
| Script argument or option validation fails | 64 |
| Unexpected I/O or internal failure | 1 |
A failed first compile does not block fixing: how far the pipeline reaches
still follows --mode, and the diagnostics it did produce keep their fixes
(DLG1113 is discovered during desugaring, before the semantic error
DLG2001 stops the run by default). Fix mode applies what it has, recompiles,
and lets the surviving error set the exit code — and still writes the
corrected script, because the concrete defect the writer can see is repaired
even when the compile ends in an error.
Demonstrable runs
Long messages are elided with …. A hunk row's leading number is its line number,
and a row starting with a space is context. On a terminal the diagnostics render as
rich Errata blocks, the hunk rows are colored, and the fix section is otherwise
identical.
A — fix in place: one warning fixed, one remains
$ ddown compile workshop.dialogue.md --fix
workshop.dialogue.md(3,27): warning DLG1113: `=>` makes a jump only when a link follows it. With no link here it is read literally, staying as the characters "=>". If you meant to jump, add a target: `=> [The market](#the-market)`. If you meant the characters, escape the arrow: `\=>`.
for more information, see https://pengzhengyi.github.io/dialoguedown/guide/error-codes.html#dlg1113
workshop.dialogue.md(5,1): warning DLG1107: This line looks like a speaker prefix ("Bob:") but the name is styled, so it is not recognized and the line has no speaker. Remove the styling to declare the speaker.
for more information, see https://pengzhengyi.github.io/dialoguedown/guide/error-codes.html#dlg1107
2 warnings
1 fixable with --fix
Fixed workshop.dialogue.md (1 fix; 1 warning remains)
1. Applied Fix: Escape as literal text
2 |
3 | -Alice: The rule is simple => the lever opens the door.
3 | +Alice: The rule is simple \=> the lever opens the door.
4 |
$ echo $?
0
The diagnostics are exactly what ddown compile workshop.dialogue.md prints;
the fix section then names the repair and shows the change as a hunk.
D — an error survives; the correction still lands
$ ddown compile broken.dialogue.md --fix
broken.dialogue.md(3,27): warning DLG1113: `=>` makes a jump only when a link follows it. …
for more information, see https://pengzhengyi.github.io/dialoguedown/guide/error-codes.html#dlg1113
broken.dialogue.md(5,1): error DLG2001: Two scenes resolve to the same anchor '#the-workshop'. Rename one heading so each jump target is unambiguous.
for more information, see https://pengzhengyi.github.io/dialoguedown/guide/error-codes.html#dlg2001
1 error, 1 warning
1 fixable with --fix
Fixed broken.dialogue.md (1 fix; 1 error remains)
1. Applied Fix: Escape as literal text
2 |
3 | -Alice: The rule is simple => the lever opens the door.
3 | +Alice: The rule is simple \=> the lever opens the door.
4 |
$ echo $?
65
The warning is gone from the script and the write notice says the error remains, while the duplicated scene still fails the compile.
Key design decisions
D1 — Fix mode lives on compile
Fixing is compiling with a repair step, so it rides compile's option
resolution (--config, --mode), its errata rendering, and its exit codes
instead of becoming a second command that would duplicate all three.
D2 — One preferred fix per diagnostic; the list is alternatives
Fixes is a menu the writer chooses from in the editor, not a plan to execute
in full. The first element is the preferred, auto-applicable repair, and
--fix applies only it, as the model's documentation states. Applying
every element would execute conflicting remedies the moment a producer offers
two — the message for DLG1113 itself advertises a jump target and an escape —
and ESLint's fix-versus-suggestions split is the mature shape of that
rule.
D3 — Select ascending with a deterministic tie-break, apply descending
Emission order is not a contract, so candidates are ordered explicitly and the earliest fix in the file wins a conflict, as in ESLint and clang-tidy. Descending application then keeps each pending offset valid against the original text, and overlap is skipped whole rather than merged: a half-applied fix is worse than a fix not applied.
D4 — Splice the raw text, and preserve its encoding frame
Untouched regions round-trip byte for byte, including line endings and
whitespace the compiler does not model. A leading UTF-8 BOM is detected from
the file's first bytes and written back with
new UTF8Encoding(encoderShouldEmitUTF8Identifier: bomWasPresent), because
File.ReadAllText would consume it without a trace and File.WriteAllText
writes no preamble. Diagnostic offsets are offsets into the BOM-stripped
string, so a later switch to raw-byte reading would shift every one of them.
D5 — In place only; no emission and no second destination
Every surveyed fixer edits in place. A corrected-copy option would be novel
twice over — the capability has no analog, and it would overload -o,
producing a report that names two files. --emit and -o therefore conflict
with --fix. When a non-destructive mode is wanted, the mature analog is a
dry run or a patch, not a second destination.
D6 — Diagnostics as found, then the fix section
The diagnostics stay exactly what a plain compile prints, so a terminal reader never has to separate "what the compiler found" from "what the fixer did", and every position stays valid against the file the writer has open. The fix narration gets its own room rather than a continuation line squeezed under a diagnostic — which is where the hunk showing the change can live. The write notice is the signal that something on disk changed, and it carries the corrected state because the diagnostics above describe the script as read.
D7 — The recompile verifies; the exit code follows the corrected script
The second compile is not reporting, it is verification: it sets the exit code
from the corrected state, and it catches the one case a single pass cannot
otherwise see — a fix that fails to clear its own diagnostic, or introduces a
new one. Diagnostics present after but not before print under an
after fixing: lead-in, so a producer cannot hide a regression. With the
shipped producer the set is always empty, and the testable invariant is that every applied fix
is absent from the recompiled diagnostics.
D8 — Silence is paired with discovery
--fix with nothing to fix is byte-identical to the plain compile it claims
to match — the existing renderer already prints nothing when the script is
clean, and a no-fix run adds nothing. Silence is safe only because the plain
compile advertises the feature with N fixable with --fix, in the spirit of
ESLint's and Ruff's fixable-count hints. Silence never crosses the write: a run
that changed a file says so.
D9 — --mode bounds which fixes are discovered
--mode stage-boundary (the default) stops the pipeline at an error, so a
script whose error precedes desugaring yields no fixes, while
--mode best-effort --fix surfaces and repairs more. That is the same posture
as clang-tidy, which refuses to apply fixes when compilation errors were found
unless --fix-errors is given; --fix alone never overrides the mode.
D10 — No selection and no CI mode
With one fix producer there is no user choice to express; both are additive (see
Deferred work). Keeping --fix a plain flag leaves room for a
separate --fix-code filter.
D11 — DiffPlex computes the hunk
The hunk is a real diff of the script with and without that one fix, computed by
DiffPlex — Apache-2.0, no transitive
dependencies, a 33 KB assembly, 42.7M downloads, and actively maintained. The
vetting spike ran 13 shared cases against a hand-written line renderer; DiffPlex
came out shorter and adds word-level sub-pieces for the emphasis inside a
changed line. One API trap is worth recording: InlineDiffBuilder returns a
token stream, not lines — the line-structured model is SideBySideDiffBuilder.
A single CLI-local renderer, FixDiff, touches the package, so a future swap
stays local.
Error and boundary cases
| Case | Behavior |
|---|---|
Script missing, or not *.dialogue.md |
existing script-argument validation, exit 64 |
--fix with --emit or -o |
usage error naming the option, exit 64, nothing written |
| No diagnostic carries a fix | byte-identical to a plain compile, no write, exit as that compile |
| A fix's edit falls outside the text (defensive) | that fix is skipped and reported; the rest apply |
| A fix that changes no line | its note still prints, with no hunk |
| Overlapping fixes | the later candidate is skipped whole and reported |
| Two insertions at the same offset | fixed by the ascending tie-break: code, then title |
| Script read-only, or the write fails | I/O error surfaces, exit 1, and no write notice is printed |
| Leading UTF-8 BOM | detected on read, written back per D4 |
| CRLF line endings | preserved by splicing; a replacement containing a lone \n is a producer bug, not an applier behavior |
| Non-UTF-8 script without a BOM | the compile already assumes UTF-8; fix mode inherits that assumption |
Second --fix run |
nothing applied, no write, mtime unchanged; remaining errata print as usual |
Integration
CompileSettingsdeclares--fixand rejects--emitor-owith it;CliConfiguratorcarries a--fixhelp example.CompileCommandbranches to fix mode before emission: read, compile, select, apply, write, recompile, report.DialogueDown.Cli.Fixingowns the pure splice:FixApplier.Applyreturns aFixApplicationholding the corrected text and aFixOutcomeper candidate — applied, or skipped for aFixSkipReason.FixRuncarries the corrected script's diagnostics, the written file, and anything new after fixing;FixDiffturns one applied fix intoHunkRows withHunkSegments and renders them;ScriptContentsreads and writes the script with its BOM frame. The architecture guard's root-namespace cap is what puts these inFixingrather than inDialogueDown.Cli.IErrataRenderer.Rendertakes the diagnostics as found plus an optionalFixRun, so the plain and rich paths stay one code path, the diagnostics are identical to a plain compile, and the hint comes from the same list.DiffPlexis pinned inDirectory.Packages.propsand referenced only by the CLI; nothing outsideFixing.FixDiffnames it.Diagnostic.FixesandLocatedDiagnostic.Fixesdocument the preference order, so the model itself carries the contract an automatic fixer relies on.- The command-line guide documents
--fix, including that a fix run exits 0 after rewriting the file, since there is no CI check mode.
Testability
FixApplierTests covers the pure function: preferred-fix selection when a
diagnostic offers alternatives, ascending selection with the tie-break,
descending application, overlap skip, an atomic multi-edit fix, an insertion at
the text's end, an out-of-range span, and the no-op when nothing is fixable.
FixDiffTests covers the hunk: context rows, the trailing-newline rule, CRLF,
a change on the first or last line, the changed-word emphasis, and the no-hunk
case. ScriptContentsTests pins the BOM round-trip on both presence and
absence. Renderer tests cover the separated shape (the diagnostics byte for
byte, then the fix section), the write notice with and without a remaining
clause, the skipped note, the after fixing: block, the hint, and the rich
path. Command tests cover in-place fixing; the byte-for-byte identity of a
no-fix run with a plain compile; idempotence, where a second run writes nothing
and leaves the modification time; a surviving error exiting 65 while still
writing; one note and hunk per fix; BOM preservation; BOM-less writing; and the
--emit and -o conflicts exiting 64. The recompile invariant shows up as the
corrected-state clause — (1 fix) when a fix clears its diagnostic,
(1 fix; 1 error remains) when an error survives — and the after fixing:
path is unit-tested because no shipped producer can trigger it.
Deferred work
- Per-code selection (
--fix-code <ID>, repeatable). Additive later:--fixkeeps meaning "all". The flag shape, unknown-code handling (a usage error), and whether the filter also filters the displayed errata wait until a second fix producer makes selection a real choice. - A CI check mode —
--fix-dry-run(ESLint) or a fail-when-fixable flag in the spirit of--verify-no-changesand--exit-non-zero-on-fix. Until it exists, the guide warns that--fixexits 0 after mutating the file. - A patch mode — a unified diff on stdout, the mature analog of
clang-tidy --export-fixesand the non-destructive alternative to a corrected copy. - Fix-safety tiers. The only fix is mechanically derived from a named
producer. A future fix that guesses must carry an applicability label (Ruff's
safe/unsafe) rather than riding
--fixsilently. - Multi-pass fixing. One pass suffices while every fix removes the diagnostic that carries it; the recompile check is what would notice if that stops being true. If it does, revisit with a bounded loop rather than an unbounded one.
- A dirty-VCS guard.
cargo fixrefuses to rewrite a working tree with uncommitted changes; the CLI has no Git dependency, so this would be a deliberate addition, not an inheritance. - An editor fix-all. The editor applies one chosen fix at a time; if it ever batches, it should reuse the CLI's ordering policy, pinned by a shared conformance case.
Alternatives not chosen
--fix alland--fix <code>as flag values. Prior art selects by code through a separate filter option (dotnet format --diagnostics, Ruff'sselect), never through--fix's own value. A magicallword lengthens the common case, and--fix DLG1113reads like a script path. A repeatable--fix-coderemains available as an additive later flag.fixedandskippedas severity words (scene.dialogue.md(3,27): fixed DLG1113 …). That slot belongs to a severity in thefile(line,col): severity CODE: messagegrammar that problem matchers parse; no surveyed tool invents a fix severity, and the message would be lost with it. The fix narration gets its own section after the diagnostics instead (D6).- A corrected copy via
-o. No surveyed fixer offers one; it overloads the emission destination and makes one report name two files. A dry run or a patch is the mature way to be non-destructive. - Writing a playbook or DOT alongside the corrected script. Two artifacts from one command invite reading the output of the wrong revision, and a fix run is a maintenance step, not an export.
- Re-reading the written file before recompiling. The corrected text is already in memory; re-reading adds I/O, a failure window, and a second place where the encoding could differ.
- Forcing best-effort discovery in fix mode. It would silently ignore an
explicit
--mode stage-boundary; honoring the mode keeps the flag compositional and matches clang-tidy's bail-out default. - A separate
ddown fixcommand. It would duplicate option resolution, errata rendering, and exit-code policy for one boolean's worth of behavior.