Skip to content

Comment-preserving formatter (fmt) for Ktav documents #13

Description

@PHPCraftdream

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions