Skip to content

Linter Plugins

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) {
let 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.