Markdown-formatted comments
Code formatters encode a simple principle: source should have a consistent, deterministic style. They eliminate the manual effort of adjusting whitespace and reduce the cognitive burden of reading code written in many subtly different styles. But comments, despite being part of that same maintained source, are usually treated as opaque text, with formatting left as an afterthought.
A large portion of the (relatively minuscule) amount of time I spend typing into an editor now is writing comments. Writing good comments that capture intent, context, or an architectural model is an editorial task involving judgment that I have yet to see coding agents handle consistently, even as their ability to write code improves. Those comments can be critical to the long-term maintainability of a codebase.
What should not require judgment is arranging that prose on the page. I still spend time manually wrapping and reflowing comments to the same line width as the surrounding code, and when agents rewrite comments, they likewise either leave jagged wrapping or spend tokens and iteration cycles manually reflowing prose. That mechanical work should simply be automatic.
Some tools do format comments, but typically as a narrow, opt-in capability rather than a baseline formatter guarantee:
prettier-plugin-jsdocis an optional Prettier plugin that Markdown-formats JSDoc comments, but it only applies to doc comments.rustfmthas thewrap_commentsoption, but it’s unstable, and only wraps lines that are too long; it doesn’t reflow lines that are too short.
As I’ve been working on a new language and its unified compiler toolchain, I wanted first-class, always-on support for deterministic Markdown formatting of comments.
Language design
The language originally had three classes of comments:
- Doc comments: use
///and document a declaration, serving as part of the public api of a package and appearing on hover. - Line comments: use
//and occupy their own line (or multiple lines). - Trailing line comments: use
//and trail regular code on the same line.
I wanted the first two to support full Markdown formatting: prose would wrap to the same fixed line width used for formatting code, while Markdown elements such as headers, bulleted lists, and code blocks would be supported directly.
There is, however, a legitimate need for opaque comments. ASCII diagrams and commented-out code are the most obvious examples: their existing layout clearly should not be changed by the formatter.
To account for this, I introduced a new type of comment: an “opaque line
comment”, beginning with //|, that explicitly opts out of any wrapping or
formatting, while remaining visually unobtrusive. Editor shortcuts to comment
out existing code (often cmd + /) will use opaque line comments, while
beginning a new comment is assumed to be prose and will use //.
The formatting pipeline
For background, source is formatted by:
- Parsing source into a syntax tree and applying canonical source transformations.
- Turning the syntax tree into a
Layoutintermediate representation. - Printing the layout tree to a string.
Our Layout model and printing algorithm are based on Philip Wadler’s
A prettier printer
and the strict formulation presented by Christian Lindig in
Strictly Pretty.
An important invariant: the Layout data model and the print algorithm are
language-agnostic. This is valuable decoupling: as we evolve the language,
adding new syntax features and refactoring the AST data model, the print
algorithm remains unaffected.
Our Layout model is a recursive enum describing a tree of pretty-printing
operations:
enum Layout {
Empty,
Text(String),
Newline,
SpaceOrNewline,
NewlineIfBroken,
BreakEnclosingGroup,
Sequence(Vec<Self>),
Indent(Box<Self>),
Group(Box<Self>),
BrokenOrFlat { broken: Box<Self>, flat: Box<Self> },
OpaqueText(String),
}
Most nodes are self-evident: Empty prints nothing, Text prints text,
BrokenOrFlat describes two alternate layouts which the printing algorithm can
choose between based on available space, etc.
One extension to the standard model was OpaqueText: subtly different from the
standard Text, the printer emits its contents, which may contain newlines,
without treating those newlines as layout operations. This, along with a
lossless syntax tree, allows our formatter to operate on invalid or incomplete
code: regions that cannot be understood by the parser are preserved
byte-for-byte while everything else is printed from its parsed tree.
Implementing Markdown formatting
I first attempted a minimal prototype that only supported prose wrapping: it
used a new Fill layout, which would fill as many items within the available
space before wrapping them. To avoid wrapping non-prose structures such as
bulleted lists and code fences, the layout builder used rough heuristics to
identify Markdown elements: basically whether a line started with #, -, or
another recognized marker.
This worked, and validated the user experience of automatically wrapping comment prose. But it relied on crude heuristics, and didn’t actually support formatting Markdown elements.
Fully featured Markdown formatting requires a real Markdown formatter. Implementing one within the compiler would mean owning substantial parsing and formatting complexity, along with its edge cases and maintenance burden. Reusing an existing formatter was the clear choice.
The codebase already used
dprint-plugin-markdown to
format Markdown files, and I was quite happy with it: it’s well-maintained,
comprehensive, and handles edge cases well. But now we needed to integrate this
formatter into our compiler’s printer engine. This might sound trivial at first,
until indentation and available width enter the picture.
- The Markdown formatter cannot run while building the
Layout, because the width available to the comment depends on its eventual indentation. - It cannot simply run afterward because the printed string has lost the structural boundary between code and Markdown.
- We considered patching
dprint-plugin-markdownto expose its private formatting internals, but lowering its Markdown AST into ourLayoutwould require us to implement and maintain a second Markdown generator, while composing dprint’s unresolved print IR withLayoutwould couple the printer to a different layout model.
I eventually came up with a clean design that preserves the language-agnostic
Layout data model, in the form of a “self-formatting block” layout node using
a Rust trait:
trait SelfFormattingBlock {
fn format(&self, available_width: usize) -> Vec<String>;
}
enum Layout {
// ... existing layout nodes
SelfFormattingBlock(Box<dyn SelfFormattingBlock>),
}
Our language-aware layout builder can then define an implementation of that trait for Markdown comments:
struct MarkdownCommentBlock {
source: String,
line_prefix: &'static str,
}
impl SelfFormattingBlock for MarkdownCommentBlock {
fn format(&self, available_width: usize) -> Vec<String> {
// Call dprint-plugin-markdown's public formatting api,
// using the available width.
}
}
The build step simply turns a comment AST node into:
Layout::SelfFormattingBlock(Box::new(MarkdownCommentBlock {
source,
line_prefix,
}))
while the printer operates on an opaque “self-formatting block”, providing it with the available width at print time.
This keeps each part of the pipeline focused on what it knows. The language-aware builder identifies the comment as Markdown and preserves its source and line prefix without needing to know its eventual rendered width. At print time, the language-agnostic printer supplies that width without knowing anything about Markdown. The block itself delegates the formatting to an existing, comprehensive formatter.
The result is that standalone prose comments receive the same deterministic
treatment as the surrounding code, while //| provides an explicit escape hatch
for content whose layout must remain untouched.