Skip to content

Topiary (language-parametric, tree-sitter)

The modern realization of Box's 1996 idea: do not write a formatter, write a grammar annotation. Topiary formats any language for which a tree-sitter grammar exists by running a set of tree-sitter queries whose capture names are formatting directives — @append_hardline, @prepend_space, @leaf, @append_indent_start. There is no per-language code at all. For a D formatter this is a genuine third option alongside printing from the DMD AST and formatting the token stream, and it is the reason this deep-dive exists.

Language (host)Rust
LicenseMIT
Repositorytweag/topiary @ a307aee6 (2026-08-13)
Enginetopiary-core/src/lib.rs, pretty.rs, atom_collection.rs, tree_sitter.rs, language.rs
Grammar bindingstopiary-queries/queries/<lang>/formatting.scm — 15 languages shipped
Categoryforeign CST (tree-sitter) · declarative capture-driven · queries as configuration
Layout paradigmdeclarative-from-foreign-CST

Overview

What it solves

The cost of a formatter per language. Topiary's pitch:

"Topiary aims to be a uniform formatter for simple languages, as part of the [Tree-sitter] ecosystem. … Authors can create a formatter for a language without having to write their own formatting engine or even their own parser. Users benefit from uniform code style and, potentially, the convenience of using a single formatter tool, across multiple languages over their codebases, each with comparable styles applied." — README.md

The phrase "or even their own parser" is the crux. tree-sitter grammars already exist for most languages — including D, pinned in this repository's own flake — so the marginal cost of a formatter becomes the cost of writing queries.

Design philosophy: capture names are the instruction set

A formatting specification is a .scm query file. From the shipped TOML rules:

scheme
; Sometimes we want to indicate that certain parts of our source text should
; not be formatted, but taken as is. We use the leaf capture name to inform the
; tool of this.
[
  (string)
  (quoted_key)
] @leaf

; Allow blank line before
[
  (comment)
  (table)
  (table_array_element)
  (pair)
] @allow_blank_line_before

; Append line breaks
[
  (comment)
] @append_hardline

topiary-queries/queries/toml/formatting.scm

Every directive in the system is a capture name. The full vocabulary across the shipped grammars is about thirty, and it maps directly onto the layout-IR primitives:

Topiary captureConcept
@append_space / @prepend_spacea space
@append_hardline / @prepend_hardlinea mandatory break
@append_empty_softline / @append_spaced_softlinesoftline / line
@append_input_softlinebreak iff the input had one here — the author's-breaks channel
@append_indent_start / @append_indent_endindent open/close
@append_begin_scope / @append_end_scopea group, delimited rather than nested
@append_begin_measuring_scope / @…end_measuring_scopemeasure this span but break that one
@append_scoped_softline (empty / spaced)a break belonging to a named scope
@leafverbatim — do not format inside
@allow_blank_line_beforeblank-line policy, per node kind
@delete / @do_nothingremove a node / suppress a rule
@append_delimiter / @append_antispaceinsert a token / remove a space
@multi_line_string, @multi_line_indent_allwhitespace-sensitive region handling

Two of these are genuinely novel and worth naming:

  • @append_input_softline makes author's-breaks-preserved a per-capture choice rather than a whole-formatter paradigm. gofmt applies that policy everywhere; zig fmt applies it via trailing commas; topiary lets a grammar author apply it to exactly the constructs where it is wanted.
  • Measuring scopes decouple "what determines whether this fits" from "what breaks if it doesn't". In a group-based algebra those are necessarily the same span; here they are not. This is a real expressiveness gain over the combinator vocabulary.

Scopes instead of nesting

@..._begin_scope / @..._end_scope mark a group by its endpoints rather than by tree containment. This matters because in a foreign CST you do not control the node boundaries — the construct you want to group may not correspond to any single node. Delimited scopes are how a query-driven formatter recovers the grouping the grammar author did not provide.


1. Input model & fidelity

A tree-sitter CST, which is full-fidelity: comments are nodes, and every byte is covered. Topiary therefore inherits tree-sitter's other properties — error recovery, incremental parsing — for free.

Behaviour on unparseable input: tree-sitter always produces a tree with ERROR nodes; Topiary refuses to format when the tree contains errors (an idempotence/safety choice rather than an architectural limit).

Round-trip: an idempotence check is part of the tool's own test discipline, and @leaf gives byte-exact preservation for marked nodes.

2. Layout IR & break decision

Paradigm: declarative-from-foreign-CST. The queries produce a stream of atoms (atom_collection.rs) which pretty.rs renders. Under the hood the break decision is combinator-style fit testing over scopes; the novelty is entirely in how the document is specified.

Width policy: a soft indent/line-width configured per language in languages.ncl.

3. Alignment, indentation & vertical rhythm

Indentation via @append_indent_start/_end; blank lines via @allow_blank_line_before. No column alignment — the capture vocabulary has no align, which is the clearest expressiveness gap versus clang-format or gofmt.

4. Comments, trivia & preservation

Comments are ordinary CST nodes and are captured like anything else ((comment) @append_hardline). Like dfmt, there is no attachment problem — position in the tree is position in the output. @leaf handles verbatim regions; @multi_line_string handles whitespace-sensitive ones.

5. Configurability, opinionation & config discovery

The queries are the configuration. A project can fork a .scm file and change its style without touching Rust. languages.ncl (Nickel) declares per-language settings and grammar sources. This is a fundamentally different configuration model from every other system here: instead of options over a fixed formatter, the formatter itself is data.

6. Integration surface & output contract

Whole document. topiary-cli with --check; no range formatting, no cursor, no edits. A topiary-web-tree-sitter-sys crate targets the browser.


Strengths

  • No per-language code. A formatter is a query file; the marginal cost of a new language is hours, not months.
  • Full-fidelity input for free, with comments as nodes and no attachment problem.
  • Measuring scopes are more expressive than group for foreign trees.
  • @append_input_softline makes author's-breaks a per-construct decision — a genuinely good idea nobody else has.
  • Style is data, forkable per project without a build.
  • Rides the tree-sitter ecosystem, including grammars this repository already pins.

Weaknesses

  • No alignment in the capture vocabulary.
  • Layout quality is bounded by the grammar's shape. If a grammar does not distinguish the construct you want to treat specially, no query can.
  • Query files get large and are hard to debug — the graphviz.rs module exists to visualize what the queries did.
  • Whole-document output, no LSP integration story.
  • Quality ceiling below hand-written formatters for complex languages; it targets "simple languages" by its own README.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Formatting spec as tree-sitter queriesNo engine, no parser, no code per languageExpressiveness is bounded by both the query language and the grammar's node shapes
Capture names as the instruction setReuses tree-sitter's existing tooling and mental model~30 magic strings with no static checking; typos are silent
Delimited scopes rather than tree-nested groupsIn a foreign CST the construct you want to group may not be a nodeBegin/end must be paired correctly by hand
Measuring scopes separate from breaking scopes"does this fit" and "what breaks" are genuinely different spansAn extra concept; more ways to get a query wrong
@leaf for verbatimWhitespace-sensitive constructs need byte preservationAnything not marked is fair game — errors of omission are silent
Refuse trees containing ERROR nodesNever reformat code you may have misparsedGives up tree-sitter's main advantage for editor use
Queries as configurationProjects can restyle without forking the toolNo stable style; two projects' "topiary" output can differ arbitrarily

What this means for D

Topiary is a live third option for a D formatter, and the survey should say so plainly rather than defaulting to a hand-written engine:

  • A tree-sitter-d grammar already exists and is already pinned in this repository's flake (nix/packages/tree-sitter-d.nix), and libs/tree-sitter + libs/syntax already drive whole-buffer CSTs.
  • A query-driven formatter would have no comment attachment problem and no Loc-end problem, because it never touches DMD's AST.
  • It would also have no semantic knowledge, which for D matters more than for TOML: q{} token strings, mixin bodies, and the is-expression grammar are places where a purely syntactic formatter will be blunt.

The honest assessment for the proposal: topiary-style formatting is the cheapest path to a D formatter and is unlikely to beat dfmt on quality, because the ceiling is set by the grammar rather than by the engine. Its ideas — @append_input_softline, measuring scopes, @leaf — are worth taking regardless of the architecture chosen.


Sources

  • tweag/topiary @ a307aee6787602e51087c54f867976949feae383: topiary-core/src/{lib,pretty,atom_collection,tree_sitter,language,graphviz}.rs · topiary-queries/queries/*/formatting.scm (15 languages) · languages.ncl · README.md

Related deep-dives in this tree:Layout preservation · Combinators · Concepts · dfmt · The D landscape · The proposal