feat: mds fmt auto-formatter#137
Conversation
Adds a span-guided, line-oriented source rewriter (never a token/AST reconstructor, since the lexer is lossy) implementing a conservative R1-R4 ruleset: CRLF/CR removal, exactly-one-final-newline, 3+ blank-line-run collapsing matching clean_output's own algorithm exactly, and directive trailing-whitespace stripping. A runtime safety gate re-compiles both the original and formatted source and returns MdsError::FormatterInvariant on any divergence rather than silently writing a non-equivalent result. Two invariants were verified empirically against the live compiler rather than assumed from prose, and reshaped the ruleset: - A whitespace-only "blank" line is NOT inert mid-document (clean_output only fully strips leading/trailing runs, and mid-stream only collapses truly-empty newline runs) — so blank-line whitespace stripping was dropped as unsound; body-text trailing whitespace (Markdown hard breaks) is never touched. - @message/@define bodies bypass clean_output entirely (built via `evaluate_nodes(...).trim()`, no newline-run capping, no \r removal), so they get a third "raw content" region alongside frontmatter/code fences — copied byte-for-byte, including nested code fences. Task: feat/60-mds-fmt-auto-formatter
Wires the mds-core formatting engine into the CLI: `mds fmt` on a file, directory (recursive, INCLUDING _-prefixed partials -- a deliberate divergence from build/check, since formatting rewrites source rather than emitting compiled output), or stdin (`-`, as a filter). `--check` is read-only and exits non-zero if anything would change; `--diff` is read-only and prints a unified diff (colorized via raw ANSI only on a TTY, using the `similar` crate -- a genuinely zero-transitive-dependency, Apache-2.0, MSRV-1.85 pin); the two combine. Writes only when content actually changes (preserves mtime). Reuses the existing exit_code mapping (0/1/2/3, no new codes) and the existing resolve_input/read_stdin/collect_mds_files helpers. Adds a FmtConfig `mds.json` section (sort_frontmatter_keys, default true) as forward-compat scaffolding for a rule deferred to a future version -- not yet consulted by any formatting behavior. Task: feat/60-mds-fmt-auto-formatter
Adds mds fmt to the CLI Reference command list, a Fmt options block, the exit-code note for --check, and a "Formatting with mds fmt" subsection covering in-place/dir/--check/--diff/stdin usage, what gets normalized vs. deliberately left untouched, and the mds.json fmt section. Stamps the CHANGELOG [Unreleased] entry (#60). Task: feat/60-mds-fmt-auto-formatter
…rInvariant The assert_equivalent gate now explicitly handles parse-level Syntax errors (like unclosed @message/@if/@for/@define/@block blocks) by surfacing them verbatim, matching mds build/check behavior. This prevents misleading FormatterInvariant reports for what is ordinary author error (missing @EnD). Engine fmt tests increased to 43 with two new syntax-error tests: - syntax_error_unclosed_directive_blocks_surface_syntax_not_formatter_invariant: validates that 5 block type variants all surface Syntax, not a silent pass or a FormatterInvariant. - syntax_error_unclosed_message_with_trailing_ws_is_syntax_not_formatter_invariant: validates the specific case where trailing ws on the final body line previously triggered a FormatterInvariant (rewrite diverged from the raw region), now a clean Syntax error. Co-Authored-By: Claude <noreply@anthropic.com>
The fmt_file and fmt_dir helpers were byte-identical; mds fmt dispatches on filesystem type (file, dir, or stdin) at runtime, so a single fmt_path helper eliminates duplication and improves clarity. CLI fmt tests increased to 35 with one new test: - unclosed_directive_block_exits_one_with_syntax_not_formatter_invariant: validates that the CLI surfaces the same Syntax error for unclosed @message blocks as the core formatter (not a FormatterInvariant nor a silent format). Co-Authored-By: Claude <noreply@anthropic.com>
0efef25 to
1bd44a4
Compare
🔴 Consistency Issue: FormatterInvariant.detail should be messageFile: Every other This is public API and the field name is baked into the surface. Since we're still in the pre-release window and this change is free now (no published users), renaming to FormatterInvariant { message: String },After release, this becomes a breaking change. Recommend fixing before shipping. |
🔴 Testing: Safety Gate Failure Paths Never ExercisedFile: The safety gate is the core invariant that makes this feature safe to ship. However, no test ever makes it fire:
The suite has only one indirect integration test that happens to avoid the subtlest branches (no raw-span, no code fence). This means a refactor that made Fix: Add direct unit tests for
Plus one integration test forcing |
🟡 Consistency: MdsError::FormatterInvariant.detail should be messageFile: Every other This is public API (baked into the enum surface). The rename is free now pre-release, but becomes breaking after shipping. Fix: FormatterInvariant { message: String },Update the constructor helper and test sites accordingly. |
🟡 Documentation: Blank Line Collapse Threshold is UnderstatedFiles: The docs state "Runs of 3+ blank lines collapse to one." The actual behavior collapses runs of 2 or more blank lines to one (verified in Concretely: This contradicts the internal module docs ( Fix: Use unambiguous wording in all three locations: "Collapses runs of two or more consecutive blank lines to a single blank line (mirrors the compiler's own output normalizer)" or in newline terms: "3+ consecutive newlines collapse to 2" |
🟡 Documentation: Byte-for-Byte Claim Contradicts CRLF NormalizationFiles: The same document claims:
These are in direct tension for frontmatter and code fences: R1 strips Fix: Scope the claim to be precise: "The content of frontmatter and code fences (apart from CRLF→LF normalization), and the fully byte-for-byte content of This removes the contradiction and preserves the meaningful raw-content distinction. |
🟡 Complexity: Positional Boolean-Flag Parameters (Transposition Hazard)File:
Failure scenario: A future edit swaps two bools at one call site; the compiler accepts it and Fix: Bundle into a struct: #[derive(Clone, Copy)]
struct FmtFlags { check: bool, diff: bool, quiet: bool }
fn run_fmt_file(path: &Path, flags: FmtFlags) -> Result<()> { ... }The code already has |
📋 Summary: Code Review Findings (All Reports)Overall Assessment: This is an exceptionally well-engineered PR that should be approved after addressing the highlighted items above. The formatter is safe by construction, the test coverage of the happy path is exemplary, and the code quality is consistently high. Regression risk is zero; the reviewer score spans 7-10/10 across all nine review dimensions. Scores by Dimension
High-Confidence Findings (80%+) Requiring Fixes Before Release
Important-But-Optional Polish (80%+ confidence)
Lower-Confidence But Notable Items (60-79%)
Pre-existing / Informational (Not Blocking)
Recommendation: APPROVE (with conditions)Merge when the four HIGH-CONFIDENCE findings above are fixed:
The remaining 7 polish items are low-risk pre-release refactors worth doing (all touched code is new), but they do not block merge — they can ship as is and be addressed in follow-ups if preferred. Review Method: 11 specialized sub-reviews (testing, performance, consistency, complexity, rust, reliability, security, regression, dependencies, architecture, documentation) read and deduplicated. All findings verified against source code. Claude Code Review | |
`FormatterInvariant` was the lone free-form-string variant in `MdsError`
not named `message`; every other variant (Syntax, Io, ResourceLimit,
BuiltinError, ImportError, ExportError, Extends, YamlError, JsonError)
uses `message`. Rename for consistency before the variant ships in a
public release.
No binding (napi/wasm/python) exposes this variant yet, so this is a
no-cost breaking change while still in [Unreleased].
Update the `#[error]` format string, the `formatter_invariant()` helper
parameter, and the two struct-literal construction sites in
tests/api_surface.rs. The `{ .. }` match arm in tests/fmt.rs needs no
change.
Co-Authored-By: Claude <noreply@anthropic.com>
The byte-range rewrite in formatter.rs relies on three unstated invariants with no production assertion: 1. `body_start` must be a char boundary before `source[..body_start]` 2. `pos` (advancing cursor in rewrite_body's loop) must be a char boundary before `source[pos..]` 3. `protected` and `raw_content` spans must be sorted and non-overlapping for the advancing cursors (pi / ri) to be correct Add zero-cost `debug_assert!` guards at each site, matching the project's reliability.md / rust.md mandates (debug_assert! for invariants in hot paths). A future lexer regression will now fail loudly in debug rather than silently mis-slicing. Co-Authored-By: Claude <noreply@anthropic.com>
…evaluator.rs (A1) The formatter's safety model has a critical three-way dependency that was documented only in formatter.rs and KNOWLEDGE.md: - clean_output (lib.rs) is the ceiling of what mds fmt may change in markdown-mode content - @message/@define bodies bypass clean_output entirely (evaluator.rs line 845: plain `.trim()`, no clean_output call), so their content reaches compiled JSON verbatim - formatter.rs::raw_content_spans protects those bodies from R1/R3 transforms precisely because of this bypass Adding back-reference comments at both dependents (clean_output and collect_single_message) makes the coupling visible locally. The A1 shared-constant extraction (block-opener keywords duplicated between formatter.rs and parser.rs) is deferred to tech debt — the back-ref comments deliver the core value with no behavioural risk. Co-Authored-By: Claude <noreply@anthropic.com>
Three positional bool params (check, diff, quiet) were threaded through
run_fmt_stdin / run_fmt_file / run_fmt_directory in identical order — a
silent-transposition hazard since all three are `bool` and the compiler
cannot distinguish them.
Bundle them into a private `FmtFlags { check, diff, quiet }` struct
(#[derive(Clone, Copy)]); build it once in run_fmt and pass it to all
three helpers. Each helper destructures it at the top to keep the
function body unchanged.
Also moves `MAX_DEPTH` from module scope into run_fmt_directory, matching
the function-local placement used by run_build_directory / run_check_directory
in build.rs.
Co-Authored-By: Claude <noreply@anthropic.com>
…ures The per-file loop in run_fmt_directory interleaved six concerns (read, format, diff render, read-only tally, write, write-tally) across ~85 lines while mutating three counters across six branches, making it hard to audit each branch in isolation. Extract format_one_file(file, flags) -> FileOutcome, which encapsulates the full per-file read → format → (optional) diff → (optional) write sequence and returns a typed outcome enum. run_fmt_directory's loop is now a single match expression that tallies the outcome. As part of this extraction, a diff-output failure (non-broken-pipe stdout error from print_diff) now returns FileOutcome::Failed and increments fail_count — previously such errors were logged to stderr but the file was still tallied as "would reformat", leaving fail_count unaffected and the exit code misleadingly clean (RU2). Co-Authored-By: Claude <noreply@anthropic.com>
The directory-mode --check summary used `!quiet || changed_count > 0 || fail_count > 0`, which forced "N would reformat, K failed" to stderr even when --quiet was set and files only *would* change (no errors). This contradicts the documented --quiet contract (suppress status/summaries, never errors) and the single-file --check path, which is fully silent under --quiet. Drop the `changed_count > 0` disjunct: `!flags.quiet || fail_count > 0` matches the non-read_only branch's guard, sibling run_check_directory's guard, and the KB-stated --quiet semantics. Co-Authored-By: Claude <noreply@anthropic.com>
…coloring
The previous implementation keyed colorization on the rendered string prefix:
`starts_with("---")` / `starts_with("+++")` for CYAN (file headers).
A content line that starts with `-- ` or `++` produces a diff line like
`--- comment` or `+++ foo`, which matched the file-header check and was
rendered CYAN instead of RED/GREEN. Cosmetic, TTY-only, but incorrect.
Replace with a state machine: track whether a `@@` hunk marker has been
seen. Before the first `@@`, `---`/`+++` are file headers (CYAN). After
`@@`, `-` lines are removals (RED) and `+` lines are additions (GREEN),
regardless of what their remaining content looks like.
Adds a regression test that exercises both the file-header path and the
within-hunk path for content starting with extra dashes/pluses.
Co-Authored-By: Claude <noreply@anthropic.com>
Two factual inaccuracies in user-facing mds fmt docs: 1. Blank-line threshold (DO1): all three locations said "3+ blank lines collapse to one." The actual R3 rule is min(N,2) newlines, meaning TWO or more consecutive blank lines (= three or more newlines) collapse to one — not three. Two blank lines in a row were incorrectly implied to survive formatting unchanged. 2. CRLF vs byte-for-byte contradiction (DO2): docs claimed frontmatter, code fences, AND @message/@define bodies are "byte-for-byte untouched" while also stating CRLF is normalized "including inside frontmatter and code fences." These contradict: R1 (CRLF strip) does cross frontmatter/ code-fence boundaries; only @message/@define bodies are truly byte-for-byte (neither R1 nor R3 applies inside raw_content_spans). Fixed by distinguishing the two cases in each location. Ground truth: formatter.rs module doc (lines 30-60) and KNOWLEDGE.md Rule 1/Rule 3 — both already stated the correct behavior. Co-Authored-By: Claude <noreply@anthropic.com>
Resolves batch R-tests: T1 (safety-gate paths): - Add CLI-level integration test `structural_fallback_formats_file_with_undefined_var` (cli_fmt.rs): proves path 3 of assert_equivalent works end-to-end for files with undefined runtime vars. Syntax-propagation + file-not-written paths were already covered; FormatterInvariant end-to-end is deferred (requires a formatter bug — see comment in cli_fmt.rs). T2/P2 (perf test honesty + fallback coverage): - Rename `perf_linear_one_megabyte_file_formats_under_two_seconds` → `perf_formats_large_file_without_panic_or_catastrophic_slowdown` - Drop the tight 2.0s wall-clock SLA (CI-flaky); replace with a 30s catastrophic-regression smoke check - Add a ~300 KB undefined-var input exercising the structural_equivalent fallback path at scale (previously the perf test only exercised the double-compile gate path) T3 (adversarial corner cases): - `t3_deep_nesting_for_inside_if_inside_message_*`: @for inside @if inside @message — blank-line run between nested @EnD lines inside the raw-content span must NOT collapse - `t3_run_of_consecutive_whitespace_only_lines_preserved_verbatim`: 3 consecutive whitespace-only lines — distinct from the single-line case already tested - `t3_bom_plus_crlf_bom_preserved_cr_stripped`: UTF-8 BOM + CRLF combination — BOM survives verbatim, all \r stripped T4 (mtime test discriminating power): - `inplace_unchanged_file_preserves_mtime_and_reports_unchanged`: also assert file bytes are identical (in addition to mtime) to prove no rewrite happened T5 (malformed fmt config): - Add `fmt_config_malformed_bool_field_fails_loading` and `fmt_config_valid_section_loads_cleanly` to build.rs's test module T7 (dead fixture): - Delete `fmt_body_hardbreak.mds` — confirmed unreferenced in the entire test tree T8a (corpus count guard): - Assert at least 10 corpus entries actually ran `compile_equivalent_across_corpus` T8b (diff test robustness): - Add `@@` hunk-header assertion to `diff_prints_unified_diff_to_stdout_writes_nothing` (more discriminating than `---` alone, which frontmatter content can satisfy) Co-Authored-By: Claude <noreply@anthropic.com>
"The unit tests that were in main.rs have moved to build.rs." describes a past transition, not current state. The following line already explains what matters: this file holds only integration-level wiring.
Summary
Implements
mds fmt— an opinionated, safety-gated auto-formatter for MDS templates.Closes #60
Engine (
mds-core):format_str/format_str_withA span-guided, line-oriented source rewriter — never a token/AST reconstructor, since the
lexer is lossy (interpolation inner whitespace trimmed, the newline after a directive line /
code fence discarded, frontmatter
\rfiltered) and reconstructing from tokens would invent ordrop whitespace the author actually wrote. Tokenizes only to locate protected byte-ranges and
directive-line offsets, then rewrites the original source string in a single left-to-right pass.
Ruleset (v1 — R1-R4):
clean_outputstrips every\rfrom the final compiled string unconditionally, frontmatter lexing already filters\r, and directive matching trims\rbefore comparison — no downstream consumer can ever observe it""clean_output's own leading/trailing-trim (verified against the live compiler)clean_output's own newline-run cap exactly — verified empirically, not just inferred from its doc commentdir.trim()before matching, so trailing whitespace is discarded pre-parse and never reaches outputTwo things were discovered empirically (not anticipated in the plan) and reshaped the design:
clean_output's per-characterloop treats a bare space as ordinary content (resets its newline-run counter, pushed through
verbatim) — only a truly empty line participates in run-collapsing, and only the absolute
start/end of the document get unconditional stripping.
printf 'Hello\n \nWorld\n' | mds build -preserves the three spaces byte-for-byte. Stripping such a line in the formatter would silently
break compile-equivalence, so this formatter leaves body-text trailing whitespace — including
whitespace-only "blank" lines — completely untouched, everywhere.
@message/@definebodies bypassclean_outputentirely. Message content is built viaevaluate_nodes(...).trim()with noclean_outputpass (confirmed by readingcollect_single_messagein
evaluator.rsand reproducing it: a\rinside a message body survives verbatim into thecompiled JSON, and an uncollapsed 4-newline run stays uncollapsed). These bodies — and, conservatively,
@definebodies too (a function can be called from a markdown-mode site or a message-mode site,and the formatter can't tell which without a full call-graph analysis) — get a third "raw content"
region alongside frontmatter/code fences: copied byte-for-byte, including any nested code fence,
with only directive lines (R4) still safe to trim inside them.
Safety gate (
assert_equivalent): re-compiles both the original and formatted source with thesame
base_dirand requires identicalCompiledOutput. When the original doesn't compile standalone(a minority case — typically an undefined runtime variable; imports still resolve via
base_dir),falls back to a structural, rule-aware token comparison (
Textcompared viaclean_outputitself,outside raw-content spans; byte-exact inside them). Any divergence returns
MdsError::FormatterInvariantrather than silently writing a wrong result — the CLI never writes on this error.
Deferred to a future version: interpolation
{ x }→{x}trimming, frontmatter key sorting,@importgrouping/reordering, blank-line insertion around directive blocks, body hard-breaknormalization, directive internal spacing.
CLI (
mds-cli):mds fmt [FILE|DIR|-] [--check] [--diff]_-prefixed partials — formatting rewrites source,not compiled output, so partials are just as much a candidate as anything else) / stdin (
-, asa filter) / omit to auto-detect.
--check: read-only, exits non-zero if anything would change.--diff: read-only, prints aunified diff via the
similarcrate (colorized with raw ANSI only when stdout is a TTY, matchingwatch.rs's own TTY-gated ANSI convention — no color crate dependency). They combine:--diffcontrols what's printed,
--checkcontrols the exit code.--quietsuppresses status but never errors.mds fmt -into a closed pipe handles a broken pipe gracefully (no panic).N formatted, M unchanged, K failed(N would reformat, K failedunder--check), continue-on-error, matching therun_check_directoryprecedent.FmtConfig(mds.json→{"fmt": {"sort_frontmatter_keys": true}}) is added as forward-compatscaffolding only — validated on load, not yet consulted by any formatting behavior (no matching
CLI flag; a no-op flag would be a UX/clippy liability).
Exit codes (reuses the existing mapping — no new codes)
--checkfound something that would change, or a format/parse error (incl.FormatterInvariant).mds/ I/O / bad UTF-8Test summary
crates/mds-core/tests/fmt.rs) + 12 white-box unit tests informatter.rs— idempotence and compile-equivalence across a corpus, every rule individually,the safety gate (both the real-compile path and the structural fallback), the
@message/@defineraw-content exemption (including a nested-code-fence-inside-a-message-body regression test), and
edge cases (empty/whitespace-only, missing trailing newline, frontmatter-only, mid-directive EOF,
unclosed fence/interpolation/frontmatter, UTF-8 BOM, multi-byte UTF-8, mixed CRLF/LF, a ~1MB
linear-time perf test).
cargo test -p mds-core: 638 lib tests + 41 fmt.rs + 60 api_surface.rs,all green (was 626/59 before this PR).
crates/mds-cli/tests/cli_fmt.rs) + 6 exit-code rows added tocli_commands.rs+ 6 unit tests infmt.rs— every behavior above, including mtime preservation,channel-separation assertions, symlink rejection, oversized/bad-UTF8/non-.mds handling, and a
partial+parent round-trip proving a formatted partial still compiles its parent to unchanged output.
cargo test --workspace(1300+ tests, 0 failures),cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings(zero warnings, including the wasm/napi/pythonbinding crates — none of their public surfaces were touched),
cargo check -p mds-core -p mds-cli -p mds-python,node scripts/verify-versions.mjs(unchanged — no version bump for a feature PR).printf 'a \r\n\n\n\nb\n' | mds fmt -→a \n\nb\n(CRLF→LF, blank-collapse, body two-spacebreak preserved)
mds fmt --check --diff crates/mds-cli/tests/fixtures/fmt_unformatted.mds→ diff on stdout,exit 1
snyk_code_scanon the new/modified Rust — 0 issues on the first pass. Thesimilardependency (3.1.1, Apache-2.0, MSRV 1.85 ≤ workspace's 1.88, released 2026-05-23 — pastthe repo's 30-day soak policy) was confirmed via
cargo treeto compile with zero transitivedependencies (a
bstrentry appears inCargo.lock's full resolution graph but is feature-gatedoff and never actually compiled or linked — verified via
cargo tree --all-featuresand checkingtarget/for build artifacts). Snyk's SCA tool doesn't support Cargo/Rust package-managerdetection in this environment, so the dependency review was done manually rather than via
snyk_sca_scan.Deviations from the plan (with justification)
Verified unsound against the live compiler (see above) — stripping it would silently violate
compile-equivalence, which the plan itself states is the non-negotiable, overriding constraint.
The one concrete test case in the plan (
" \n"whole-document input) is still covered, via R2.raw_content_spans) was added for@message/@definebodies,beyond the plan's literal "protected = Frontmatter* ∪ Code*" definition — required once the
clean_output-bypass was discovered; otherwise R1/R3 would silently break message JSON content.rewrite's internal signature takes abody_start: usize(and the newraw_contentspans) beyond the plan's literal 3-argument sketch — needed to correctly distinguish "leading
frontmatter" from "a leading code fence with no frontmatter" for the leading-blank-elision rule.
All of these are private, non-public-API functions.
("clean_output collapses 3+
\n→2") describes what's actually a 2+-blank-line threshold. This PRimplements a direct, empirically-verified mirror of
clean_output's real per-character algorithm(documented in
formatter.rs's module docs) rather than a hand-derived "3+" reading — this is aprecision fix per the plan's own instruction to verify claims against live code, not a behavior
gap (it's a superset of what the literal "3+" wording would require, so AC-EF-5 still holds).
MdsError::FormatterInvariantreachability is covered structurally (the variant exists, hasthe right diagnostic code, is exercised via
mds_error_variants_exist/ a dedicatedformatter_invariant_has_diagnostic_codetest) rather than via a forced-bad-transform test hook,per the plan's own fallback allowance ("if not cleanly feasible, cover it structurally and say so").
fmt_unformatted.mds,fmt_formatted.mds,_fmt_partial.mds,fmt_body_hardbreak.mds,fmt_malformed.mds) live only undercrates/mds-cli/tests/fixtures/(consumed by the CLI's subprocess-based tests). The engine's"corpus" tests use inline string constants instead of on-disk fixtures, since
mds-corehas noexisting fixture-directory corpus walker to match, and inline strings avoid fragile cross-crate
fixture-path coupling.
Reviewer focus areas
crates/mds-core/src/formatter.rs's module docs and theraw_content_spans/rewrite_bodypriority ordering (directive > raw-content > protected > ordinary) — this is the trickiest part
of the engine.
crates/mds-cli/src/fmt.rs,especially how
--checkand--diffcombine.