Table of Contents

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

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:

  • --fix on compile: 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.

  1. Fixes is 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. --fix applies exactly one fix per diagnostic — the first — and leaves any siblings for the writer. The contract lives on Diagnostic.Fixes and LocatedDiagnostic.Fixes, mirroring ESLint's split between one automatic fix and a list of suggestions, and LSP's isPreferred flag.
  2. 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.
  3. 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.
  4. Apply the kept set descending. Every edit is a splice into the original text, so descending application keeps every pending offset valid without rebasing.
  5. 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's note: 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's potentially fixable with the --fix option and Ruff's [*] 1 fixable with the --fix option, without Ruff's inline [*] marker, which would break the greppable file(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

  • CompileSettings declares --fix and rejects --emit or -o with it; CliConfigurator carries a --fix help example.
  • CompileCommand branches to fix mode before emission: read, compile, select, apply, write, recompile, report.
  • DialogueDown.Cli.Fixing owns the pure splice: FixApplier.Apply returns a FixApplication holding the corrected text and a FixOutcome per candidate — applied, or skipped for a FixSkipReason. FixRun carries the corrected script's diagnostics, the written file, and anything new after fixing; FixDiff turns one applied fix into HunkRows with HunkSegments and renders them; ScriptContents reads and writes the script with its BOM frame. The architecture guard's root-namespace cap is what puts these in Fixing rather than in DialogueDown.Cli.
  • IErrataRenderer.Render takes the diagnostics as found plus an optional FixRun, 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.
  • DiffPlex is pinned in Directory.Packages.props and referenced only by the CLI; nothing outside Fixing.FixDiff names it.
  • Diagnostic.Fixes and LocatedDiagnostic.Fixes document 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: --fix keeps 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-changes and --exit-non-zero-on-fix. Until it exists, the guide warns that --fix exits 0 after mutating the file.
  • A patch mode — a unified diff on stdout, the mature analog of clang-tidy --export-fixes and 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 --fix silently.
  • 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 fix refuses 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 all and --fix <code> as flag values. Prior art selects by code through a separate filter option (dotnet format --diagnostics, Ruff's select), never through --fix's own value. A magic all word lengthens the common case, and --fix DLG1113 reads like a script path. A repeatable --fix-code remains available as an additive later flag.
  • fixed and skipped as severity words (scene.dialogue.md(3,27): fixed DLG1113 …). That slot belongs to a severity in the file(line,col): severity CODE: message grammar 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 fix command. It would duplicate option resolution, errata rendering, and exit-code policy for one boolean's worth of behavior.