| name | formatter-development |
| description | Guide for implementing formatting rules using Biome's IR-based formatter infrastructure. Use when implementing formatting for new syntax nodes, handling comments in formatted output, or comparing Biome's formatting against Prettier for JavaScript, CSS, JSON, HTML, or other languages. |
| compatibility | Designed for coding agents working on the Biome codebase (github.com/biomejs/biome). |
Purpose
Use this skill when implementing or modifying Biome's formatters. It covers the trait-based formatting system, IR generation, comment handling, and testing with Prettier comparison.
Prerequisites
- Install required tools:
just install-tools (includes wasm-bindgen-cli and wasm-opt)
- Language-specific crates must exist:
biome_{lang}_syntax, biome_{lang}_formatter
- For Prettier comparison: Install
bun and run pnpm install in repo root
Common Workflows
Generate Formatter Boilerplate
For a new language (e.g., HTML):
just gen-formatter html
This generates FormatNodeRule implementations for all syntax nodes. Initial implementations use format_verbatim_node (formats code as-is).
Implement FormatNodeRule for a Node
Example: Formatting JsIfStatement:
use crate::prelude::*;
use biome_formatter::write;
use biome_js_syntax::{JsIfStatement, JsIfStatementFields};
#[derive(Debug, Clone, Default)]
pub(crate) struct FormatJsIfStatement;
impl FormatNodeRule<JsIfStatement> for FormatJsIfStatement {
fn fmt_fields(&self, node: &JsIfStatement, f: &mut JsFormatter) -> FormatResult<()> {
let JsIfStatementFields {
if_token,
l_paren_token,
test,
r_paren_token,
consequent,
else_clause,
} = node.as_fields();
write!(
f,
[
if_token.format(),
space(),
l_paren_token.format(),
test.format(),
r_paren_token.format(),
space(),
consequent.format(),
]
)?;
if let Some(else_clause) = else_clause {
write!(f, [space(), else_clause.format()])?;
}
Ok(())
}
}
Using IR Primitives
Common formatting building blocks:
use biome_formatter::{format_args, write};
write!(f, [
token("if"),
space(),
soft_line_break(),
hard_line_break(),
group(&format_args![
token("("),
soft_block_indent(&format_args![
node.test.format(),
]),
token(")"),
]),
format_with(|f| {
if condition {
write!(f, [token("something")])
} else {
write!(f, [token("other")])
}
}),
])?;
Handle Comments
use biome_formatter::format_args;
use biome_formatter::prelude::*;
impl FormatNodeRule<JsObjectExpression> for FormatJsObjectExpression {
fn fmt_fields(&self, node: &JsObjectExpression, f: &mut JsFormatter) -> FormatResult<()> {
let JsObjectExpressionFields {
l_curly_token,
members,
r_curly_token,
} = node.as_fields();
write!(
f,
[
l_curly_token.format(),
block_indent(&format_args![
members.format(),
format_dangling_comments(node.syntax()).with_soft_block_indent()
]),
r_curly_token.format(),
]
)
}
}
Leading and trailing comments are handled automatically by the formatter infrastructure.
Compare Against Prettier
After implementing formatting, validate against Prettier:
# Compare a code snippet
bun packages/prettier-compare/bin/prettier-compare.js --rebuild 'const x={a:1,b:2}'
# Compare with explicit language
bun packages/prettier-compare/bin/prettier-compare.js --rebuild -l ts 'const x: number = 1'
# Compare a file
bun packages/prettier-compare/bin/prettier-compare.js --rebuild -f path/to/file.tsx
# From stdin (useful for editor selections)
echo 'const x = 1' | bun packages/prettier-compare/bin/prettier-compare.js --rebuild -l js
Always use --rebuild to ensure WASM bundle matches your Rust changes.
Create Snapshot Tests
Create test files in tests/specs/ organized by feature:
crates/biome_js_formatter/tests/specs/js/
├── statement/
│ ├── if_statement/
│ │ ├── basic.js
│ │ ├── nested.js
│ │ └── with_comments.js
│ └── for_statement/
│ └── various.js
Example test file basic.js:
if (condition) {
doSomething();
}
if (condition) doSomething();
if (condition) {
doSomething();
} else {
doOther();
}
Run tests:
cd crates/biome_js_formatter
cargo test
Review snapshots:
cargo insta review
Test with Custom Options
Create options.json in the test folder:
{
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
}
}
}
This applies to all test files in that folder.
Format and Build
After changes:
just f # Format Rust code
just l # Lint
just gen-formatter # Regenerate formatter infrastructure if needed
Tips
- format_verbatim_node: Initial generated code uses this - replace it with proper IR as you implement formatting
- Space tokens: Use
space() instead of token(" ") for semantic spacing
- Breaking: Use
soft_line_break() for optional breaks, hard_line_break() for mandatory breaks
- Grouping: Wrap related elements in
group() to keep them together when possible
- Indentation: Use
block_indent() for block-level indentation, indent() for inline
- Lists: Use
join_nodes_with_soft_line() or join_nodes_with_hardline() for formatting lists
- Mandatory tokens: Use
node.token().format() for tokens that exist in AST, not token("(")
- Debugging: Use
dbg_write! macro (like dbg!) to see IR elements: dbg_write!(f, [token("hello")])?;
- Don't fix code: Formatter should format existing code, not attempt to fix syntax errors
IR Primitives Reference
space()
soft_line_break()
hard_line_break()
soft_line_break_or_space()
indent(&content)
block_indent(&content)
soft_block_indent(&content)
group(&content)
conditional_group(&content)
token("text")
dynamic_token(&text, pos)
format_with(|f| { ... })
format_args![a, b, c]
if_group_breaks(&content)
if_group_fits_on_line(&content)
References
- Full guide:
crates/biome_formatter/CONTRIBUTING.md
- JS-specific:
crates/biome_js_formatter/CONTRIBUTING.md
- Prettier comparison tool:
packages/prettier-compare/
- Examples:
crates/biome_js_formatter/src/js/ for real implementations