Single source of truth, no width/tabs/brace-style configuration.
Context
Most modern languages that ship a first-party formatter (gofmt, rustfmt with default config, dotnet format, csharpier) converged on the same answer to the bikeshed question of formatter style: pick one and don't expose knobs. Languages that allow per-project style files (older prettier configs, clang-format) routinely waste cycles in code review on layout disagreements and create friction for cross-project contributions.
The Beskid formatter starts from a clean slate. The reference implementation in compiler/crates/beskid_analysis/src/format/ is already opinionated (four-space indent, brace on same line, one blank line between members) and ships through a single CLI surface (beskid format / beskid fmt). The question this ADR resolves is whether to expose layout configuration to consumers.
Decision
The Beskid formatter must be a canonical, knob-free pretty-printer. The platform ships exactly one layout style. The formatter:
Must not expose user-configurable width, indent unit, brace style, member ordering, blank-line policy, or comment placement.
Must not read project-level style files (no .beskid-format.toml, no [format] section in Project.proj).
Must be a pure function of the parsed AST and the formatter version compiled into the CLI.
May change formatter output between releases under the rules in the parent hub's Compatibility and versioning section (release notes + idempotency preserved + ADR when the layout policy changes substantively).
The CLI must continue to support the operational flags --write, --check, and --output; these affect where the output goes, not what it looks like.
Consequences
Pros:
Zero bikeshedding in code review: there is exactly one correct way for any given input.
Deterministic CI:beskid format --check is a reliable PR gate; no per-repo style file ambiguity.
Simpler editor integrations: every plugin can shell out to the canonical CLI without re-implementing layout logic.
Faster onboarding: new Beskid contributors don't need to learn project-specific style files.
Cons / accepted trade-offs:
Teams with strong existing house style (especially "tabs" or "2-space") cannot satisfy that preference via the formatter; this is by design.
Future syntactic features that might benefit from style-aware emission (for example, partial-application chains) must take a layout decision once and ship it as part of the formatter, not defer to user preference.
Some downstream tools that wrap the formatter and want to expose style flags must either drop those flags or layer them outside the canonical formatter.
Verification anchors
beskid_analysis::format::policy — single file that owns layout policy; no other formatter file emits \n or indent runs directly.
beskid_cli::commands::format::FormatArgs — the clap surface contains only --write, --check, --output; the absence of style flags is observable from beskid format --help.
Parent hub Layout policy section — enumerates the fixed indent unit, blank-line rules, and brace style.
Two anti-patterns would make this hard to evolve safely:
Scattered layout policy — per-construct emitters writing raw \n or four-space runs make a policy change (for example, "double blank between top-level items") a multi-file refactor.
God-trait Emit — putting blank-line policy inside Emit::emit parameters would force the AST-side modules to know about higher-level layout intent.
This ADR records the chosen split.
Decision
The formatter must preserve the following module decomposition:
Emit trait in format/emit.rs defines a single method emit(&self, w, cx). Per-construct modules must implement Emit for the AST node they own.
EmitCtx in format/emit.rs is the only sanctioned place to mutate indent depth, write newlines, write indents, or open/close braces. AST-side modules must call its helpers (write_indent, nl, ln, space, token, open_brace, close_brace) rather than writing raw layout characters.
policy.rs is the only place that owns inter-declaration, inter-member, and inter-statement blank-line decisions. AST-side modules call cx.between_top_level_declarations(...), cx.between_members(...), and cx.between_block_items(...); they do not duplicate the policy.
mod.rs stays as exports-only. New construct emitters are added as siblings under items/ (or as new files when the construct does not fit an existing role).
A change that violates rule (2) or (3) must be revised before merging; the parent Verification and traceability article enumerates the file anchors.
Consequences
Pros:
A new layout rule lands as one edit in policy.rs and an optional method on EmitCtx; downstream emitters don't need to know.
The formatter is easier to audit: anyone reviewing layout output knows to read policy.rs first.
The per-construct emitters stay readable — each function reads as "what does this node look like in source", not "how do I implement blank-line policy".
Cons / accepted trade-offs:
AST-side modules cannot work around policy by writing raw characters; this is intentional and is enforced by code review.
EmitCtx accrues helpers over time. We accept this as the lesser evil compared to scattered policy.
Verification anchors
compiler/crates/beskid_analysis/src/format/emit.rs — single owner of Emit, EmitCtx, Emitter, EmitError, and format_program.
compiler/crates/beskid_analysis/src/format/policy.rs — single owner of the three policy helpers.
compiler/crates/beskid_analysis/src/format/mod.rs — mod declarations and pub use re-exports only; no business logic.
This article documents the internal architecture of the Beskid formatter — how the Emit/EmitCtx/Emitter triad and the policy module cooperate to project a parsed AST back into canonical source. It is informative-with-normative-claims: the shape of the architecture is normative for anyone modifying the formatter; the fine-grained module decomposition is informative.
The article does not redefine the formatter contract (covered by the parent hub) and does not prescribe any consumer-facing knob: there are none.
Be local — emit only the textual representation of the node, never of sibling or parent nodes.
Be deterministic — never observe wall-clock time, environment variables, or randomness.
Be reentrant — child emits use the same &mut EmitCtx; they must restore any context they mutate (indent depth, policy toggles) before returning, except for push_indent/pop_indent pairs that bracket a nested scope.
Use EmitCtx helpers (write_indent, nl, ln, space, token, open_brace, close_brace) rather than writing raw \n/' ' characters, so layout policy changes localize to EmitCtx.
indent: usize — current indent depth in units, where one unit is four spaces. push_indent adds one; pop_indent saturates at zero.
policy_blank_line_between_members: bool — when true (the default), between_members emits one blank line.
open_brace / close_brace are the only sanctioned way to emit braces; they handle the indent push/pop for the block body.
A trait implementation that bypasses these helpers (for example, writing \n directly) is a defect; it breaks the contract that policy changes live in one place.
The blank-line and spacing policy lives in format/policy.rs:
between_top_level_declarations — one newline between top-level items (the default cx.nl(w)).
between_members — one newline between members of a container (struct fields, enum cases, type method blocks, etc.); skipped when policy_blank_line_between_members is false.
between_block_items — extra blank between a block-like statement (if, while, for) and a following let, to keep introductions visually separated.
Adding a new normative rule must be done by extending policy.rs rather than scattering \n writes across items/. Re-running the existing test fixtures after a policy change must continue to pass; if the policy intentionally changes byte output, the fixtures must be regenerated in the same change set.
format_program(&Spanned<Program>) is the user-facing entry point; it constructs an Emitter plus a fresh EmitCtx and dispatches into the AST. There are no caches, no concurrency primitives, and no I/O on the driver side.
Any std::fmt::Error returned by an underlying writer is wrapped as EmitError (the formatter never panics on layout failures). The CLI converts EmitError to a Miette diagnostic via emit_error_semantic_diagnostic, which constructs a single-character span at the start of the source so the user can locate the problem in their editor.
The formatter must propagate parse errors verbatim from the caller (the CLI surfaces them through the existing parser diagnostics) and must not attempt partial formatting of a failed parse.
The CLI layer in compiler/crates/beskid_cli/src/commands/format.rs is the only place the formatter performs I/O. Its responsibilities:
Discover.bd files: a single file, a single tree (with the .git/target/etc. ignore set), or stdin (not yet — currently rejected).
Parse each discovered file via services::parse_program_with_source_name.
Format via format_program.
Dispatch based on flags:
No flags: stdout (single file only).
--output: stdout-equivalent into a single output file.
--write: in-place rewrite.
--check: bail non-zero on the first mismatch with a not formatted: message.
The CLI must not attempt to recover from a parse error by skipping the file; CI behaviors that need partial coverage must drive the formatter on a curated file list rather than tolerate parse failures.
Idempotency — the canonical test is to run format_program(parse(format_program(parse(s)))) and assert byte equality with format_program(parse(s)). This must be exercised in beskid_analysis unit tests.
Policy regressions — any change to policy.rsmust be accompanied by an ADR (or an existing ADR update) and a refresh of fixture outputs.
Drift detection — CI should run beskid format --check against a known-formatted source tree (Beskid’s own corelib and book code samples) to catch regressions early.