Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cc75070
feat(biome_grit_patterns): implement rewrite linearization
chocky335 Feb 14, 2026
d77242a
feat(biome_analyze): support code actions from analyzer plugins
chocky335 Feb 14, 2026
e8f051d
fix(biome_service): prevent infinite loop when applying empty mutations
chocky335 Feb 14, 2026
5319ad2
feat(biome_service): apply plugin rewrite text edits via --write
chocky335 Feb 14, 2026
27c1043
chore: add changeset for fixable GritQL plugins
chocky335 Feb 14, 2026
6d0de5e
fix(biome_grit_patterns): replace expect() with error propagation in …
chocky335 Feb 14, 2026
d2f7fb2
fix(biome_service): handle growth guard check in record_text_edit_fix
chocky335 Feb 14, 2026
925ddeb
test(biome_cli): assert error result in check_plugin_rewrite_no_write
chocky335 Feb 14, 2026
2eabba2
fix(biome_service): provide rule name in ConflictingRuleFixesError fo…
chocky335 Feb 14, 2026
0ada54b
fix(biome_grit_patterns): add debug_assert for overlapping replacemen…
chocky335 Feb 14, 2026
66d0723
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 20, 2026
6d59b1a
refactor(biome_analyze): rename with_actions to with_plugin_actions
chocky335 Feb 20, 2026
fd7a55c
refactor(biome_service): move plugin text edit logic to ProcessFixAll
chocky335 Feb 20, 2026
7deb504
feat(biome_service): support plugin rewrites for CSS and JSON
chocky335 Feb 20, 2026
12549f9
fix(biome_grit_patterns): return error for overlapping replacements
chocky335 Feb 20, 2026
e4ca833
test(biome_cli): add --write only test for plugin rewrites
chocky335 Feb 20, 2026
433b19f
test(biome_cli): add CSS and JSON plugin rewrite tests
chocky335 Feb 20, 2026
f11eb65
test(biome_lsp): add test for plugin rewrite diagnostics
chocky335 Feb 20, 2026
9dc4fd8
chore: update changeset to reflect CSS/JSON support and unsafe-only f…
chocky335 Feb 20, 2026
9937031
Merge remote-tracking branch 'upstream/next' into feat/fixable-gritql…
chocky335 Feb 20, 2026
e545ebb
fix(biome_service): use settings-derived parser options for plugin re…
chocky335 Feb 20, 2026
faa7979
fix: use correct severity string in plugin tests
chocky335 Feb 20, 2026
c7858f8
refactor(biome_analyze): pair plugin diagnostics with actions in Plug…
chocky335 Feb 20, 2026
c44a237
feat(biome_plugin_loader): add fix_kind parameter to register_diagnostic
chocky335 Feb 20, 2026
16ec315
test(biome_cli): add tests for safe plugin fix_kind
chocky335 Feb 20, 2026
e8826fa
fix(biome_grit_patterns): harden rewrite linearization edge cases
chocky335 Feb 20, 2026
b7dc304
fix(biome_grit_patterns): improve rewrite linearization error messages
chocky335 Feb 23, 2026
04582ab
fix(biome_plugin_loader): error on invalid severity and fix_kind values
chocky335 Feb 23, 2026
bad2d3d
test(biome_cli): add tests for invalid fix_kind and severity
chocky335 Feb 23, 2026
4cffff9
fix(biome_plugin_loader): improve severity error message consistency
chocky335 Feb 23, 2026
72df848
fix(biome_plugin_loader): separate log entries from action pairing
chocky335 Feb 23, 2026
11f2bc5
fix(biome_plugin_loader): check missing span only on diagnostic entries
chocky335 Feb 23, 2026
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
17 changes: 17 additions & 0 deletions .changeset/fixable-gritql-plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@biomejs/biome": minor
---

Added support for applying GritQL plugin rewrites as code actions. GritQL plugins that use the rewrite operator (`=>`) now produce fixable diagnostics for JavaScript, CSS, and JSON files. By default, plugin rewrites are treated as unsafe fixes and require `--write --unsafe` to apply. Plugin authors can pass `fix_kind = "safe"` to `register_diagnostic()` to mark a fix as safe, allowing it to be applied with just `--write`.

**Example plugin** (`useConsoleInfo.grit`):
```grit
language js

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

Running `biome check --write` applies safe rewrites. Unsafe rewrites (the default, or `fix_kind = "unsafe"`) still require `--write --unsafe`.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_analyze/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ biome_diagnostics = { workspace = true }
biome_parser = { workspace = true }
biome_rowan = { workspace = true }
biome_suppression = { workspace = true }
biome_text_edit = { workspace = true }
camino = { workspace = true }
enumflags2 = { workspace = true }
indexmap = { workspace = true }
Expand Down
52 changes: 44 additions & 8 deletions crates/biome_analyze/src/analyzer_plugin.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use biome_diagnostics::Applicability;
use biome_rowan::{
AnySyntaxNode, Language, RawSyntaxKind, SyntaxKind, SyntaxNode, TextRange, WalkEvent,
};
use camino::Utf8PathBuf;
use rustc_hash::FxHashSet;
use std::hash::Hash;
use std::{fmt::Debug, sync::Arc};

use biome_rowan::{AnySyntaxNode, Language, RawSyntaxKind, SyntaxKind, SyntaxNode, WalkEvent};

use crate::matcher::SignalRuleKey;
use crate::{
PluginSignal, RuleCategory, RuleDiagnostic, SignalEntry, Visitor, VisitorContext, profiling,
Expand All @@ -16,13 +18,41 @@ pub type AnalyzerPluginSlice<'a> = &'a [Arc<Box<dyn AnalyzerPlugin>>];
/// Vector of analyzer plugins that can be cheaply cloned.
pub type AnalyzerPluginVec = Vec<Arc<Box<dyn AnalyzerPlugin>>>;

/// Data for a code action produced by a plugin.
#[derive(Debug, Clone)]
pub struct PluginActionData {
/// The source range this action applies to.
pub source_range: TextRange,
/// The original source text that was matched.
pub original_text: String,
/// The rewritten text to replace the original.
pub rewritten_text: String,
/// A message describing the action.
pub message: String,
/// Whether this fix is safe or unsafe.
pub applicability: Applicability,
}

/// A diagnostic paired with its optional code action.
#[derive(Debug)]
pub struct PluginDiagnosticEntry {
pub diagnostic: RuleDiagnostic,
pub action: Option<PluginActionData>,
}

/// Result of evaluating a plugin, containing diagnostics paired with their actions.
#[derive(Debug, Default)]
pub struct PluginEvalResult {
pub entries: Vec<PluginDiagnosticEntry>,
}

/// Definition of an analyzer plugin.
pub trait AnalyzerPlugin: Debug + Send + Sync {
fn language(&self) -> PluginTargetLanguage;

fn query(&self) -> Vec<RawSyntaxKind>;

fn evaluate(&self, node: AnySyntaxNode, path: Arc<Utf8PathBuf>) -> Vec<RuleDiagnostic>;
fn evaluate(&self, node: AnySyntaxNode, path: Arc<Utf8PathBuf>) -> PluginEvalResult;
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
Expand Down Expand Up @@ -103,20 +133,26 @@ where
}

let rule_timer = profiling::start_plugin_rule("plugin");
let diagnostics = self
let eval_result = self
.plugin
.evaluate(node.clone().into(), ctx.options.file_path.clone());
rule_timer.stop();

let signals = diagnostics.into_iter().map(|diagnostic| {
let name = diagnostic
let signals = eval_result.entries.into_iter().map(|entry| {
let name = entry
.diagnostic
.subcategory
.clone()
.unwrap_or_else(|| "anonymous".into());
let text_range = entry.diagnostic.span().unwrap_or_default();

let signal = PluginSignal::<L>::new(entry.diagnostic)
.with_plugin_action(entry.action)
.with_root(node.clone());

SignalEntry {
text_range: diagnostic.span().unwrap_or_default(),
signal: Box::new(PluginSignal::<L>::new(diagnostic)),
text_range,
signal: Box::new(signal),
rule: SignalRuleKey::Plugin(name.into()),
category: RuleCategory::Lint,
instances: Default::default(),
Expand Down
3 changes: 2 additions & 1 deletion crates/biome_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ mod visitor;
pub use biome_diagnostics::category_concat;

pub use crate::analyzer_plugin::{
AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, PluginTargetLanguage, PluginVisitor,
AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, PluginActionData,
PluginDiagnosticEntry, PluginEvalResult, PluginTargetLanguage, PluginVisitor,
};
pub use crate::categories::{
ActionCategory, OtherActionCategory, RefactorKind, RuleCategories, RuleCategoriesBuilder,
Expand Down
61 changes: 52 additions & 9 deletions crates/biome_analyze/src/signals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ use crate::categories::{
SUPPRESSION_INLINE_ACTION_CATEGORY, SUPPRESSION_TOP_LEVEL_ACTION_CATEGORY,
};
use crate::{
AnalyzerDiagnostic, AnalyzerOptions, OtherActionCategory, Queryable, RuleDiagnostic, RuleGroup,
ServiceBag, SuppressionAction,
AnalyzerDiagnostic, AnalyzerOptions, OtherActionCategory, PluginActionData, Queryable,
RuleDiagnostic, RuleGroup, ServiceBag, SuppressionAction,
categories::ActionCategory,
context::RuleContext,
registry::{RuleLanguage, RuleRoot},
rule::Rule,
};
use biome_console::{MarkupBuf, markup};
use biome_diagnostics::{Applicability, CodeSuggestion, Error, advice::CodeSuggestionAdvice};
use biome_rowan::{BatchMutation, Language};
use biome_rowan::{BatchMutation, Language, SyntaxNode, TextRange};
use biome_text_edit::TextEdit;
use std::borrow::Cow;
use std::iter::FusedIterator;
use std::marker::PhantomData;
use std::vec::IntoIter;
Expand Down Expand Up @@ -109,18 +111,30 @@ where
/// Unlike [DiagnosticSignal] which converts through [Error] into
/// [DiagnosticKind::Raw](crate::diagnostics::DiagnosticKind::Raw), this type
/// directly converts via `AnalyzerDiagnostic::from(RuleDiagnostic)`.
pub struct PluginSignal<L> {
pub struct PluginSignal<L: Language> {
diagnostic: RuleDiagnostic,
_phantom: PhantomData<L>,
plugin_action: Option<PluginActionData>,
root: Option<SyntaxNode<L>>,
}

impl<L: Language> PluginSignal<L> {
pub fn new(diagnostic: RuleDiagnostic) -> Self {
Self {
diagnostic,
_phantom: PhantomData,
plugin_action: None,
root: None,
}
}

pub fn with_plugin_action(mut self, action: Option<PluginActionData>) -> Self {
self.plugin_action = action;
self
}

pub fn with_root(mut self, root: SyntaxNode<L>) -> Self {
self.root = Some(root);
self
}
}

impl<L: Language> AnalyzerSignal<L> for PluginSignal<L> {
Expand All @@ -129,7 +143,25 @@ impl<L: Language> AnalyzerSignal<L> for PluginSignal<L> {
}

fn actions(&self) -> AnalyzerActionIter<L> {
AnalyzerActionIter::new(vec![])
let Some(action_data) = &self.plugin_action else {
return AnalyzerActionIter::new(vec![]);
};

let Some(root) = &self.root else {
return AnalyzerActionIter::new(vec![]);
};

let text_edit =
TextEdit::from_unicode_words(&action_data.original_text, &action_data.rewritten_text);

AnalyzerActionIter::new(vec![AnalyzerAction {
rule_name: None,
category: ActionCategory::QuickFix(Cow::Borrowed("plugin")),
applicability: action_data.applicability,
message: markup!({ action_data.message }).to_owned(),
mutation: BatchMutation::new(root.clone()),
text_edit: Some((action_data.source_range, text_edit)),
}])
}

fn transformations(&self) -> AnalyzerTransformationIter<L> {
Expand All @@ -149,6 +181,8 @@ pub struct AnalyzerAction<L: Language> {
pub applicability: Applicability,
pub message: MarkupBuf,
pub mutation: BatchMutation<L>,
/// Pre-computed text edit for plugin rewrites. Takes precedence over mutation.
pub text_edit: Option<(TextRange, TextEdit)>,
}

impl<L: Language> AnalyzerAction<L> {
Expand Down Expand Up @@ -179,7 +213,10 @@ impl<L: Language> Default for AnalyzerActionIter<L> {

impl<L: Language> From<AnalyzerAction<L>> for CodeSuggestionAdvice<MarkupBuf> {
fn from(action: AnalyzerAction<L>) -> Self {
let (_, suggestion) = action.mutation.to_text_range_and_edit().unwrap_or_default();
let (_, suggestion) = action
.text_edit
.or_else(|| action.mutation.to_text_range_and_edit())
.unwrap_or_default();
Self {
applicability: action.applicability,
msg: action.message,
Expand All @@ -190,7 +227,10 @@ impl<L: Language> From<AnalyzerAction<L>> for CodeSuggestionAdvice<MarkupBuf> {

impl<L: Language> From<AnalyzerAction<L>> for CodeSuggestionItem {
fn from(action: AnalyzerAction<L>) -> Self {
let (range, suggestion) = action.mutation.to_text_range_and_edit().unwrap_or_default();
let (range, suggestion) = action
.text_edit
.or_else(|| action.mutation.to_text_range_and_edit())
.unwrap_or_default();

Self {
rule_name: action.rule_name,
Expand Down Expand Up @@ -469,6 +509,7 @@ where
category: action.category,
mutation: action.mutation,
message: action.message,
text_edit: None,
});
};
if let Some(text_range) = R::text_range(&ctx, &self.state)
Expand All @@ -485,6 +526,7 @@ where
applicability: Applicability::Always,
mutation: suppression_action.mutation,
message: suppression_action.message,
text_edit: None,
};
actions.push(action);
}
Expand All @@ -498,6 +540,7 @@ where
applicability: Applicability::Always,
mutation: suppression_action.mutation,
message: suppression_action.message,
text_edit: None,
};
actions.push(action);
}
Expand Down
Loading
Loading