Aller au contenu

Linter Plugins

Ce contenu n’est pas encore disponible dans votre langue.

Biome supports two forms of plugins:

  • GritQL, a powerful DSL to build small and expressive rules.
  • JavaScript, which allows you to tap into Biome’s infrastructure, such as CST, semantic model, and more.

It’s important you understand how the two plugin formats work so that you can make an informed decision on how they will impact your work, your users, and Biome itself.

GritQL:

  • For Biome to enable plugins for a language, both GritQL and Biome must support it.
  • It’s a DSL; you have to learn how it works. A DSL plugin can be more expressive and require way less code to write, depending on the use case.
  • Plugins run on every file, regardless of whether the content of the file matches the query.
  • Plugins use a different pipeline from the one used by Biome’s Rust lint rules, and they don’t have access to information such as the semantic model, file kind, etc.
  • The memory usage is minimal and can be controlled by Biome.

JavaScript:

  • They can support the same languages that Biome’s Rust lint rules support.
  • They are very verbose, so writing a rule for a small thing could take 10x more lines of code compared to GritQL.
  • They have access to more information, such as the file being analyzed, the semantic model, CST navigation, and more.
  • It’s way easier to write a plugin that can negatively impact the performance of downstream users.
  • The memory used by the plugins can’t be controlled by Biome.

Biome Linter supports GritQL plugins. Plugins can match specific code patterns, report customized diagnostics, and suggest fixable rewrites.

Here is an example of a plugin that reports on all usages of Object.assign():

plugin.grit
`$fn($args)` where {
$fn <: `Object.assign`,
register_diagnostic(
span = $fn,
message = "Prefer object spread instead of `Object.assign()`"
)
}

You can put a GritQL snippet in a file anywhere in your project, but make sure you use the .grit extension. Then, you can simply enable it as a plugin with the following configuration:

biome.json
{
"plugins": ["./path-to-plugin.grit"]
}

The plugin will now be enabled on all supported files the linter runs on. You can see its results when running biome lint or biome check. For example:

$ biome lint
/packages/tailwindcss-config-analyzer/src/introspect.ts:12:17 plugin ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✖ Prefer object spread instead of `Object.assign()`
10 │ function createContextFromConfig(config: Partial<Config>) {
11 │ return createContext(
> 12 │ resolveConfig(Object.assign({}, DEFAULT_CONFIG, config)),
│ ^^^^^^^^^^^^^
13 │ );
14 │ }

You can suppress plugin diagnostics with lint/plugin suppression comments. See plugin suppressions for details.

Plugins can also suggest code rewrites using the => operator:

no-console.grit
`console.log($msg)` as $call where {
register_diagnostic(
span = $call,
message = "Use console.info instead of console.log.",
severity = "warn",
fix_kind = "safe"
),
$call => `console.info($msg)`
}

Rewrite behavior:

  • Without --write, rewrites are shown as suggestions but not applied.
  • With --write, Biome applies plugin rewrites marked with fix_kind = "safe".
  • With --write --unsafe, Biome also applies unsafe plugin rewrites.
  • If fix_kind is omitted, the rewrite is treated as unsafe by default.

By default, a plugin runs on every file Biome processes. However, you can use the includes option to restrict files and folders where the plugin can or can’t run.

In the following example, the first plugin only runs on files inside src/components/. The second plugin runs on all files that have the extension .ts under the src/ folder, except for files that end with .test.ts in the src/ folder:

biome.json
{
"plugins": [
{
"path": "./react-plugin.grit",
"includes": ["src/components/**"]
},
{
"path": "./ts-only-plugin.grit",
"includes": ["src/**/*.ts", "!src/**/*.test.ts"]
}
]
}

When includes is set, the plugin only runs on files that match at least one positive pattern and are not excluded by a negated pattern. Patterns follow the glob syntax reference.

Resolve plugins from shared configurations

Section titled “Resolve plugins from shared configurations”

By default, Biome resolves plugin paths in a shared configuration from the directory containing the configuration that extends it. To load a plugin that ships with the shared configuration, set resolutionKind to "config":

biome.shared.jsonc
{
"plugins": [
{
"path": "./plugins/no-object-assign.grit",
"resolutionKind": "config"
}
]
}

Then extend the shared configuration:

biome.json
{
"extends": ["./biome.shared.jsonc"]
}

Biome resolves the plugin path from the directory containing biome.shared.jsonc, while includes globs are still resolved from the directory containing the extending configuration.

See plugins.<ITEM>.resolutionKind for the full option reference.

A GritQL snippet always attempts to match against a given target language. If no target language is specified, JavaScript or one of its super languages is assumed.

If you want to use a different target language, you must specify it explicitly. For example, here is a CSS plugin to report any selector that sets a color outside the allowed .color-* classes:

plugin.grit
language css;
`$selector { $props }` where {
$props <: contains `color: $color` as $rule,
not $selector <: r"\.color-.*",
register_diagnostic(
span = $rule,
message = "Don't set explicit colors. Use `.color-*` classes instead."
)
}

Biome currently supports JavaScript, CSS, and JSON target languages.

In addition to Grit’s built-in functions, Biome currently supports one extra function:

Registers a diagnostic to be reported whenever the pattern matches.

Supports four arguments:

  • span (required): The syntax node to attach the diagnostic to. This is typically a variable that you matched within a code snippet.
  • message (required): The message to show with the diagnostic.
  • severity: The severity of the diagnostic. Allowed values are: hint, info, warn, and error. By default, error is used.
  • fix_kind: Safety of an associated rewrite fix (safe or unsafe). This is only relevant when the pattern also uses the rewrite operator (=>). By default, fixes are treated as unsafe.

Biome accepts .js and .ts as file extensions. If you decide to use TypeScript to write a plugin, we strongly recommend installing the @biomejs/runtime package, which provides the relevant types.

The plugin must export an object composed by the defineRule function. The object must have two fields:

  • query: which CST nodes to query.
  • run: the core logic of the lint rule. If the rule detects a violation, use the function registerDiagnostic to return the node to highlight, the severity of the diagnostic, and a message.
plugin.ts
import { defineRule, ast, registerDiagnostic } from "@biomejs/runtime/plugin"
export const noAssign = defineRule({
query: ast("JS_CALL_EXPRESSION"),
run(node, ctx) {
registerDiagnostic(node, "error", "Can't assign anything.")
}
})

You use query to tell the plugin which nodes to inspect. In order to know which nodes to inspect, you have two options: use the command biome inspect file or the playground.

Each node has a name assigned by the Biome grammar, usually in all caps. For example, the name associated with the debugger statement is JS_DEBUGGER_STATEMENT, so you can import its interface called JsDebuggerStatement and use the kind field:

plugin.ts
import { defineRule, ast, JsDebuggerStatement } from "@biomejs/runtime/plugin"
export const noAssign = defineRule({
query: ast(JsDebuggerStatement.kind),
})

Type: string | string[]

It accepts one name or a list of nodes to query.

plugin.ts
import { defineRule, ast, registerDiagnostic } from "@biomejs/runtime/plugin"
export const noAssign = defineRule({
query: ast("JS_CALL_EXPRESSION", "JS_DEBUGGER"),
run(node, ctx) {
registerDiagnostic(node, "error", "Can't assign anything.")
}
})

Type: string | ...string

It accepts one name or a list of nodes to query. Via semantic, it’s possible to inspect the semantic model of the current file, accessible via ctx.model.

plugin.ts
import { defineRule, semantic, registerDiagnostic } from "@biomejs/runtime/plugin"
export const noAssign = defineRule({
query: semantic("JS_CALL_EXPRESSION", "JS_DEBUGGER"),
run(node, ctx) {
const model = ctx.model;
registerDiagnostic(node, "error", "Can't assign anything.")
}
})

The run function accepts two arguments:

  • The first argument is the node that matched the query. We call it node.
  • The second argument is of type RuleContext, which contains useful information. We call it ctx.

Type: string

The absolute path of the file being linted.

Type: JsFileSource

Information about how the file was parsed, such as whether it’s JavaScript or TypeScript, whether it’s a module or a script, and whether the code is embedded in another file, such as an Astro, Vue, or Svelte component.

Type: SemanticModel

Bindings and references in the original analyzed source.

Type: (reference: AnyJsIdentifierReference) => Binding | undefined

Resolves an identifier use to the binding it refers to, respecting shadowing. Returns undefined for configured globals and unresolved references.

Type: (node: AnyJsIdentifierBinding) => Binding | undefined

Looks up the binding introduced by a declaration’s identifier node. Returns undefined when the model does not track that declaration.

Type: () => readonly Binding[]

Returns all tracked bindings in the source, including nested scopes. Declarations with the same name can introduce separate bindings.

Type: () => readonly Binding[]

Returns the bindings marked as exported by the model, with each binding appearing once even if it has multiple export sites. Order is unspecified. Re-exports that introduce no local binding are not included.

Type: () => boolean

Returns whether the model tracks at least one exported binding. This is not a check for export syntax: an empty export, a re-export without a local binding, or an exported literal can leave this value false.

Type: () => readonly Scope[]

Returns all scopes in this source, including the global scope and nested scopes.

Type: () => Scope

Returns the outermost scope of this source, including for an empty file. This scope has no parent. It is unrelated to the global references returned by allGlobalReferences().

Type: (node: AnyJsAstNode) => Scope | undefined

Returns the most specific scope containing the node’s trimmed source range. Returns undefined for an empty node. Use globalScope() to obtain a scope for an empty file.

Type: (node: AnyJsAstNode) => Scope | undefined

Returns the scope a declaration’s identifier was hoisted to, if the model records one. This can differ from the scope containing its source text.

Type: () => readonly GlobalReference[]

Returns references resolved to globals configured in the supplied model. Locally shadowed names resolve to local bindings instead.

Type: () => readonly UnresolvedReference[]

Returns references that resolve to neither a local binding nor a global configured in the supplied model.

Type: (reference: AnyJsIdentifierReference) => boolean

Returns whether an identifier use resolves to a configured global. Returns false for local bindings and unresolved references.

Type: (reference: AnyJsIdentifierReference) => boolean

Returns whether an identifier use has no local binding or configured global. A reference to a declaration later in the source can still resolve.

Type: (node: AnyJsIdentifierBinding | AnyJsIdentifierReference) => boolean

Returns whether a declaration is imported, or a reference resolves to an imported declaration. Returns false when a reference has no local binding.

Type: (node: AnyJsIdentifierBinding | AnyJsIdentifierReference) => boolean

Returns whether a declaration is exported, or a reference resolves to an exported declaration. Returns false when a reference has no local binding.

Type: () => AnyJsIdentifierBinding

Returns the identifier node introducing this binding, rather than the enclosing declaration statement. The node can be used in diagnostics and fixes.

Type: () => "class" | "enum" | "function" | "generic" | "hoistedValue" | "import" | "importType" | "interface" | "module" | "namespace" | "type" | "unknown" | "using" | "value"

Returns the model’s declaration classification, such as "function", "importType", or "hoistedValue". This describes how the binding was declared, rather than the syntax kind of its identifier node.

Type: () => readonly (AnyJsIdentifierBinding | AnyJsIdentifierReference)[]

Returns the identifier nodes at this binding’s tracked export sites. A directly exported declaration yields its identifier binding; an export specifier yields the local identifier reference, not its exported alias. Returns an empty array for a binding with no tracked export sites.

Type: () => readonly { readonly start: number; readonly end: number }[]

Returns the model’s source ranges for this binding’s export sites. Offsets count UTF-8 bytes, with an inclusive start and exclusive end; they are not JavaScript string indices. Returns an empty array when no export sites are tracked.

Type: () => Scope

Returns the scope containing the declaration’s identifier in the source. For hoisted declarations, use ctx.model.scopeHoistedTo(binding.syntax()) to inspect the destination scope instead.

Type: () => readonly Reference[]

Returns all references that resolve to this binding, including reads and writes. References to a different binding with the same name are excluded.

Type: () => readonly Reference[]

Returns references classified as reads by the semantic model.

Type: () => readonly Reference[]

Returns references classified as writes by the semantic model.

Type: () => boolean

Returns whether this binding was declared by an import.

Type: () => boolean

Returns whether this binding is exported from the source.

Type: () => AnyJsIdentifierReference

Returns the identifier node at the reference site. The node can be used in diagnostics and fixes.

Type: () => Binding | undefined

Returns the binding this reference resolves to.

Type: () => Scope

Returns the scope containing this identifier use, which can differ from the scope containing its declaration.

Type: () => boolean

Returns whether the model classifies this reference as a read.

Type: () => boolean

Returns whether the model classifies this reference as a write.

Type: () => AnyJsAstNode

Returns the syntax node associated with this scope, such as a module, function, or block. The node can be used in diagnostics and fixes.

Type: () => boolean

Returns whether this is the source’s outermost scope.

Type: () => Scope | undefined

Returns the immediately enclosing scope, or undefined for the global scope.

Type: () => readonly Scope[]

Returns this scope’s immediate child scopes, without their descendants.

Type: () => readonly Scope[]

Returns this scope followed by its enclosing scopes, ending with the global scope.

Type: () => readonly Binding[]

Returns bindings declared in or hoisted to this scope. Bindings belonging to parent or child scopes are excluded.

Type: (name: string) => Binding | undefined

Looks up a binding in this scope only; parent scopes are not searched. Returns undefined when the name is absent. Unicode escapes are decoded. When a TypeScript name has both type and value bindings, the value binding is preferred, falling back to the type binding if no value exists.

Type: () => AnyJsIdentifierReference

Returns the identifier node that uses the configured global.

Type: () => boolean

Returns whether the model classifies this global reference as a read.

Type: () => boolean

Returns whether the model classifies this global reference as a write.

Type: () => AnyJsIdentifierReference

Returns the identifier node whose name could not be resolved.

A common operation in lint rules is checking the kind of a syntax node or token. Sometimes, you want to check for multiple kinds at a time.

if (node.kind === "JS_CALL_EXPRESSION" || node.kind === "JS_NEW_EXPRESSION") { ... }

However, there is a performance cost when you check this way. It results in multiple string comparisons. To avoid most of this cost, we provide these “kind sets” APIs that can check for any number of kinds in constant time.

Build each set once at module scope, then reuse it in every run() call:

plugin.ts
import { ast, defineRule, nodeKinds, registerDiagnostic, tokenKinds } from "@biomejs/runtime/plugin"
const CALLS = nodeKinds("JS_CALL_EXPRESSION", "JS_NEW_EXPRESSION");
const LOOSE_EQUALITY = tokenKinds("EQ2", "NEQ");
export const kindSets = defineRule({
query: ast("JS_EXPRESSION_STATEMENT"),
run(statement, ctx) {
const expression = statement.expression;
if (!expression) return;
if (CALLS.has(expression)) {
registerDiagnostic(expression, "information", `${expression.kind} is in CALLS.`)
}
for (const element of expression.childrenWithTokens()) {
if (LOOSE_EQUALITY.has(element)) {
registerDiagnostic(expression, "warning", `${element.text} is in LOOSE_EQUALITY.`)
}
}
}
})

Type: <K extends keyof JsNodeByKind>(...kinds: K[]) => NodeKindSet<K>

Creates a set of node kinds. It accepts the same names as ast, such as "JS_CALL_EXPRESSION". Pass each kind as a separate argument, not as an array. nodeKinds() with no arguments returns an empty set, which matches nothing.

It throws a TypeError for an unknown name or a token kind. For a token kind, the message names the function to use instead:

nodeKinds() received the token kind "IDENT", but it only accepts node kinds. Create a set of token kinds with tokenKinds("IDENT") instead.

Type: <K extends JsAstToken["kind"]>(...kinds: K[]) => TokenKindSet<K>

Creates a set of token kinds. It accepts any name that token.kind can return, such as "IDENT" or "LET_KW". This includes "EOF", "ERROR_TOKEN", and trivia kinds such as "WHITESPACE" and "COMMENT".

Like nodeKinds, it throws a TypeError if a name is unknown or is a node kind.

Type: (element: JsAstElement | undefined) => boolean

Returns whether element is a node whose kind is in the set. It returns false for tokens and for undefined, so you can pass optional fields and the results of childrenWithTokens() directly.

It throws a TypeError for anything that isn’t a node, a token, or undefined. That includes kind strings and null, so CALLS.has("JS_CALL_EXPRESSION") throws.

In TypeScript, a true result narrows element to the node types in the set. After CALLS.has(element), element is a JsCallExpression | JsNewExpression.

Type: (element: JsAstElement | undefined) => boolean

Returns whether element is a token whose kind is in the set. It returns false for nodes and for undefined, and throws a TypeError for anything else.

In TypeScript, a true result narrows element to a token with one of the set’s kinds. After LOOSE_EQUALITY.has(element), element.kind is "EQ2" | "NEQ".

Type: (other: NodeKindSet) => NodeKindSet

Returns a new set with the kinds of both sets. Neither set changes, so you can share a base set and extend it in several places:

plugin.ts
import { nodeKinds } from "@biomejs/runtime/plugin"
const CALLS = nodeKinds("JS_CALL_EXPRESSION", "JS_NEW_EXPRESSION");
const CALLS_AND_IMPORTS = CALLS.union(nodeKinds("JS_IMPORT_CALL_EXPRESSION"));

other must be a set returned by nodeKinds(). Passing a token kind set throws a TypeError that says so:

set.union() received a token kind set, but this is a node kind set. Pass a set returned by nodeKinds() instead.

It also throws for a kind name, an array, or anything else that isn’t a node kind set, and when you pass zero or more than one argument. To add a single kind, wrap it in a set first, as in CALLS.union(nodeKinds("JS_IMPORT_CALL_EXPRESSION")).

In TypeScript, the result’s type has the kinds of both sets, so has() on it narrows to any of them. After CALLS_AND_IMPORTS.has(element), element is a JsCallExpression | JsNewExpression | JsImportCallExpression.

Type: (other: TokenKindSet) => TokenKindSet

Returns a new set with the kinds of both sets. Neither set changes.

plugin.ts
import { tokenKinds } from "@biomejs/runtime/plugin"
const EQUALITY = tokenKinds("EQ2", "EQ3", "NEQ", "NEQ2");
const COMPARISON = EQUALITY.union(tokenKinds("L_ANGLE", "R_ANGLE", "LTEQ", "GTEQ"));

other must be a set returned by tokenKinds(). Like NodeKindSet.union, it throws a TypeError for a node kind set, a kind name, an array, or the wrong number of arguments.

The two set types are separate. You can’t pass a node kind set where a token kind set is expected, or the other way around. That includes union: TypeScript rejects CALLS.union(LOOSE_EQUALITY), and it throws at runtime.

Each set’s type records exactly the kinds it was built with. nodeKinds("JS_CALL_EXPRESSION", "JS_NEW_EXPRESSION") has the type NodeKindSet<"JS_CALL_EXPRESSION" | "JS_NEW_EXPRESSION">, and you can’t assign it to a NodeKindSet with any other kinds, whether fewer, more, or a plain NodeKindSet. A helper that accepts any set must be generic over its kinds:

plugin.ts
import type { AnyJsAstNode, JsNodeByKind, NodeKindSet } from "@biomejs/runtime/plugin"
function countMembers<K extends keyof JsNodeByKind>(
set: NodeKindSet<K>,
nodes: readonly AnyJsAstNode[],
): number {
return nodes.filter((node) => set.has(node)).length;
}

For token kind sets, use K extends JsAstToken["kind"] and TokenKindSet<K>.

Many rules look for a few node kinds anywhere inside a node, often the whole file. You could walk the tree yourself with children(), but then every node in the file becomes a JavaScript object and goes through your code, even the ones you don’t care about.

node.descendants() and node.walk() take a node kind set and do the search inside Biome. Biome only creates JavaScript objects and calls your code for the nodes whose kind is in the set, which is much faster than a children() walk.

Here is a typical recursive walk that collects every call in a file:

plugin.js
import { ast, defineRule, registerDiagnostic } from "@biomejs/runtime/plugin"
function findCalls(node, calls) {
for (const child of node.children()) {
if (child.kind === "JS_CALL_EXPRESSION") {
calls.push(child);
}
findCalls(child, calls);
}
return calls;
}
export const calls = defineRule({
query: ast("JS_MODULE"),
run(root) {
for (const call of findCalls(root, [])) {
registerDiagnostic(call, "information", "Found a call.")
}
}
})

With descendants(), the helper goes away:

plugin.js
import { ast, defineRule, nodeKinds, registerDiagnostic } from "@biomejs/runtime/plugin"
const CALLS = nodeKinds("JS_CALL_EXPRESSION");
export const calls = defineRule({
query: ast("JS_MODULE"),
run(root) {
for (const call of root.descendants(CALLS)) {
registerDiagnostic(call, "information", "Found a call.")
}
}
})

As with other kind sets, build each set once at module scope and reuse it in every run() call.

Neither method includes or visits the node you call it on, even when its kind is in the set. There is no variant for tokens.

Type: <K extends keyof JsNodeByKind>(kinds: NodeKindSet<K>) => readonly JsNodeByKind[K][]

Returns a new array with the nodes inside node whose kind is in kinds, in source order. Each call creates new node objects, so calling it twice returns different objects for the same nodes.

kinds must be a set returned by nodeKinds(). A kind string, an array, or a set from tokenKinds() throws a TypeError:

The kinds argument to node.descendants() is not a node kind set. Create one with nodeKinds(), such as nodeKinds("JS_CALL_EXPRESSION"), and pass it instead of a kind string or array.

In TypeScript, the result has the node types in the set. root.descendants(nodeKinds("JS_CALL_EXPRESSION", "JS_NEW_EXPRESSION")) returns a readonly (JsCallExpression | JsNewExpression)[].

Type: <K extends keyof JsNodeByKind>(kinds: NodeKindSet<K>, enter: (node: JsNodeByKind[K]) => unknown, exit?: (node: JsNodeByKind[K]) => void) => void

Visits the nodes inside node whose kind is in kinds, in source order. Biome calls enter(match) before it visits the nodes inside a match, and exit(match) after them. exit receives the same object that enter received, and you can omit it.

Use walk() instead of descendants() when you need to know how matches nest, for example to keep a stack of the enclosing functions or JSX elements. This rule reports each call along with the function it’s in:

plugin.js
import { ast, defineRule, nodeKinds, registerDiagnostic } from "@biomejs/runtime/plugin"
const FUNCTIONS_AND_CALLS = nodeKinds("JS_FUNCTION_DECLARATION", "JS_FUNCTION_EXPRESSION", "JS_ARROW_FUNCTION_EXPRESSION", "JS_CALL_EXPRESSION");
export const enclosingFunctions = defineRule({
query: ast("JS_MODULE"),
run(root) {
const functions = [];
root.walk(
FUNCTIONS_AND_CALLS,
(node) => {
if (node.kind === "JS_CALL_EXPRESSION") {
const enclosing = functions.at(-1) ?? "the module";
registerDiagnostic(node, "information", `${node.callee.text}() is called in ${enclosing}.`)
} else if (node.id?.text === "skipped") {
return false;
} else {
functions.push(node.id?.text ?? "an anonymous function");
}
},
(node) => {
if (node.kind !== "JS_CALL_EXPRESSION") {
functions.pop();
}
},
);
}
})

If enter returns exactly false, Biome skips the nodes inside that match and doesn’t call exit for it. In the example above, calls inside a function named skipped aren’t reported. Any other return value, including undefined, null, and 0, continues the walk.

If enter or exit throws, the walk stops and the exception propagates to your code.

kinds follows the same rules as in descendants(). walk() also throws a TypeError if enter isn’t a function, or if exit is given and isn’t a function.

In TypeScript, enter and exit receive the node types in the set.