Topiary is a uniform formatter designed to support multiple languages through a single, consistent interface. By relying on Tree-sitter grammars and formatting queries, it easily adapts to new languages. But what happens when a language contains other languages inside it?
I’m excited to share how Language Injections work in Topiary, a new feature allowing Topiary to format “forests” of syntax trees embedded within one another. This unlocks support for formatting embedded languages like OCaml snippets inside OCamllex files or code blocks within Markdown documents.
The Problem: Embedded Languages
Many file formats and programming languages permit embedding entirely different syntaxes within them. Take Markdown, for instance. A Markdown document can contain fenced code blocks for arbitrary languages. You can even nest these embeddings! Consider a Markdown document containing some OCamllex (a lexer generator for OCaml).
Before formatting, the nested syntax might look a bit messy:
Example lexer:
```ocamllex
rule main =parse
|_ {print_string "Hello, ";print_endline
"World!"}
```After passing this Markdown file through Topiary, the Markdown, OCamllex, and even the OCaml code nested inside the OCamllex, are all cleanly formatted:
Example lexer:
```ocamllex
rule main = parse
| _ {
print_string "Hello, ";
print_endline
"World!"
}
```Historically, formatters struggle with this. They either leave the embedded code alone, implement ad-hoc parsing for specific combinations, or risk breaking the inner syntax. I wanted Topiary to handle this reliably.
The Solution: Language Injections
The idea of “language injections” comes from the Tree-sitter ecosystem, where it has become a de facto standard for handling embedded languages. Editors like Neovim, Helix, and Zed all use Tree-sitter’s injection mechanism to provide accurate syntax highlighting, code navigation, and other language-aware features inside embedded documents. The technique works by running a secondary Tree-sitter parser over a region of the host document, producing a separate syntax tree for the injected language. Which regions to inject, and which language to use, is specified through injections.scm query files that ship alongside grammars.
Topiary now adopts this same mechanism for formatting. By delegating formatting tasks to another language’s formatter mid-flight, Topiary can correctly format inner code chunks while still respecting the layout of the host language.
I recently merged the foundational Topiary PRs along with two language integrations to demonstrate this capability in action:
- OCamllex / OCaml Injections: Topiary can now format the OCaml semantic actions embedded inside OCamllex files.
- Markdown Injections: Topiary can dynamically format code fences in Markdown documents according to their language identifier.
Deep Dive: How It Works Under the Hood
If you’re new to Topiary, the short version is that it formats code by parsing it with Tree-sitter grammars and then applying declarative formatting rules written as Tree-sitter queries. For a fuller introduction, see the announcement blog post or the Topiary Book.
The injection process relies on Topiary’s existing Tree-sitter foundation and extends the atomization model. At a high level, Topiary parses the host document, extracts the injected spans, delegates their formatting to the inner language’s formatter, and stitches the results back together. Here are the technical details of how I achieved this.
1. Discovering Injections with Queries
If a host language definition includes an injections.scm query file, Topiary runs those queries against the host tree prior to standard formatting (a query can be thought of as a pattern-matching function that takes an AST of the host document and produces a list of matching spans). The queries dictate which spans of the host AST contain foreign code.
Topiary supports two methods of resolving the injected language:
Static Resolution:
For languages where the embedded language is always known (e.g., OCamllex always embeds OCaml), the injection query uses an #injection_language! predicate against the relevant AST node (in the tree-sitter-ocamllex grammar, the code block node is handily named ocaml):
;; injections.scm
(
(ocaml) @injection.content
(#injection_language! "ocaml")
)Dynamic Resolution:
For formats like Markdown where the inner language isn’t known ahead of time, Topiary dynamically captures the language identifier from the AST (e.g., the info string of a code fence) using the @injection.language capture (a capture simply assigns a name to a matched node in the query):
;; injections.scm
(fenced_code_block
(info_string
(language) @injection.language
)
(code_fence_content) @injection.content
)2. Atomization and Rewriting
Once the injection queries identify the embedded spans, Topiary proceeds with the host language formatting. Crucially, Topiary treats the captured @injection.content nodes as leaves during query matching.1 This means the host formatter doesn’t traverse inside them.
After the host document is tokenized into atoms (the basic building blocks of the layout engine, representing either raw text fragments or formatting directives like spaces and line breaks), Topiary iterates through the captured injection spans:
- Each injected span is formatted independently by its corresponding inner language formatter.
- The inner formatters execute as if they are producing text starting from column zero.
- The resulting formatted string replaces the corresponding host leaf in the atom stream.
There is no “re-parse” phase after the injected text is rewritten. The host renderer retains control over the indentation; when it finally prints the substituted atom, it applies the appropriate indentation string from the host context.
Because step 1 invokes the full formatting pipeline, injections are naturally recursive. If the injected language itself defines an injections.scm file, its injections will be discovered and formatted in turn. For example, formatting the nested Markdown/OCamllex/OCaml snippet from the top of this post doesn’t require any additional effort: Topiary will format all three layers without any special orchestration. Markdown delegates to OCamllex, which delegates to OCaml, each through the exact same code path!
3. Performance
The most expensive part of formatting an injected language is compiling its grammar and parsing its query files. If Topiary recompiled the queries for every single injected span, formatting a document with 100 embedded snippets would be prohibitively slow.
To solve this, Topiary needs a way to compile the grammar once and share it across all matching spans. This ensures the total cost is sublinear in the number of injections.
3.1. The LanguageResolver Hook
To achieve this caching dynamically, I introduced the LanguageResolver type alias to the core formatter API. Topiary is not just a CLI; it is also a library. Because the core library doesn’t know how languages are configured or where grammars live on disk, I delegate the responsibility of resolving languages to the frontend via this hook:
pub type LanguageResolver<'a> = dyn Fn(&str) -> FormatterResult<Option<Arc<Language>>> + 'a;Let’s unpack this piece by piece:
dyn Fn(&str): The resolver is a trait object, specifically, a callable value that takes a language name (e.g.,"ocaml"or"rust") as a string slice. Using a trait object here means the core formatter doesn’t need to know which concrete function performs the resolution; it just calls whatever the CLI (or any other frontend) hands it.-> FormatterResult<Option<Arc<Language>>>: The return type is aResultwrapping anOption:Ok(Some(language))means the language was found and loaded. Topiary proceeds to format the injected span with that language’s grammar and queries.Ok(None)is a soft failure: the language isn’t configured or isn’t supported. Topiary skips formatting for that span gracefully, leaving it untouched.Err(...)is a hard failure (e.g., a query file failed to parse). Topiary aborts the entire formatting run and returns an error without modifying the file.
Arc<Language>: The resolvedLanguageis wrapped in an atomically reference-counted pointer. Topiary compiles the Tree-sitter grammars and queries into aLanguagefor formatting;Arcenables caching. The CLI can compile theLanguageon the first call and return clonedArchandles on subsequent calls, ensuring the compilation overhead is shared across all injected spans of the same language.+ 'a: A lifetime bound tying the resolver to a borrow scope. In practice, this gives the resolver the flexibility to temporarily reference external data (such as the CLI’s configuration cache) without requiring permanent ownership of it.
This design decouples Topiary’s core formatting logic from any particular frontend. The core library doesn’t know how caching works; it just calls the resolver and acts on the result.
Limitations
These changes do a great job in the majority of use cases, but still have a few known limitations.
Firstly, because injected spans are formatted independently, the current injection model is stateless. It cannot express layout decisions that require measuring or choosing between softline layouts across the host/injected boundary. For example, consider a Markdown document like this:
Let's first define a greeting function that takes a string reference:
```rust
fn greet(name: &str) {
```
Now we complete the function by printing the greeting to standard output:
```rust
println!("Hello, {name}!");
}
```Here a Rust function definition is split across two code fences. Since each fence is its own injection, Topiary formats them independently. The first block sees an unclosed brace, and the second sees an indented statement followed by a closing brace with no matching opener. Neither fragment is valid Rust on its own, so the formatter can’t make sensible layout decisions about them. In practice, because the inner language fails to parse, Topiary will abort the entire formatting run and return an error.
However, if you pass the --tolerate-parsing-errors flag, Topiary will do its best. Because each fragment is formatted independently as if starting from column zero, the indentation on the second fragment gets completely stripped:
Let's first define a greeting function that takes a string reference:
```rust
fn greet(name: &str) {
```
Now we complete the function by printing the greeting to standard output:
```rust
println!("Hello, {name}!");
}
```Overall, I believe this is a perfectly acceptable trade-off: purity and statelessness provide an elegant and robust formatting pipeline, which is not worth giving up for gaining the ability to handle fragmented constructs, which are less common.
Another known limitation is that, while Tree-sitter grammars are usually flexible enough to parse individual definitions or expressions, if a grammar strictly expects a full source file and nothing less, it won’t be able to parse short injected snippets correctly. Just like with fragmented code blocks, this will result in a parsing error unless --tolerate-parsing-errors is used. Fortunately, most grammars (e.g. Java, C#, or Rust) are remarkably flexible and do not exhibit this problem.
Conclusion
By leveraging language injection queries and extending the core formatting API, Topiary can now cleanly format embedded code snippets without breaking the host language layout. With language injections, Topiary is no longer just formatting single syntax trees: it’s formatting forests.
This feature will be included in an upcoming release. In the meantime, if you’re working with OCamllex (for which Topiary is, to my knowledge, the only complete formatter!) or writing technical Markdown documents, you can try it out today.
To run it on an OCamllex file using Nix, for example, you can use:
nix run github:topiary/topiary -- fmt myfile.mll- Usually embedded language spans are already parsed as leaves by the tree-sitter grammar itself. But they don’t have to, so enforcing that Topiary only sees leaves no matter what the grammar says is safer.↩
Behind the scenes
A software engineer with experience in creating web-based functional programs, maintaining a functional programming language and designing and implementing tools enhancing developer productivity. Their personal goal is to get as many people as possible working with functional programming languages.
If you enjoyed this article, you might be interested in joining the Tweag team.