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.editorconfigand 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
From a release (recommended)
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
| Argument | Description |
|---|---|
[PATH]... | Files or directories to format. Directories are searched recursively (honoring .gitignore/.ignore/.primignore); defaults to the current directory when omitted. |
Options
| Flag | Description |
|---|---|
--check | Write nothing; exit non-zero if any file would change, and list it. |
--diff | Print 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, --help | Print help. |
-V, --version | Print version. |
Exit codes
| Code | Meaning |
|---|---|
0 | Success. |
1 | Changes needed (--check found a difference). |
2 | Error (parse or I/O failure). |
Operating modes
- Default — format the given files in place.
--check— a CI gate: exit1and 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:
| Key | Effect |
|---|---|
end_of_line | lf (default) or crlf; the emitted line ending. |
trim_trailing_whitespace | true (default) strips trailing whitespace; false preserves it. |
insert_final_newline | true (default) keeps one final newline; false strips it. |
indent_style | space/tab — drives JSON/JSONC, TOML, and YAML indentation. |
indent_size | indent width for the JSON/JSONC, TOML, and YAML formatters. |
max_line_length | line width for the structured formatters (default 80). |
Scope notes:
- prim treats files as UTF-8;
charsetvalues other thanutf-8are not supported (a non-UTF-8 file is left unchanged and reported). end_of_line = cr(bare carriage return) is treated aslf.- An unreadable or malformed
.editorconfigis 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 tomax_line_lengthwith 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
| Fork | Decision |
|---|---|
| Scope | Config/docs/data only (md, json/jsonc, yaml, toml). No source code. |
| Config | One canonical style; honor .editorconfig. No prim.toml. |
| Ordering | Never reorder keys/entries/arrays (semantics-preserving). |
| Other text files | Hygiene on a curated orphan allowlist, never on source. |
| Markdown wrap | Hard-wrap prose to width (.editorconfig max_line_length, else 80). |
| JSON5 | Excluded (JSONC covers comment needs). |
.primignore | Yes — committed escape hatch (gitignore syntax). |
| Make / Shell | Out 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_lengthfrom.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
.editorconfigsetsend_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
.editorconfigand honorindent_style,indent_size,max_line_length,end_of_line,charset,insert_final_newline,trim_trailing_whitespace— including theroot=truechain 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
.gitignoreand.ignore(via theignorecrate) 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
--checkshall write nothing, exit0when all files are already formatted, exit non-zero when any file would change, and list the files that would change. - FR-5.3
--diffshall 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 embeddingshfmtcompiled 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:
- Classify —
classify(&path)returns theFileKind, orNoneif prim does not own the file. Files that are not owned are left byte-for-byte unchanged and not reported. - Read —
fs::read_to_stringloads 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). - Resolve —
editorconfig::resolve(&path)walks the.editorconfigcascade from the file’s directory upward. A missing config yieldsStyle::default()(FR-3.1). A malformed or unreadable config falls back toStyle::default()with a warning (AD-0002). - Format —
prim_fmt::format(kind, &source, &style)applies the whitespace hygiene pass (FR-2), and for structured formats the per-format pass followed by hygiene:Json/Jsoncviadprint-plugin-json(FR-1.2/1.3, AD-0003),Tomlviataplo(FR-1.5, AD-0004),Yamlviapretty_yaml(FR-1.4, AD-0005),Markdownviadprint-plugin-markdown(FR-1.1/1.1a/1.6, AD-0006). It returnsResult<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. - Write — if the formatted text differs from the original,
write::atomicreplaces the file via a same-directory temp file and rename, preserving permission bits (FR-6.4). In--checkmode, the path is printed to stdout instead (FR-5.2). In--diffmode, a unified diff is printed to stdout viadiff::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
| Invocation | Behaviour |
|---|---|
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 key | Style field | Notes |
|---|---|---|
end_of_line | end_of_line | cr maps to Lf (AD-0002) |
trim_trailing_whitespace | trim_trailing_whitespace | |
insert_final_newline | insert_final_newline | false strips all trailing newlines |
indent_style + _size | indent | tab_width fallback applied |
max_line_length | max_line_length | off → None; unset → None |
charset | — | out 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-filepathmode — 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, exit2.
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 withhygiene. - FR-1.6 via the callback:
format_code_block_textreturnsOk(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::Parsearm 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.jsonis deleted.justfilefmt/lintcallpriminstead ofdprint fmt/dprint check.- The CI
Dprintjob is replaced by aprim self-checkjob (cargo run -p prim-cli -- --check .); the gate’sneedsis updated. markdownlintstays 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.