Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

prim

prim is a single-binary, opinionated, near-zero-config formatter for a repository’s connective tissue — Markdown, JSON/JSONC, YAML, TOML — plus whitespace hygiene on a curated set of un-owned text files.

It is not a source-code formatter and has no plugin system. Think of it as the tool that tidies the files no other formatter owns.

  • One canonical style. No prim.toml, no per-rule knobs — prim honors .editorconfig and nothing else.
  • Semantics-preserving. prim never reorders keys, table entries, or array elements, and never changes the parsed data model of a document.
  • Safe by default. Unparseable or non-UTF-8 files are left byte-for-byte unchanged and reported.

Project status

prim is at an early stage. Recursive file discovery and the format-agnostic whitespace hygiene pass (trailing-whitespace removal, single final line-feed, LF endings) are implemented across the parsed formats and the orphan allowlist, wired through the prim-fmt engine. The structured per-format passes (JSON/YAML/TOML/Markdown) and .editorconfig resolution land in later milestones. See the Specification for the full v1 scope.

Getting started

Install

curl -sSfL https://raw.githubusercontent.com/driftsys/prim/main/install.sh | bash

This downloads the prebuilt prim binary for your platform, verifies its SHA-256 checksum, installs it to ~/.local/bin, and sets up shell completions.

From crates.io

cargo install prim-cli

The crate is prim-cli; the installed binary is prim.

From source

git clone https://github.com/driftsys/prim
cd prim
./bootstrap          # installs git-std and configures git hooks
cargo build --release

First run

prim --version
prim --help

prim formats files in place, or a whole directory tree:

prim README.md config.yaml   # specific files
prim .                       # the current directory, recursively

Note: at this early stage prim applies whitespace hygiene (trailing-whitespace removal, single final line-feed, LF endings) to the parsed formats and the orphan allowlist. Structured per-format formatting (JSON/YAML/TOML/Markdown) lands in later milestones.

Usage

prim [OPTIONS] [PATH]...

Arguments

ArgumentDescription
[PATH]...Files or directories to format. Directories are searched recursively (honoring .gitignore/.ignore/.primignore); defaults to the current directory when omitted.

Options

FlagDescription
--checkWrite nothing; exit non-zero if any file would change, and list it.
--diffPrint a unified diff of pending changes; write nothing.
--stdin-filepath <PATH>Read stdin, write the formatted result to stdout (format-on-save).
--exclude <GLOB>Exclude paths matching the glob (repeatable).
--color <auto|always|never>When to use coloured output (default auto).
--completions <SHELL>Generate a shell completion script and print it to stdout.
-h, --helpPrint help.
-V, --versionPrint version.

Exit codes

CodeMeaning
0Success.
1Changes needed (--check found a difference).
2Error (parse or I/O failure).

Operating modes

  • Default — format the given files in place.
  • --check — a CI gate: exit 1 and list the files that would change.
  • --diff — preview pending changes without writing.
  • --stdin-filepath — editor format-on-save: stdin in, formatted stdout out.

Configuration

prim honors .editorconfig as its only style configuration — there is no prim.toml and there are no per-rule flags. With no .editorconfig present, prim applies its built-in canonical style (LF endings, trailing whitespace stripped, exactly one final newline, two-space indent).

prim resolves the standard .editorconfig cascade for each file: it walks up the directory tree, stops at the nearest root = true, and applies matching per-glob sections (e.g. [*.md]). With --stdin-filepath, the cascade is resolved relative to that path’s directory.

Honored keys:

KeyEffect
end_of_linelf (default) or crlf; the emitted line ending.
trim_trailing_whitespacetrue (default) strips trailing whitespace; false preserves it.
insert_final_newlinetrue (default) keeps one final newline; false strips it.
indent_stylespace/tab — drives JSON/JSONC, TOML, and YAML indentation.
indent_sizeindent width for the JSON/JSONC, TOML, and YAML formatters.
max_line_lengthline width for the structured formatters (default 80).

Scope notes:

  • prim treats files as UTF-8; charset values other than utf-8 are not supported (a non-UTF-8 file is left unchanged and reported).
  • end_of_line = cr (bare carriage return) is treated as lf.
  • An unreadable or malformed .editorconfig is ignored with a warning, and the built-in canonical style applies.

Status: prim applies whitespace hygiene (trailing-whitespace removal, final newline, line endings) — driven by .editorconfig — to every file it owns, and structured canonical formatting to all of its parsed formats: JSON/JSONC (consistent indentation, one space after :, no trailing commas), TOML (canonical spacing, inline-table style preserved), YAML (canonical layout with anchors/aliases and block scalar styles preserved), and Markdown (ATX headings, normalized lists/tables, and prose hard-wrapped to max_line_length with guardrails — inline code, links, tables, and fenced code are never broken, and fenced code is preserved verbatim). All formats preserve comments and never reorder. See the Specification.

Recipes

CI formatting gate

Fail the build when any tracked file is not formatted:

- name: Check formatting
  run: prim --check .

prim --check writes nothing, exits 0 when everything is already formatted, and exits 1 (listing the offending files) otherwise.

Editor format-on-save

Point your editor’s “format with external command” hook at:

prim --stdin-filepath "$FILE"

prim reads the buffer on stdin and writes the formatted result to stdout. The path is used only to select the right formatter.

Excluding files

prim respects .gitignore and .ignore automatically. To exclude a tracked file from formatting (for example a deliberately malformed test fixture, or a generated CHANGELOG.md), add it to a committed .primignore using gitignore syntax:

# .primignore
CHANGELOG.md
fixtures/malformed.json

Using prim with git-std

git-std generates CHANGELOG.md, which prim would otherwise hard-wrap as Markdown. In repositories using both tools, add CHANGELOG.md to .primignore (prim ships this entry by default).

Specification (v1)

This is the human-readable v1 requirements specification for prim. It mirrors issue #1. Code and tests remain the source of truth; this document describes the intended system.

Identity

prim is a single-binary, opinionated, near-zero-config formatter for a repository’s connective tissue — Markdown, JSON/JSONC, YAML, TOML — plus whitespace hygiene on a curated set of un-owned text files. It is not a source-code formatter and has no plugin system.

Settled decisions

ForkDecision
ScopeConfig/docs/data only (md, json/jsonc, yaml, toml). No source code.
ConfigOne canonical style; honor .editorconfig. No prim.toml.
OrderingNever reorder keys/entries/arrays (semantics-preserving).
Other text filesHygiene on a curated orphan allowlist, never on source.
Markdown wrapHard-wrap prose to width (.editorconfig max_line_length, else 80).
JSON5Excluded (JSONC covers comment needs).
.primignoreYes — committed escape hatch (gitignore syntax).
Make / ShellOut of v1 allowlist; shell deferred to Phase 2 (shfmt/wasm).

FR-1 — Structured formatting

  • FR-1.1 prim shall format Markdown to one canonical style (ATX headings, normalized list markers, normalized table padding, normalized blank-line spacing) and hard-wrap paragraph prose to the line width — max_line_length from .editorconfig, else 80.
  • FR-1.1a (wrap guardrails) prim shall wrap prose paragraphs only; it shall not break inside inline code, shall not split a URL or link, shall not wrap tables or fenced code blocks, and shall preserve explicit hard line breaks (trailing \ or two-space).
  • FR-1.2 prim shall format JSON to a canonical style (consistent indentation, one space after :, no trailing commas).
  • FR-1.3 prim shall format JSONC, preserving all comments in position. (JSON5 excluded.)
  • FR-1.4 prim shall format YAML, preserving comments, anchors/aliases, and multi-line scalar styles.
  • FR-1.5 prim shall format TOML, preserving comments and inline-table style.
  • FR-1.6 prim shall preserve fenced code-block contents verbatim (no reformatting of embedded source).

FR-2 — Text hygiene (parsed formats + orphan allowlist)

  • FR-2.1 For every file it processes, prim shall remove trailing whitespace from each line.
  • FR-2.2 prim shall ensure each processed file ends with exactly one line-feed.
  • FR-2.3 prim shall normalize line endings to LF, unless .editorconfig sets end_of_line = crlf.
  • FR-2.4 (scope) prim shall process only (a) the parsed formats (md/json/jsonc/yaml/toml) and (b) a built-in orphan allowlist of un-owned text files. Every other file — recognized source code, unknown types, binaries — is left byte-for-byte unchanged.
  • FR-2.5 prim shall identify allowlisted files by filename/extension, not content sniffing.

FR-3 — Style resolution

  • FR-3.1 prim shall apply its built-in canonical style with no config file present.
  • FR-3.2 prim shall read .editorconfig and honor indent_style, indent_size, max_line_length, end_of_line, charset, insert_final_newline, trim_trailing_whitespace — including the root=true chain and per-glob sections.
  • FR-3.3 prim shall expose no other style configuration (no prim.toml, no per-rule flags).
  • FR-3.4 prim shall never reorder keys, table entries, or array elements.

FR-4 — File discovery

  • FR-4.1 prim shall default to the current working directory, recursively, when given no paths.
  • FR-4.2 prim shall respect .gitignore and .ignore (via the ignore crate) without invoking git, and shall function in non-git directories.
  • FR-4.3 prim shall process explicit file/directory path arguments.
  • FR-4.4 prim shall respect a committed .primignore (gitignore syntax).
  • FR-4.5 prim shall accept CLI exclude globs.

FR-5 — Operating modes (CLI)

  • FR-5.1 (default) prim shall format matched files in place.
  • FR-5.2 --check shall write nothing, exit 0 when all files are already formatted, exit non-zero when any file would change, and list the files that would change.
  • FR-5.3 --diff shall print a unified diff of pending changes and write nothing.
  • FR-5.4 With --stdin-filepath <path>, prim shall read stdin and write the formatted result to stdout.
  • FR-5.5 (exit codes) 0 = success · 1 = changes needed (--check) · 2 = error (parse/IO).

FR-6 — Correctness & safety

  • FR-6.1 (idempotency) Running prim on its own output shall produce zero further changes.
  • FR-6.2 (semantic preservation) Formatting shall not change the parsed data model of a JSON/JSONC/YAML/TOML document.
  • FR-6.3 (fail-safe) An unparseable file shall be left byte-for-byte unchanged and reported as an error (exit 2).
  • FR-6.4 (atomic write) prim shall write via a temporary file and atomic rename, preserving permission bits.
  • FR-6.5 prim shall process only UTF-8 text; it shall leave non-UTF-8 files unchanged and report them.

NFR — non-functional (targets, tunable)

  • NFR-1 One statically linked binary, zero runtime dependencies.
  • NFR-2 Linux/macOS/Windows on amd64 + arm64.
  • NFR-3 (determinism) identical input → byte-identical output on every supported platform.
  • NFR-4 (throughput) format a 5,000-file repository in under 2 s on an 8-core machine with warm cache, parallelized across files.
  • NFR-5 (footprint) peak memory scales with the largest single file, not repository size.

Non-goals

  • No source-code formatting (Rust/JS/TS/Python/Go/…).
  • No plugins or user-facing extensibility API.
  • No linting/diagnostics beyond format-checking.
  • No schema validation.
  • No style knobs beyond .editorconfig.

Phase 2 — roadmap (not v1)

  • prim may format shell scripts (*.sh/*.bash) by embedding shfmt compiled to WebAssembly. This brushes the “no plugins” non-goal and is to be decided deliberately at Phase 2 start: prim has no plugin system (no user-supplied formatters), but may embed specific curated wasm formatters internally.

prim — System Design

prim is a single statically linked binary (prim) that formats a repository’s connective tissue: Markdown, JSON/JSONC, YAML, TOML, plus whitespace hygiene on a curated orphan allowlist. It is not a source-code formatter and has no plugin system.

Workspace structure

The repository is a Cargo workspace with three crates.

prim-fmt is the formatting engine. It is a library with no CLI, terminal, or I/O dependencies. It exposes the public surface that all other crates consume: classify, format, FileKind, Style, LineEnding, and Indent. Per-format structured passes (FR-1) will be added inside this crate as future milestones; the match kind { … } dispatch in format is the intended extension point.

prim-cli is the thin binary crate. Its [[bin]] target is named prim. It owns all I/O: argument parsing (clap), file discovery (ignore), .editorconfig resolution (ec4rs), atomic writes (tempfile), and coloured terminal output (yansi). It calls into prim-fmt exclusively through the format function. cargo install prim-cli is the user-facing install command.

spec (workspace path spec/) is a test-only crate (never published). It holds trycmd CLI-output snapshot tests and shell-based install tests.

Component map

prim-fmt (library, pure)
  classify.rs   FileKind, classify(path) -> Option<FileKind>
  style.rs      Style, LineEnding, Indent  (re-exported from lib.rs)
  error.rs      FormatError (thiserror)  (re-exported from lib.rs)
  hygiene.rs    hygiene(source, &Style) -> String
  json.rs       format(source, &Style) -> Result<String, FormatError>  (dprint-plugin-json)
  toml.rs       format(source, &Style) -> Result<String, FormatError>  (taplo)
  yaml.rs       format(source, &Style) -> Result<String, FormatError>  (pretty_yaml)
  markdown.rs   format(source, &Style) -> Result<String, FormatError>  (dprint-plugin-markdown)
  lib.rs        format(kind, source, &Style) -> Result<String, FormatError>  (dispatch)

prim-cli (binary "prim")
  cli.rs           Cli (clap struct), ColorWhen
  main.rs          entry point — colour init, completions, process::exit
  app.rs           run(&Cli) -> i32 — mode dispatch
  discover.rs      collect(paths, excludes) -> Vec<Discovered>
  editorconfig.rs  resolve(path) -> Style  (ec4rs -> Style)
  diff.rs          unified(path, original, formatted) -> String  (similar)
  write.rs         atomic(path, contents)
  ui.rs            error / warning / would_reformat

Data flow

For every file that prim processes the steps are, in order:

  1. Classifyclassify(&path) returns the FileKind, or None if prim does not own the file. Files that are not owned are left byte-for-byte unchanged and not reported.
  2. Readfs::read_to_string loads the file as UTF-8. A failure is reported (exit 2 for an explicitly named file; warning and skip for a walked file) and the file is not written (FR-6.3, FR-6.5).
  3. Resolveeditorconfig::resolve(&path) walks the .editorconfig cascade from the file’s directory upward. A missing config yields Style::default() (FR-3.1). A malformed or unreadable config falls back to Style::default() with a warning (AD-0002).
  4. Formatprim_fmt::format(kind, &source, &style) applies the whitespace hygiene pass (FR-2), and for structured formats the per-format pass followed by hygiene: Json/Jsonc via dprint-plugin-json (FR-1.2/1.3, AD-0003), Toml via taplo (FR-1.5, AD-0004), Yaml via pretty_yaml (FR-1.4, AD-0005), Markdown via dprint-plugin-markdown (FR-1.1/1.1a/1.6, AD-0006). It returns Result<String, FormatError>; a parse error leaves the file unchanged and is reported as in step 2 (explicit → exit 2, discovered → warning). All per-format passes are now implemented.
  5. Write — if the formatted text differs from the original, write::atomic replaces the file via a same-directory temp file and rename, preserving permission bits (FR-6.4). In --check mode, the path is printed to stdout instead (FR-5.2). In --diff mode, a unified diff is printed to stdout via diff::unified (FR-5.3).

For --stdin-filepath, steps 2 and 5 are replaced by stdin-read and stdout-write respectively; resolve and format use the supplied path for .editorconfig lookup and classification (FR-5.4). A parse error in this mode echoes the original source to stdout unchanged (so format-on-save never blanks the buffer), reports to stderr, and exits 2 (AD-0003).

Command surface and exit codes

InvocationBehaviour
prim [PATH]...Format files in place.
prim --check [PATH]...Exit 1 and list files that would change. Writes nothing.
prim --diff [PATH]...Print unified diff (via similar) to stdout. Writes nothing.
prim --stdin-filepath <p>Read stdin, write formatted result to stdout.
prim --completions <shell>Print shell completion script to stdout.

Exit codes: 0 success · 1 changes needed (–check) · 2 error (parse/IO). See FR-5.5.

Engine API

#![allow(unused)]
fn main() {
// prim_fmt public surface
pub fn classify(path: &Path) -> Option<FileKind>;
pub fn format(kind: FileKind, source: &str, style: &Style) -> Result<String, FormatError>;
pub use style::{Style, LineEnding, Indent};
pub use classify::FileKind;
pub use error::FormatError;
}

Style::default() is prim’s built-in canonical style (FR-3.1): LF line endings, trailing whitespace stripped, exactly one final newline, two-space indent.

Style resolution detail

editorconfig::resolve(path) is the sole I/O consumer of .editorconfig. It calls ec4rs::properties_of(path), applies use_fallbacks() for EditorConfig spec defaults, then maps properties onto Style fields. The mapping is:

EditorConfig keyStyle fieldNotes
end_of_lineend_of_linecr maps to Lf (AD-0002)
trim_trailing_whitespacetrim_trailing_whitespace
insert_final_newlineinsert_final_newlinefalse strips all trailing newlines
indent_style + _sizeindenttab_width fallback applied
max_line_lengthmax_line_lengthoffNone; unset → None
charsetout of scope (AD-0002)

indent drives indentation in the JSON/JSONC, TOML, and YAML passes; max_line_length (default 80) drives line width in those passes and the Markdown prose wrap. (YAML forbids tab indentation, so Indent::Tab falls back to two spaces there — AD-0005.)

Crate boundary invariant

prim-fmt must never depend on clap, yansi, ignore, ec4rs, or any other I/O or terminal crate. The boundary is enforced by the separation into two Cargo packages. All I/O, including .editorconfig file reading, lives exclusively in prim-cli. See AD-0001.

Implementation status (v1 complete)

Implemented: recursive file discovery (FR-4), whitespace hygiene (FR-2), .editorconfig resolution (FR-3), all per-format structured passes — JSON/JSONC (FR-1.2/1.3, AD-0003), TOML (FR-1.5, AD-0004), YAML (FR-1.4, AD-0005), Markdown + prose wrap (FR-1.1/1.1a/1.6, AD-0006) — atomic writes (FR-6.4), UTF-8 fail-safe reporting (FR-6.5), --diff unified output (FR-5.3), and a cross-cutting idempotency/semantic-preservation harness (FR-6.1/6.2, crates/prim-fmt/tests/correctness.rs). prim formats its own Markdown; the repo no longer depends on dprint (AD-0006). All v1 requirements (FR-1 through FR-6) are implemented.

Deferred (post-v1, not requirements): a per-directory Style cache (AD-0002), colorized --diff output.

AD-0001 — Pure engine crate boundary: prim-fmt stays free of I/O

Context

prim needs a formatting engine that external tools or future crates can consume without pulling in a CLI dependency tree. It also needs .editorconfig resolution, file discovery, and atomic writes — all I/O-heavy operations. The question is where the boundary between the two sits.

A single crate containing both the engine and the CLI is the simplest package structure, but it forces any library consumer to resolve clap, yansi, and ignore unless all CLI code is feature-gated. A thin wrapper pattern (one lib crate, one bin crate depending on it) is established practice and the pattern used by the driftsys/git-std archetype.

Options

Single lib+bin package with feature flags. The engine and CLI live together; prim-fmt functionality is gated behind a default-off cli feature. Simpler Cargo.toml; one fewer publish target. Drawback: feature flags are a maintenance surface, and the feature boundary is easily eroded over time.

Two crates: prim-fmt (lib) + prim-cli (bin). The engine is a separate package. prim-cli depends on prim-fmt and adds all CLI dependencies. Library consumers get a lean dep tree at zero cost. Drawback: one extra Cargo.toml and one extra cargo publish step on release.

Monolith. Engine and CLI together, no separation. Simplest for a tool that will never be consumed as a library. Drawback: the maintainer anticipates other crates consuming the engine; the split pays for itself immediately.

Decision

The workspace uses two separate Cargo packages: prim-fmt (library) and prim-cli (binary). prim-fmt must never depend on clap, yansi, ignore, ec4rs, or any I/O or terminal crate. The crate boundary is the enforcement mechanism; no feature flags are needed.

All I/O — .editorconfig reading (ec4rs), file discovery (ignore), atomic writes (tempfile), terminal output (yansi) — lives exclusively in prim-cli. The resolved Style struct lives in prim-fmt so the engine can consume it without any I/O dependency; prim-cli constructs Style values and passes them into prim_fmt::format.

A third crate, spec (test-only, never published), holds CLI snapshot tests and install tests.

Consequences

Per-format parsers (FR-1) belong in prim-fmt or in future prim-* sibling library crates, never in prim-cli. If a parser ever needs I/O (unlikely for a formatting library), that is a design smell to revisit explicitly.

The release pipeline publishes prim-fmt first, then prim-cli, because prim-cli has a path-and-version dependency on prim-fmt.


Satisfies: FR-3 (style resolution placed in CLI; engine stays pure), NFR-1 (single static binary remains achievable when the lib is dep-free).
Related: AD-0002 (editorconfig library choice), docs/design/system.md.

AD-0002 — EditorConfig resolution: ec4rs, semantic choices, and scope cuts

Context

FR-3 requires prim to honor .editorconfig as its only style configuration. Implementing that requires (a) choosing how to parse and cascade .editorconfig files, and (b) settling the semantics for several keys and edge cases that the EditorConfig specification leaves ambiguous or where prim’s design constrains the answer.

Options for the parser/cascade implementation

Hand-roll the INI parser, glob matcher, and cascade walker. The EditorConfig glob grammar includes {a,b}, **, [!…], and numeric ranges — non-trivial to get right. The root = true chain and property precedence rules add further surface. Estimated ~300+ lines of fiddly code to own, maintain, and test against edge cases.

ec4rs (pure Rust). A pure-Rust crate that descends from the editorconfig-core test suite. API: properties_of(path) -> Result<Properties>; Properties::get::<T>() for typed property access; use_fallbacks() for spec defaults. Zero native dependencies. Passes the upstream compatibility test suite.

FFI crates (editorconfig-rs / editorconfig-sys) wrapping C libeditorconfig. The canonical reference implementation. Drawback: introduces a C dependency, which makes cross-compilation for the single-static-binary distribution (NFR-1) significantly harder or impossible without pre-built artifacts.

Decision: use ec4rs

ec4rs is adopted as the sole EditorConfig dependency (ec4rs = "1.2" in prim-cli/Cargo.toml). It solves the implementation problem with minimal owned code, stays pure Rust (preserving NFR-1), and passes the core test suite. FFI crates are rejected because a C dependency undermines the single-static-binary distribution model. Hand-rolling is rejected on minimum-code grounds.

Semantic decisions

The following choices apply to specific EditorConfig keys or edge cases.

insert_final_newline = false — when set, prim strips all trailing newlines so the file ends with content and no line ending. This is the literal reading of the EditorConfig specification (“ensure the file does not end with a newline”). true (the default) preserves today’s behaviour: exactly one final newline.

end_of_line = cr (bare carriage-return, deprecated by EditorConfig) — prim maps this to Lf. FR-2.3 carves out only crlf as an explicit exception to LF normalization. The deprecated cr value has no valid use case in prim’s target file types and falls through to the canonical LF default.

charset — out of scope. prim is a UTF-8-only formatter. Non-UTF-8 files are already left unchanged and reported (FR-6.5). Supporting utf-8-bom, latin1, or utf-16* would require transcoding, which prim does not do. charset is not carried in Style (no consumer, no testable application). This is a deliberate scope cut, not an oversight.

indent and max_line_length — resolved and carried, not yet consumed. Both fields are populated from .editorconfig and stored in Style, but the whitespace-hygiene pass does not consume them. They are available to the per-format parsers (FR-1, issues #9–12) when those land. Carrying them now avoids an API break later and makes resolution testable at the unit level today.

Per-file resolution; no per-directory cache. editorconfig::resolve is called once per file. The .editorconfig cascade depends on the file’s directory path, so caching by directory is possible but not implemented. YAGNI applies: profile first, cache only if NFR-4 (5,000 files < 2 s) shows pressure.

Malformed or unreadable .editorconfig — prim falls back to Style::default() and emits a ui::warning. The file is not left unprocessed. This is the fail-safe posture: a bad config file should not silently corrupt output or block the tool.

Consequences

ec4rs appears as a prim-cli dependency. It does not appear in prim-fmt. Any future change to the EditorConfig handling library is isolated to prim-cli/src/editorconfig.rs and does not affect the engine API.

charset support, if ever needed, requires an explicit follow-up decision and likely a pipeline change (prim would need to detect encoding before the UTF-8 read step). It is not a drop-in field addition.

A per-directory Style cache, if ever implemented, belongs in prim-cli (I/O side). The engine API (format(kind, source, &Style)) does not need to change.


Satisfies: FR-3.1 (canonical default), FR-3.2 (.editorconfig cascade and keys), FR-3.3 (no other config surface), FR-2.3 (end_of_line = crlf branch).
Related: AD-0001 (crate boundary), docs/design/system.md (resolution mapping table), crates/prim-cli/src/editorconfig.rs.

AD-0003 — JSON/JSONC via dprint-plugin-json, and a fallible format

Context

FR-1.2/1.3 require canonical, comment-preserving formatting for JSON and JSONC (JSON5 excluded), without reordering keys or array elements (FR-3.4/6.2). This is the first per-format structured pass, and the first that can fail: an unparseable file must be left byte-for-byte unchanged and reported (FR-6.3). The pre-existing format signature was infallible (-> String), which cannot express a parse failure.

Options for the formatter

dprint-plugin-json (chosen). The dprint JSON formatter, used as a library. Its defaults already satisfy FR-1.2 (one space after :, and with TrailingCommaKind::Never, no trailing commas), it preserves comments (FR-1.3) and the author’s line-break shape, and it never reorders. It is pure Rust with no I/O (the path argument only selects a parse mode), so prim-fmt stays pure. This is the same engine the repository already uses via dprint, so prim’s JSON output matches the established style.

jsonc-parser (CST) + a hand-written printer. dprint’s lower-level parser gives a comment-bearing CST, but prim would own the canonical printer — ~hundreds of lines, with comment re-attachment the tricky part. Rejected on minimum-code grounds.

Hand-rolled tokenizer + printer. Rejected: unicode escapes, number fidelity, and comment attachment are all easy to get subtly wrong, for no benefit over the mature dprint printer.

JSON5 (single quotes, unquoted keys, trailing commas as syntax) is not parsed by jsonc-parser, so JSON5 input becomes a parse error and the file is left unchanged — consistent with “JSON5 excluded”.

Decision: dprint-plugin-json as a library, in a prim-fmt json module

dprint-plugin-json = "0.21" is added to prim-fmt. The integration lives in a json module (not a separate prim-json crate — the glue is ~60 lines; YAGNI). prim_fmt::format dispatches FileKind::Json | FileKind::Jsonc to it; both kinds are formatted identically as JSONC, so comments are preserved even in .json (lenient and semantics-preserving) rather than rejected. Style maps to a dprint Configuration (indent_width/use_tabs from Style::indent, line_width from max_line_length defaulting to 80, trailing_commas = never). The line ending is not set on dprint; the existing hygiene pass owns end-of-line and final-newline normalization, keeping one source of truth for Style’s whitespace semantics across all formats.

Decision: format becomes fallible

format(kind, source, &Style) now returns Result<String, FormatError>. FormatError is a public thiserror enum with a single Parse(String) variant (carrying the parser’s message and location), and will gain variants as YAML and TOML land. The CLI maps a parse error to prim’s existing fail-safe posture:

  • In-place mode — an explicitly named file → error + exit 2; a discovered file → warning; the file is left byte-for-byte unchanged either way (mirrors the non-UTF-8 handling).
  • --stdin-filepath mode — the original source is echoed to stdout unchanged (so an editor’s format-on-save never blanks the buffer) and the error is reported to stderr, exit 2.

Consequences

dprint-plugin-json (and its transitive dprint-core, jsonc-parser, serde, anyhow, text_lines) become prim-fmt dependencies — all pure Rust, no FFI, preserving the single-static-binary model. The format match remains the attach point for the remaining per-format passes; each new parser returns the same Result type. A future split of the json module into a prim-json crate is mechanical if it grows.


Satisfies: FR-1.2 (JSON canonical), FR-1.3 (JSONC comment-preserving), FR-6.3 (unparseable files unchanged and reported).
Related: AD-0001 (pure engine crate boundary), AD-0002 (Style resolution), docs/design/system.md, crates/prim-fmt/src/json.rs.

AD-0004 — TOML via taplo

Context

FR-1.5 requires canonical TOML formatting that preserves comments and inline-table style, without reordering keys or table entries (FR-3.4) or changing the data model (FR-6.2). Unparseable input must be left unchanged and reported (FR-6.3). This is the second per-format structured pass and reuses the fallible format API and hygiene composition established for JSON (AD-0003).

Options

taplo (chosen). The canonical TOML formatter (the engine behind the “Even Better TOML” tooling), used as a library. It canonicalizes spacing and indentation, preserves comments, and exposes per-option control over reordering and inline-table expansion. It is pure Rust; the formatter lives in taplo’s core crate (only the serde default feature — no LSP or schema machinery).

toml_edit (cargo’s format-preserving CST). Preserves comments, inline tables, and order, but preserves the author’s existing formatting rather than canonicalizing it. Producing a canonical style would require prim to write its own normalization rules over the CST. Rejected: more code, weaker canonicalization, when taplo already canonicalizes.

Hand-rolled parser + printer. Rejected on minimum-code grounds; the TOML grammar plus comment and inline-table fidelity are easy to get subtly wrong.

Decision: taplo as a library, in a prim-fmt toml module

taplo = "0.14" is added to prim-fmt. The integration lives in a toml module (mirroring json; not a separate crate). prim_fmt::format dispatches FileKind::Toml to it.

Parse-error detection. taplo’s formatter is lenient — “invalid parts are skipped” — which would silently mangle malformed input. toml::format therefore calls taplo::parser::parse(source) first and returns FormatError::Parse when parsed.errors is non-empty; only a clean parse is formatted (via format_syntax(parsed.into_syntax(), options)). The CLI handling is identical to JSON (explicit → exit 2, discovered → warning, stdin → echo original + exit 2).

Options mapping from Style. indent_string from Style::indent (Spaces(n)n spaces, Tab → a tab); column_width from max_line_length (default 80); inline_table_expand = false to preserve inline-table style (FR-1.5 — taplo defaults this to true); reorder_keys/reorder_arrays/ reorder_inline_tables = false (FR-3.4). taplo’s crlf and trailing_newline are left at their defaults; the existing hygiene pass owns end-of-line and final-newline normalization, keeping one source of truth for Style’s whitespace semantics across all formats. Options is built with struct-update syntax (..Options::default()) to keep clippy’s field_reassign_with_default satisfied.

Array layout. taplo’s array_auto_expand / array_auto_collapse defaults are kept: arrays are reflowed to fit column_width. This changes array layout but never data or order, so it is within “format TOML to a canonical style”.

Consequences

taplo (and its transitive rowan, logos, serde, etc.) become prim-fmt dependencies — all pure Rust, no FFI, preserving the single-static-binary model. Because array collapsing depends on column_width, tests that need to observe per-element indentation set a small max_line_length to force expansion. A future split of the toml module into a prim-toml crate is mechanical if it grows.


Satisfies: FR-1.5 (TOML canonical, comments + inline-table preserved), FR-3.4 (no reorder), FR-6.2 (data model unchanged), FR-6.3 (unparseable files unchanged and reported).
Related: AD-0003 (JSON via dprint-plugin-json; the fallible format API), docs/design/system.md, crates/prim-fmt/src/toml.rs.

AD-0005 — YAML via pretty_yaml

Context

FR-1.4 requires canonical YAML formatting that preserves comments, anchors/aliases, and multi-line (block) scalar styles, without reordering keys or sequence entries (FR-3.4) or changing the data model (FR-6.2). Unparseable input must be left unchanged and reported (FR-6.3). YAML is the hardest format to round-trip, and the Rust ecosystem has few formatter-grade options.

Options

pretty_yaml (chosen). A configurable YAML formatter by g-plane, the YAML member of the same CST-formatter family (tiny_pretty + yaml_parser, a rowan CST) that backs several dprint plugins. format_text(input, &FormatOptions) -> Result<String, SyntaxError> returns a parse error directly on invalid YAML — so no separate parse step is needed. It preserves comments, anchors/aliases, and block (literal | / folded >) scalar styles, and never reorders. Pure Rust.

yaml-rust2 / saphyr / yaml-peg. YAML 1.2 parsers without a canonical, comment-preserving printer. Using one would mean writing prim’s own YAML printer — anchors, aliases, flow vs block, multi-line scalars — far too much surface. Rejected.

serde_yaml. Deprecated, and its value model strips comments and styles. Rejected.

Hand-rolled formatter. Rejected on minimum-code grounds; YAML’s round-trip fidelity is exactly what pretty_yaml already solves.

Decision: pretty_yaml as a library, in a prim-fmt yaml module

pretty_yaml = "0.6" is added to prim-fmt. The integration lives in a yaml module (mirroring json/toml). prim_fmt::format dispatches FileKind::Yaml to it.

Options mapping from Style. LayoutOptions.print_width from max_line_length (default 80); LayoutOptions.indent_width from Style::indent; LayoutOptions.line_break = Lf (the existing hygiene pass owns end-of-line and final-newline, keeping one source of truth across formats). LanguageOptions defaults are used.

Tab indentation. YAML forbids tabs for indentation and pretty_yaml has no tab option, so Indent::Tab falls back to a two-space indent.

Parse errors. format_text returns Err(SyntaxError) on invalid YAML, mapped to FormatError::Parse. CLI handling is identical to JSON/TOML (explicit → exit 2, discovered → warning, stdin → echo original + exit 2).

Consequences

pretty_yaml (and its transitive yaml_parser, rowan, tiny_pretty) become prim-fmt dependencies — all pure Rust, no FFI. With YAML done, only Markdown (#12, FR-1.1) remains among the per-format passes. A pre-existing .editorconfig behavioural test that used a .yaml file as a hygiene-only vehicle was retargeted to a .txt orphan, which stays hygiene-only regardless of future per-format passes.


Satisfies: FR-1.4 (YAML canonical; comments, anchors/aliases, block scalar styles preserved), FR-3.4 (no reorder), FR-6.2 (data model unchanged), FR-6.3 (unparseable files unchanged and reported).
Related: AD-0003 (the fallible format API), AD-0004 (TOML via taplo), docs/design/system.md, crates/prim-fmt/src/yaml.rs.

AD-0006 — Markdown via dprint-plugin-markdown, and retiring dprint

Context

FR-1.1/1.1a/1.6 require canonical Markdown with hard-wrapped prose, guardrails (never break inline code, split links, or wrap tables/fenced code; preserve hard breaks), and verbatim fenced code. This is the last per-format pass. The repo already formatted its Markdown with dprint (the markdown wasm plugin, lineWidth 80, textWrap always), gated by a required CI job — so prim taking over Markdown overlaps and, per the issue, should replace it.

Decision: dprint-plugin-markdown as a library

dprint-plugin-markdown = "0.22" — the same engine the repo already used, now a Rust dependency of prim-fmt, in a markdown module. prim_fmt::format dispatches FileKind::Markdown to it.

  • format_text(text, &Configuration, code_block_cb) -> anyhow::Result<Option<String>>.
  • Config from Style: line_width = max_line_length.unwrap_or(80), text_wrap = TextWrap::Always (FR-1.1 hard wrap). EOL/final newline stay with hygiene.
  • FR-1.6 via the callback: format_code_block_text returns Ok(None), so dprint never reformats embedded code — fenced blocks pass through verbatim.
  • dprint’s defaults give FR-1.1 canonical output (ATX headings, dash list markers, padded tables, normalized blank lines) and its wrapper honors the FR-1.1a guardrails (inline code atomic, links not split, tables/code not wrapped, hard breaks preserved).
  • Markdown is effectively infallible (CommonMark accepts any input), so the FormatError::Parse arm is defensive and unreachable in practice.

Because prim uses the same engine and config as the repo’s dprint setup, prim’s output matches the existing Markdown byte-for-byte — the migration produced zero reformatting churn.

Decision: disable dprint-core debug assertions in the dev profile

dprint-core’s printer carries a debug_assert that panics on valid Markdown containing an inline code span with an embedded newline (e.g. a long backticked span that a previous wrap split across two source lines). Release builds — and the dprint wasm plugins — compile the assertion out, which is why dprint itself never crashed. prim’s dev/test builds hit it.

A targeted profile override in the workspace Cargo.toml disables debug assertions for the dprint-core package only:

[profile.dev.package.dprint-core]
debug-assertions = false

prim’s own assertions are unaffected; only this dependency’s over-aggressive debug check is silenced, so prim is robust on such input in every build. A regression test (markdown::tests::inline_code_spanning_a_newline_does_not_panic) pins the behaviour.

Decision: retire dprint

dprint existed in this repo solely to format Markdown. With prim owning it:

  • dprint.json is deleted.
  • justfile fmt/lint call prim instead of dprint fmt/dprint check.
  • The CI Dprint job is replaced by a prim self-check job (cargo run -p prim-cli -- --check .); the gate’s needs is updated.
  • markdownlint stays as an independent lint (it checks content rules prim does not). prim and markdownlint agree on the repo’s Markdown.

prim now formats all of its own connective tissue — its stated purpose.

Consequences

dprint-plugin-markdown (and the shared dprint-core/jsonc-parser stack from AD-0003) are prim-fmt dependencies. With Markdown done, all per-format passes (FR-1.1–1.6) are implemented; Milestone 3 is complete. The .md-as-hygiene- vehicle behavioural tests were retargeted to .txt orphans, completing the migration of those tests off owned-but-now-structured file types.


Satisfies: FR-1.1 (Markdown canonical + prose wrap), FR-1.1a (wrap guardrails), FR-1.6 (fenced code verbatim), FR-3.4/6.2 (no reorder / data unchanged).
Related: AD-0003 (JSON via dprint-plugin-json; the fallible format API and the shared dprint-core stack), docs/design/system.md, crates/prim-fmt/src/markdown.rs.