Summary
Ship a comment-preserving formatter for Ktav documents: text -> text, stable
under repeated application, suitable for a pre-commit hook and a --check
mode in CI. The goal is that a diff stops depending on who wrote an object
inline and who wrote it as a block.
This came out of a reader question: the canonical form already normalises
exactly the thing people want normalised, but today it is only reachable as a
library call on a parsed Value, and it is the wrong substrate for a
formatter.
Why this is NOT just exposing emit_canonical
emit_canonical renders a Value, and comments are not in the Value model.
Spec 0.7.0 § 5.9.2 is explicit:
Comments (lines starting with ##) are never emitted. Comments present in
the original input are not part of the Value model and have no canonical
representation.
Observed, on ktav 0.7.0:
## why this port
db: {host: primary, port: 5432}
parse -> emit_canonical gives
db: {
host: primary
port: 5432
}
The structural normalisation is exactly right — inline and block spellings of
the same object converge on one output, verified byte-identical. But the
comment is gone, which makes a Value round-trip unusable as a formatter: no
one will put a hook in pre-commit that deletes their comments.
So a formatter cannot round-trip through Value. It needs a lossless view of
the source — a syntax tree or token stream that carries trivia (comments, blank
lines, and the original line order) alongside the structural nodes.
Note also that Ktav has no trailing comments at all (§ 3.4: a comment is a line
whose first non-whitespace code points are ##; trailing comments are not
supported). That simplifies the trivia model considerably compared to most
formatters: every comment owns a whole line, so a comment attaches cleanly to
the line that follows it (or to end-of-file), and there is no "does this
trailing comment belong to the item before or the line below" ambiguity.
Scope
- A
text -> text formatting entry point in the ktav crate, producing
canonical structure per § 5.9 while preserving comment lines and their
attachment.
- A CLI binary. There is currently no binary anywhere in this repository
(no [[bin]], no src/main.rs) — the only executable in the ecosystem is
ktav-lsp in ktav-lang/editor. At minimum: format in place, format to
stdout, and a --check mode that exits non-zero when a file is not already
formatted, so it can gate CI as well as pre-commit.
- Idempotence as a hard property, pinned by tests:
fmt(fmt(x)) == fmt(x) over
the whole conformance corpus, not just a handful of examples.
- Comment preservation pinned by tests over a corpus of commented documents:
count, text, and attachment point all survive.
Open questions to resolve while implementing
- Blank lines. § 3.5 says blank lines produce no Value and are ignored.
Should the formatter preserve them as trivia (people use them to group
related keys), collapse runs of them to one, or drop them? Canonical form
has no opinion because they are not in the Value model. Whatever is chosen
has to keep idempotence.
- Where does the trivia-carrying representation live? Options: extend the
existing thin/event parser (parse_events) to emit comment and blank-line
events; build a separate lossless tree; or keep a side table of
(line, trivia) and reassemble. This decision drives how much of the
formatter each binding has to reimplement versus call through the C ABI.
- Does
fmt output have to equal emit_canonical output for a
comment-free document? Making that an invariant would be a strong,
cheaply-testable guarantee and would let the conformance corpus's 221
.canonical.ktav files double as formatter fixtures. If it is not an
invariant, say why in the docs, because everyone will assume it.
- Key order. Canonical form does not reorder keys. Confirm the formatter
must not either — a formatter that reorders keys is a refactoring tool, not
a formatter, and would make review diffs worse rather than better.
Propagation
Once the Rust side is settled, this gets pushed out through the C ABI to the
six bindings, the same way the structured-error envelope is being propagated.
Two things to keep in mind when scoping that work:
- The bindings do not all expose the canonical writer today.
ktav-lang/js in
particular does not surface emitCanonical through its TypeScript facade at
all, so for JS this is an API-surface decision before it is a formatter
decision.
- A standalone CLI also partly overlaps
ktav-lang/spec#2 (a lightweight
CLI/WASM Ktav -> JSON translator, for consumers who do not want a full FFI
binding). If a single binary is going to carry both fmt and decode, that
should be decided before either is built, not after.
Definition of done
- Formatting a document preserves every comment line and its attachment, and
normalises structure to § 5.9 canonical form otherwise.
fmt(fmt(x)) == fmt(x) holds across the full conformance corpus.
- A comment-free document's formatted output matches
emit_canonical — or the
documented reason it does not.
--check exits non-zero on unformatted input and zero otherwise, with no
output written.
- The four open questions above are resolved in the implementation and the
resolutions documented, rather than left implicit.
Summary
Ship a comment-preserving formatter for Ktav documents:
text -> text, stableunder repeated application, suitable for a
pre-commithook and a--checkmode in CI. The goal is that a diff stops depending on who wrote an object
inline and who wrote it as a block.
This came out of a reader question: the canonical form already normalises
exactly the thing people want normalised, but today it is only reachable as a
library call on a parsed
Value, and it is the wrong substrate for aformatter.
Why this is NOT just exposing
emit_canonicalemit_canonicalrenders aValue, and comments are not in theValuemodel.Spec 0.7.0 § 5.9.2 is explicit:
Observed, on ktav 0.7.0:
parse ->
emit_canonicalgivesThe structural normalisation is exactly right — inline and block spellings of
the same object converge on one output, verified byte-identical. But the
comment is gone, which makes a
Valueround-trip unusable as a formatter: noone will put a hook in
pre-committhat deletes their comments.So a formatter cannot round-trip through
Value. It needs a lossless view ofthe source — a syntax tree or token stream that carries trivia (comments, blank
lines, and the original line order) alongside the structural nodes.
Note also that Ktav has no trailing comments at all (§ 3.4: a comment is a line
whose first non-whitespace code points are
##; trailing comments are notsupported). That simplifies the trivia model considerably compared to most
formatters: every comment owns a whole line, so a comment attaches cleanly to
the line that follows it (or to end-of-file), and there is no "does this
trailing comment belong to the item before or the line below" ambiguity.
Scope
text -> textformatting entry point in thektavcrate, producingcanonical structure per § 5.9 while preserving comment lines and their
attachment.
(no
[[bin]], nosrc/main.rs) — the only executable in the ecosystem isktav-lspinktav-lang/editor. At minimum: format in place, format tostdout, and a
--checkmode that exits non-zero when a file is not alreadyformatted, so it can gate CI as well as
pre-commit.fmt(fmt(x)) == fmt(x)overthe whole conformance corpus, not just a handful of examples.
count, text, and attachment point all survive.
Open questions to resolve while implementing
Should the formatter preserve them as trivia (people use them to group
related keys), collapse runs of them to one, or drop them? Canonical form
has no opinion because they are not in the Value model. Whatever is chosen
has to keep idempotence.
existing thin/event parser (
parse_events) to emit comment and blank-lineevents; build a separate lossless tree; or keep a side table of
(line, trivia) and reassemble. This decision drives how much of the
formatter each binding has to reimplement versus call through the C ABI.
fmtoutput have to equalemit_canonicaloutput for acomment-free document? Making that an invariant would be a strong,
cheaply-testable guarantee and would let the conformance corpus's 221
.canonical.ktavfiles double as formatter fixtures. If it is not aninvariant, say why in the docs, because everyone will assume it.
must not either — a formatter that reorders keys is a refactoring tool, not
a formatter, and would make review diffs worse rather than better.
Propagation
Once the Rust side is settled, this gets pushed out through the C ABI to the
six bindings, the same way the structured-error envelope is being propagated.
Two things to keep in mind when scoping that work:
ktav-lang/jsinparticular does not surface
emitCanonicalthrough its TypeScript facade atall, so for JS this is an API-surface decision before it is a formatter
decision.
ktav-lang/spec#2(a lightweightCLI/WASM Ktav -> JSON translator, for consumers who do not want a full FFI
binding). If a single binary is going to carry both
fmtanddecode, thatshould be decided before either is built, not after.
Definition of done
normalises structure to § 5.9 canonical form otherwise.
fmt(fmt(x)) == fmt(x)holds across the full conformance corpus.emit_canonical— or thedocumented reason it does not.
--checkexits non-zero on unformatted input and zero otherwise, with nooutput written.
resolutions documented, rather than left implicit.