Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@
"includes": [
"**/packages/aria-data/*.js",
"**/packages/@biomejs/**",
"**/packages/prettier-compare/**",
"**/packages/tailwindcss-config-analyzer/**",
"**/benchmark/**",
"plugins/*.js",
"scripts/**",
"!**/crates",
"!**/dist",
"!!**/dist",
"!**/packages/@biomejs/backend-jsonrpc/src/workspace.ts",
"!**/__snapshots__",
"!**/undefined",
Expand Down
74 changes: 60 additions & 14 deletions crates/biome_html_syntax/src/element_ext.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::{
AnyHtmlElement, AstroEmbeddedContent, HtmlAttribute, HtmlAttributeList, HtmlElement,
HtmlEmbeddedContent, HtmlOpeningElement, HtmlSelfClosingElement, HtmlSyntaxToken, HtmlTagName,
ScriptType, inner_string_text,
AnyHtmlContent, AnyHtmlElement, AnyHtmlTextExpression, AnySvelteBlock, AstroEmbeddedContent,
HtmlAttribute, HtmlAttributeList, HtmlElement, HtmlEmbeddedContent, HtmlOpeningElement,
HtmlSelfClosingElement, HtmlSyntaxToken, HtmlTagName, ScriptType, inner_string_text,
};
use biome_rowan::{AstNodeList, SyntaxResult, TokenText, declare_node_union};

Expand Down Expand Up @@ -55,23 +55,56 @@ impl AnyHtmlElement {

pub fn name(&self) -> Option<TokenText> {
match self {
Self::HtmlElement(el) => {
let opening_element = el.opening_element().ok()?;
let name = opening_element.name().ok()?;
let name_token = name.value_token().ok()?;
Some(name_token.token_text_trimmed())
}
Self::HtmlSelfClosingElement(el) => {
let name = el.name().ok()?;
let name_token = name.value_token().ok()?;
Some(name_token.token_text_trimmed())
}
Self::HtmlElement(el) => el.tag_name(),
Self::HtmlSelfClosingElement(el) => el.tag_name(),
_ => None,
}
}

/// Returns the closing `>` token from this element's closing tag, if it has one.
///
/// This is used for "borrowing" the closing `>` when formatting adjacent inline elements
/// to avoid introducing whitespace between them.
///
/// Only returns a token for `HtmlElement` (which has actual closing tags like `</span>`).
/// Self-closing elements like `<img />` don't have a separate closing tag to borrow from.
pub fn closing_r_angle_token(&self) -> Option<HtmlSyntaxToken> {
match self {
Self::HtmlElement(el) => el.closing_element().ok()?.r_angle_token().ok(),
// Self-closing elements don't have a closing tag to borrow from
_ => None,
}
}

pub fn is_svelte_block(&self) -> bool {
matches!(
self,
Self::AnyHtmlContent(AnyHtmlContent::AnyHtmlTextExpression(
AnyHtmlTextExpression::AnySvelteBlock(_)
))
)
}

pub fn as_svelte_block(self) -> Option<AnySvelteBlock> {
if let Self::AnyHtmlContent(AnyHtmlContent::AnyHtmlTextExpression(
AnyHtmlTextExpression::AnySvelteBlock(block),
)) = self
{
Some(block)
} else {
None
}
}
}

impl HtmlSelfClosingElement {
/// Returns the tag name of the element (trimmed), if it has one.
pub fn tag_name(&self) -> Option<TokenText> {
let name = self.name().ok()?;
let name_token = name.value_token().ok()?;
Some(name_token.token_text_trimmed())
}

pub fn find_attribute_by_name(&self, name_to_lookup: &str) -> Option<HtmlAttribute> {
self.attributes().iter().find_map(|attr| {
let attribute = attr.as_html_attribute()?;
Expand All @@ -90,6 +123,13 @@ impl HtmlSelfClosingElement {
}

impl HtmlOpeningElement {
/// Returns the tag name of the element (trimmed), if it has one.
pub fn tag_name(&self) -> Option<TokenText> {
let name = self.name().ok()?;
let name_token = name.value_token().ok()?;
Some(name_token.token_text_trimmed())
}

pub fn find_attribute_by_name(&self, name_to_lookup: &str) -> Option<HtmlAttribute> {
self.attributes().iter().find_map(|attr| {
let attribute = attr.as_html_attribute()?;
Expand All @@ -108,6 +148,12 @@ impl HtmlOpeningElement {
}

impl HtmlElement {
/// Returns the tag name of the element (trimmed), if it has one.
pub fn tag_name(&self) -> Option<TokenText> {
let opening_element = self.opening_element().ok()?;
opening_element.tag_name()
}

pub fn find_attribute_by_name(&self, name_to_lookup: &str) -> Option<HtmlAttribute> {
self.opening_element()
.ok()?
Expand Down
63 changes: 63 additions & 0 deletions packages/prettier-compare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# `prettier-compare` Package

## Overview

A CLI tool that compares Prettier and Biome formatting output and IR side-by-side. Uses OpenTUI (with React renderer) for the terminal UI, including spinners during WASM rebuilds and debounced watch mode.

## Architecture

```
packages/prettier-compare/
├── src/
│ ├── index.tsx # Main CLI entry point + React TUI app
│ ├── biome.ts # Biome formatting via @biomejs/js-api
│ ├── prettier.ts # Prettier formatting via npm package
│ ├── languages.ts # Language detection and config mapping
│ ├── components/
│ │ ├── App.tsx # Main app component
│ │ ├── DiffView.tsx # Side-by-side diff display
│ │ ├── DiagnosticsView.tsx # Error/diagnostics section
│ │ └── Spinner.tsx # Loading spinner for rebuilds
│ └── watch.ts # Watch mode with cargo rebuild + debounce
├── bin/
│ └── prettier-compare.js # Bin script
├── package.json
├── tsconfig.json
└── README.md
```

## Key Features

1. **Multiple Input Sources**: Snippet argument, file (`--file`), or stdin
2. **Language Detection**: Auto-detect from file extension or specify with `--language`
3. **Side-by-Side Diff**: Show Biome vs Prettier formatted output
4. **IR Comparison**: Show intermediate representation from both formatters
5. **Diagnostics Section**: Display syntax errors from both tools
6. **Watch Mode**: Rebuild WASM on Rust file changes with debounce and spinner
7. **All Languages**: Support JS/TS, JSON, CSS, HTML, GraphQL, etc.

## Usage

In order to run the tool, you must have the WASM build of Biome available. You can use the `--rebuild` flag to build it automatically if needed, or you can run `just build-wasm-node-dev`.

To run the tool, from the repo root run:

```bash
# Format a snippet
bun packages/prettier-compare/bin/prettier-compare.js "const x={a:1,b:2}"

# Specify language
bun packages/prettier-compare/bin/prettier-compare.js -l ts "const x: number = 1"

# From file
bun packages/prettier-compare/bin/prettier-compare.js -f src/example.tsx

# From stdin
echo "const x = 1" | bun packages/prettier-compare/bin/prettier-compare.js -l js

# Watch mode (rebuilds WASM on Rust changes)
bun --hot packages/prettier-compare/bin/prettier-compare.js -w --watch "function f() { return 1 }"
bun --hot packages/prettier-compare/bin/prettier-compare.js -l html --watch '<tr> <th> A </th> <th> B </th> <th> C </th> </tr>'
```

Note: In watch mode, if you want it to reload the wasm after building biome, then you must use `bun --hot` to run the bin script.
2 changes: 2 additions & 0 deletions packages/prettier-compare/bin/prettier-compare.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env -S bun --hot
import "../src/index.tsx";
25 changes: 25 additions & 0 deletions packages/prettier-compare/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@biomejs/prettier-compare",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Compare Biome and Prettier formatting output and IR side-by-side",
"bin": {
"prettier-compare": "./bin/prettier-compare.js"
},
"dependencies": {
"@biomejs/js-api": "workspace:*",
"@biomejs/wasm-nodejs": "workspace:*",
"@opentui/core": "^0.1.72",
"@opentui/react": "^0.1.72",
"prettier": "^3.7.0",
"prettier-plugin-svelte": "^3.4.0",
"react": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "5.7.3"
},
"license": "MIT OR Apache-2.0"
}
93 changes: 93 additions & 0 deletions packages/prettier-compare/src/biome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Biome formatting integration using @biomejs/js-api
*/

import { Biome, Distribution } from "@biomejs/js-api";

export interface BiomeDiagnostic {
description: string;
severity: string;
}

export interface BiomeResult {
/** The formatted output */
output: string;
/** The formatter IR (intermediate representation) */
ir: string;
/** Any diagnostics/errors encountered */
diagnostics: BiomeDiagnostic[];
}

let biomeInstance: Biome | null = null;

/**
* Get or create a Biome instance.
*/
async function getBiome(): Promise<Biome> {
if (!biomeInstance) {
biomeInstance = await Biome.create({ distribution: Distribution.NODE });
}
return biomeInstance;
}

/**
* Format code using Biome and return the result with IR.
*
* @param code - The source code to format
* @param filePath - Virtual file path for language detection
* @returns The formatting result including output, IR, and diagnostics
*/
export async function formatWithBiome(
code: string,
filePath: string,
): Promise<BiomeResult> {
const biome = await getBiome();
const { projectKey } = biome.openProject();

try {
biome.applyConfiguration(projectKey, {
formatter: {
indentStyle: "space",
indentWidth: 2,
},
html: {
experimentalFullSupportEnabled: true,
formatter: {
enabled: true,
selfCloseVoidElements: "always",
},
},
});

const result = biome.formatContent(projectKey, code, {
filePath,
debug: true,
});

return {
output: result.content,
ir: result.ir ?? "",
diagnostics: result.diagnostics.map((d) => ({
description:
typeof d === "object" && d !== null && "description" in d
? String(d.description)
: String(d),
severity:
typeof d === "object" && d !== null && "severity" in d
? String(d.severity)
: "error",
})),
};
} catch (err) {
return {
output: code,
ir: "",
diagnostics: [
{
description: err instanceof Error ? err.message : String(err),
severity: "error",
},
],
};
}
}
Loading
Loading