General development best practices and common gotchas when working on Biome. Use for avoiding common mistakes, understanding Biome-specific patterns (AST, syntax nodes, string extraction, embedded languages), and learning technical tips.
60
70%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./.claude/skills/biome-developer/SKILL.mdThis skill provides general development best practices, common gotchas, and Biome-specific patterns that apply across different areas of the codebase. Use this as a reference when you encounter unfamiliar APIs or need to avoid common mistakes.
DO:
quick_test to inspect AST structure before implementingVueDirective and VueV*ShorthandDirective)Option<T> or SyntaxResult<T> instead of scattering early returns throughout the caller — this makes code more readable and composableDON'T:
quick_test insteadExample - Inspecting AST:
// In crates/biome_html_parser/tests/quick_test.rs
// Modify the quick_test function:
#[test]
pub fn quick_test() {
let code = r#"<button on:click={handleClick}>Click</button>"#;
let source_type = HtmlFileSource::svelte();
let options = HtmlParserOptions::from(&source_type);
let root = parse_html(code, options);
dbg!(&root.syntax()); // Shows full AST structure
}Run: just qt biome_html_parser
Example - Extracting CST Navigation Logic:
// WRONG: Many early returns scattered in the caller
fn visit_attribute(&self, attr: JsxAttribute, collector: &mut Collector) {
let Ok(name_node) = attr.name() else { return };
let name_text = match name_node {
AnyJsxAttributeName::JsxName(n) => match n.value_token() {
Ok(t) => t.token_text_trimmed(),
Err(_) => return,
},
AnyJsxAttributeName::JsxNamespaceName(_) => return,
};
if name_text != "class" && name_text != "className" {
return;
}
let Some(jsx_string) = attr.initializer().and_then(|i| i.value().ok()) else {
return;
};
// ... do the real work
}
// CORRECT: Extract helper that returns Option<T>
fn visit_attribute(&self, attr: JsxAttribute, collector: &mut Collector) {
if let Some(inner) = self.extract_class_attribute_inner(&attr) {
self.collect_classes(&inner, collector);
}
}
fn extract_class_attribute_inner(&self, attr: &JsxAttribute) -> Option<TokenText> {
let name_node = attr.name().ok()?;
let name_text = match name_node {
AnyJsxAttributeName::JsxName(n) => n.value_token().ok()?.token_text_trimmed(),
AnyJsxAttributeName::JsxNamespaceName(_) => return None,
};
if name_text != "class" && name_text != "className" {
return None;
}
let jsx_string = attr.initializer().and_then(|i| i.value().ok())?;
jsx_string.inner_string_text().ok()
}The helper uses ? operator and Option combinators — much cleaner than scattered else { return } blocks. The caller now has a single if let Some that clearly expresses intent.
DO:
inner_string_text() when extracting content from quoted strings — it strips the surrounding quotes and returns a TokenText backed by the same green token (no allocation)text_trimmed() when you need the full token text without leading/trailing whitespacetoken_text_trimmed() on nodes like HtmlAttributeName to get the text contentHtmlString (quotes) or HtmlTextExpression (curly braces)TokenText::slice() or inner_string_text() to get sub-ranges of a token — both return a TokenText backed by the same GreenToken (ref-count bump only, no heap allocation)DON'T:
text_trimmed() when you need inner_string_text() for extracting quoted string contents.text() on a SyntaxToken — it returns raw text including surrounding trivia (whitespace, newlines). Always use .text_trimmed() instead.&s[1..s.len()-1] — use inner_string_text() instead; it is correct, allocation-free, and communicates intentword.to_string() or String::from(word) to store individual words split out of a string token — store the TokenText of the whole token plus a token-relative TextRange instead (see below)Example - String Extraction:
// WRONG: text_trimmed() includes quotes
let html_string = value.as_html_string()?;
let content = html_string.value_token()?.text_trimmed(); // Returns: "\"handler\""
// CORRECT: inner_string_text() removes quotes
let html_string = value.as_html_string()?;
let inner_text = html_string.inner_string_text().ok()?;
let content = inner_text.text(); // Returns: "handler"Example - CSS class name extraction from CssClassSelector:
// WRONG: .text() includes trivia
let name = selector.name().ok()?.value_token().ok()?.text(); // may include whitespace
// CORRECT: always use text_trimmed() on SyntaxToken
let name: &str = selector.name().ok()?.value_token().ok()?.text_trimmed();
// For owned value:
let name: TokenText = selector.name().ok()?.value_token().ok()?.token_text_trimmed();When you need to split a string token (e.g. class="foo bar baz") into individual words and store each word, do not allocate a String per word. Instead, store the TokenText of the whole token and a TextRange that is relative to the token text (not the file).
// WRONG: allocates a String per word
for word in content.split_ascii_whitespace() {
collected.push(word.to_string()); // heap allocation per word
}
// CORRECT: store token + token-relative range
// Use inner_string_text() to get the quote-stripped TokenText first.
let inner: TokenText = html_string.inner_string_text()?;
let content = inner.text();
let mut offset: u32 = 0;
for word in content.split_ascii_whitespace() {
let word_offset = content[offset as usize..]
.find(word)
.map_or(offset, |pos| offset + pos as u32);
let start = TextSize::from(word_offset);
let end = start + TextSize::from(word.len() as u32);
collected.push(MyEntry {
token: inner.clone(), // refcount bump only
range: TextRange::new(start, end),
});
offset = word_offset + word.len() as u32;
}
// Later, to read the word back:
fn text(&self) -> &str {
&self.token.text()[usize::from(self.range.start())..usize::from(self.range.end())]
}Key points:
inner_string_text() returns a TokenText whose .text() starts at byte 0 of the unquoted content. Word offsets within that are directly usable as token-relative ranges.TokenText::clone() is a refcount bump on the underlying GreenToken — it does not copy string data.u32::from(value_token.text_trimmed_range().start()) + 1 (the +1 skips the opening quote).DO:
EmbeddingKind for context (Vue, Svelte, Astro, etc.)is_source: true (script tags) vs is_source: false (template expressions)text_range().start() for text expressionsDON'T:
Example - Different Value Formats:
// Vue directives use quoted strings: @click="handler"
let html_string = value.as_html_string()?;
let inner_text = html_string.inner_string_text().ok()?;
// Svelte directives use text expressions: on:click={handler}
let text_expression = value.as_html_attribute_single_text_expression()?;
let expression = text_expression.expression().ok()?;DO:
let bindings to avoid temporary value borrows that get droppedDON'T:
Example - Avoiding Borrow Issues:
// WRONG: Temporary borrow gets dropped
let html_string = value.value().ok()?.as_html_string()?;
let token = html_string.value_token().ok()?; // ERROR: html_string dropped
// CORRECT: Store intermediate result
let value_node = value.value().ok()?;
let html_string = value_node.as_html_string()?;
let token = html_string.value_token().ok()?; // OKDO:
let chains to collapse nested if let statements (cleaner and follows Rust idioms)just l before committing to catch clippy warningsDON'T:
Vec<some_crate::module::Type>) — add a use import insteadExample - Collapsible If:
// WRONG: Nested if let (clippy::collapsible_if warning)
if let Some(directive) = VueDirective::cast_ref(&element) {
if let Some(initializer) = directive.initializer() {
// ... do something
}
}
// CORRECT: Use let chains
if let Some(directive) = VueDirective::cast_ref(&element)
&& let Some(initializer) = directive.initializer()
{
// ... do something
}Example - Import types, don't inline paths:
// WRONG: Inlined crate path in type position
enum Frame {
Visit(biome_css_semantic::model::RuleId),
}
// CORRECT: Import the type at the top of the file
use biome_css_semantic::model::RuleId;
enum Frame {
Visit(RuleId),
}Example - Let the compiler infer types:
// WRONG: Redundant type annotation — the compiler infers this from FxHashSet::default()
let mut visited: FxHashSet<RuleId> = FxHashSet::default();
// CORRECT: No annotation needed
let mut visited = FxHashSet::default();For comment and rustdoc style, see the doc-comments skill. It defines who the reader is, the separate jobs of // / /// / //!, the deletion test, and the banned patterns. Do not duplicate that guidance here.
workspace = true vs path = "..."Internal biome_* crates listed under [dev-dependencies] MUST use path = "../<crate_name>", not workspace = true. Using workspace = true for dev-dependencies can cause Cargo to resolve the crate from the registry instead of the local workspace, which is incorrect.
Regular [dependencies] still use workspace = true as normal — this rule only applies to [dev-dependencies].
DO:
path = "../biome_foo" for all biome_* dev-dependenciesfeatures when convertingDON'T:
workspace = true for biome_* crates in [dev-dependencies]Example:
# WRONG: may resolve from registry
[dev-dependencies]
biome_js_parser = { workspace = true }
biome_formatter = { workspace = true, features = ["countme"] }
# CORRECT: always resolves locally
[dev-dependencies]
biome_js_parser = { path = "../biome_js_parser" }
biome_formatter = { path = "../biome_formatter", features = ["countme"] }All crates live as siblings under crates/, so the relative path is always ../biome_<name>.
DO:
DON'T:
Example:
Svelte's on:click event handler syntax is legacy (Svelte 3/4). Modern Svelte 5 runes mode uses regular attributes. Unless users specifically request it, don't implement legacy syntax support.
For testing commands, snapshot workflows, and code generation, see the testing-codegen skill. Key reminders specific to Biome development patterns:
VueV*ShorthandDirective types)When working with enum variants (like AnySvelteDirective), check if there are also non-enum types that need handling:
// Check AnySvelteDirective enum (bind:, class:, style:, etc.)
if let Some(directive) = AnySvelteDirective::cast_ref(&element) {
// Handle special Svelte directives
}
// But also check regular HTML attributes with specific prefixes
if let Some(attribute) = HtmlAttribute::cast_ref(&element) {
if let Ok(name) = attribute.name() {
// Some directives might be parsed as regular attributes
}
}For frameworks with multiple directive syntaxes, handle each type:
// Vue has multiple shorthand types
if let Some(directive) = VueVOnShorthandDirective::cast_ref(&element) {
// Handle @click
}
if let Some(directive) = VueVBindShorthandDirective::cast_ref(&element) {
// Handle :prop
}
if let Some(directive) = VueVSlotShorthandDirective::cast_ref(&element) {
// Handle #slot
}
if let Some(directive) = VueDirective::cast_ref(&element) {
// Handle v-if, v-show, etc.
}| Method | Use When | Returns |
|---|---|---|
inner_string_text() | Extracting content from quoted strings | Content without quotes, as TokenText (no alloc) |
text_trimmed() | Getting token text without whitespace | &str — full token text |
token_text_trimmed() | Getting an owned, cloneable token text | TokenText — backed by green token |
text() | Getting raw text including trivia | &str — exact text as written |
Text vs TokenText vs String| Type | Size | Clone cost | Use when |
|---|---|---|---|
TokenText | 16 bytes | Refcount bump | You have a SyntaxToken and want allocation-free ownership |
Text | 16 bytes | Refcount bump (token) or heap copy (owned) | Union of TokenText and an owned string — use when the source may not be a token |
String | 24 bytes | Heap copy | Only when you actually need an owned, mutable string (e.g. for a diagnostic message) |
Text is the richer type: From<TokenText> is implemented, so a TokenText can always be cheaply wrapped in Text. When storing data extracted directly from a syntax token, prefer TokenText or the token+range pattern.
| Type | Method | Framework |
|---|---|---|
HtmlString | inner_string_text() | Vue (quotes) |
HtmlAttributeSingleTextExpression | expression() | Svelte (curly braces) |
HtmlTextExpression | html_literal_token() | Template expressions |
../../CONTRIBUTING.md../testing-codegen/SKILL.md../parser-development/SKILL.mdDO:
| --- | --- | --- | (not |---|---|---|)DON'T:
Example - Table Formatting:
<!-- WRONG: No spaces around separators -->
| Method | Use When | Returns |
|--------|----------|---------|
<!-- CORRECT: Spaces around separators -->
| Method | Use When | Returns |
| --- | --- | --- |The CI uses markdownlint-cli2 which enforces the "compact" style requiring spaces.
format!() (allocates a string) when formatting strings in a markup! block. markup! supports interpolation, E.g. markup! { "Hello, "{name}"!" }..to_string() or .to_string_trimmed() (allocates a string) on a SyntaxToken or SyntaxNode. It's highly unlikely that you actually need to call these methods on a syntax node. As for syntax tokens, you can easily borrow a &str from the token's text without allocating a new string, using token.text().Load this skill when:
c50a853
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.