--- id: biomejs/biome/formatter-development version: "6183bda8" license: Apache-2.0 install: manual updated: 2026-07-27 --- # formatter-development — Learn to implement and modify Biome's formatters using its trait-based IR system. This skill covers generating formatter boilerplate, writing FormatNodeRule implementations, managing comments in formatted output, and validating your work against Prettier using snapshot tests and idempotency checks. Publisher: biomejs · Stars: 25418 · Updated: 2026-07-27 Install (manual): `git clone https://github.com/biomejs/biome` ## SKILL.md ## 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 1. Install required tools: `just install-tools` (includes `wasm-bindgen-cli` and `wasm-opt`) 2. Language-specific crates must exist: `biome_{lang}_syntax`, `biome_{lang}_formatter` 3. For Prettier comparison: Install `bun` and run `pnpm install` in repo root ## Common Workflows ### Generate Formatter Boilerplate For a new language (e.g., HTML): ```shell 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`: ```rust use crate::prelude::*; use biome_formatter::write; use biome_js_syntax::{JsIfStatement, JsIfStatementFields}; #[derive(Debug, Clone, Default)] pub(crate) struct FormatJsIfStatement; impl FormatNodeRule 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: ```rust use biome_formatter::{format_args, write}; write!(f, [ token("if"), // Static text space(), // Single space soft_line_break(), // Break if line is too long hard_line_break(), // Always break // Grouping and indentation group(&format_args![ token("("), soft_block_indent(&format_args![ node.test.format(), ]), token(")"), ]), // Conditional formatting format_with(|f| { if condition { write!(f, [token("something")]) } else { write!(f, [token("other")]) } }), ])?; ``` ### Handle Comments ```rust use biome_formatter::format_args; use biome_formatter::prelude::*; impl FormatNodeRule 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(), // Handle dangling comments (comments not attached to any node) 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: ```shell # 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. Tips: - Pass code snippets in single quotes to avoid shell interpretation. - A literal backslash-n in a CLI argument is not converted to a newline — use a real newline or a file (`-f`). - Safe to run in read-only or planning mode: `--rebuild` only rebuilds the Biome WASM bundle and does not modify source files. - See `packages/prettier-compare/README.md` for full CLI details. ### Format and Build After changes: ```shell just f # Format Rust code just l # Lint just gen-formatter # Regenerate formatter infrastructure if needed ``` ## Testing infrastructure The testing infrastructure of the formatters is divided in two main pieces. Internal and external. The testing infrastructure is designed for catching idempotency cases, which means that each file inside the infrastructure is designed to fail if: - the final **printed** output differs on a second formatting run. - the final **IR** output differs on a second formatting run. **Both must be fixed.** Run `cargo t` twice. The first run may write or update snapshots; the second run re-formats the just-written output and confirms it is stable. Skipping the second run hides idempotency bugs — a broken formatter can look green on a single pass because the snapshot it wrote matches itself. ### `quick_test.rs` - Use `quick_test.rs` inside the crate for testing theories and formatting. - Only modify the `source` string literal inside the test. Do not change the parse/format/assert scaffolding around it — that scaffolding already verifies idempotency and prints the CST and IR you need for debugging. ### External infra The external infra relies on a human pulling the tests inside the repository, inside the folder `/tests/prettier`. Once the tests are ported, the infrastructure produces two files for each original file: - `..prettier-snap` which contains the output generated by Prettier at the moment the test was ported. - `..snap` which contains three sections - the input source - the list of diffs between Prettier and Biome - the output generated by Biome The `.snap` file is only created when Biome's output differs from Prettier's. When the two agree, no `.snap` file is written. The absence of a `.snap` file is **positive** — it means Biome matches Prettier for that input. ### Internal infrastructure The internal infrastructure relies on creating new test files. For each test, place two snippets in the same file: - a piece of source code **already formatted** the way Biome should produce it - the same code in an **unformatted** shape After running the formatter, both snippets should produce identical output. That identity proves the formatter converges on a canonical form and is idempotent. **Always create new test cases when implementing a feature or fixing a bug.** Internal tests exercise the exact shape you care about and survive even if the Prettier corpus changes. **Do not rely on Prettier `.snap` files disappearing as proof of correctness.** A missing `.snap` only means Biome and Prettier agree on that specific ported input. It does not cover the edge cases you introduced — write internal tests for those, and do not delete a Prettier `.snap` to make a diff "go away". ### 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`: ```javascript if (condition) { doSomething(); } if (condition) doSomething(); if (condition) { doSomething(); } else { doOther(); } ``` Run tests: ```shell cd crates/biome_js_formatter cargo test ``` Review snapshots: ```shell cargo insta review ``` ### Test with Custom Options Create `options.json` in the test folder: ```json { "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 80 }, "javascript": { "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } } } ``` This applies to all test files in that folder. ## 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 ```rust // Whitespace space() // Single space soft_line_break() // Break if needed hard_line_break() // Always break soft_line_break_or_space() // Space or break // Indentation indent(&content) // Indent content block_indent(&content) // Block-level indent soft_block_indent(&content) // Indent with soft breaks // Grouping group(&content) // Keep together if possible conditional_group(&content) // Advanced grouping // Text token("text") // Static text dynamic_token(&text, pos) // Dynamic text with position // Utility format_with(|f| { ... }) // Custom formatting function format_args![a, b, c] // Combine multiple items if_group_breaks(&content) // Only if group breaks if_group_fits_on_line(&content) // Only if fits ``` ## 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 [View on SkillFed](https://skillfed.io/biomejs/biome/formatter-development) · [View on GitHub](https://github.com/biomejs/biome)