# Formatter Development

> 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.

- Skill: `qdhenry/formatter-development` (Agent Skill)
- Install (CLI): `npx skillmds@latest add qdhenry/formatter-development`
- Raw SKILL.md: https://api.skillmd.com/api/skills/qdhenry/formatter-development/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: qdhenry (https://skillmd.com/u/qdhenry)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/qdhenry/formatter-development

---


## 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<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:

```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<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(),
                    // 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.

### 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.

### Format and Build

After changes:

```shell
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

```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

