Skip to content

feat(biome_service): support applying GritQL plugin rewrites via --write#9073

Open
chocky335 wants to merge 10 commits intobiomejs:mainfrom
chocky335:feat/fixable-gritql-plugins
Open

feat(biome_service): support applying GritQL plugin rewrites via --write#9073
chocky335 wants to merge 10 commits intobiomejs:mainfrom
chocky335:feat/fixable-gritql-plugins

Conversation

@chocky335
Copy link

Summary

Closes #5687.

This PR completes the fixable GritQL plugins feature by wiring the rewrite text edits through the CLI --write path.

GritQL plugins can now use the rewrite operator (=>) to suggest code transformations that biome check --write --unsafe applies automatically:

language js

`console.log($msg)` as $call where {
    register_diagnostic(
        span = $call,
        message = "Use console.info instead of console.log.",
        severity = "warning"
    ),
    $call => `console.info($msg)`
}
$ biome check --write --unsafe input.js
Checked 1 file in 5ms. Fixed 1 file.

Without --write --unsafe, the rewrite is shown as an "Unsafe fix" suggestion in the diagnostic output.

Changes

biome_service/src/file_handlers/mod.rs

  • Fix process_action to return Ok(None) when the mutation is empty instead of Ok(Some(())), which caused an infinite loop for plugin rewrite actions (they use text_edit, not tree mutations).
  • Add record_text_edit_fix() to track text-edit-based fixes.

biome_service/src/file_handlers/javascript.rs

  • In the fix_all loop, extract the text_edit from plugin actions before they are consumed. When process_action reports no mutation, apply the text edit by re-parsing the modified source.
  • Guard against no-op rewrites (new_text == old_text) to prevent infinite loops from identity patterns.

Test Plan

Three new CLI integration tests in biome_cli/tests/commands/check.rs:

Test What it verifies
check_plugin_apply_rewrite --write --unsafe applies a single plugin rewrite (console.logconsole.info)
check_plugin_rewrite_no_write Without --write, the diagnostic is shown with a "FIXABLE" tag and "Unsafe fix" suggestion, but the file is not modified
check_plugin_multiple_rewrites Multiple matches in one file are all rewritten (console.loglogger.info, 3 occurrences)

All existing plugin tests (check_plugin_suppressions, check_plugin_diagnostic_offset_in_vue_file, check_json_plugin) continue to pass.

@changeset-bot
Copy link

changeset-bot bot commented Feb 14, 2026

🦋 Changeset detected

Latest commit: 0ada54b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@biomejs/biome Minor
@biomejs/cli-win32-x64 Minor
@biomejs/cli-win32-arm64 Minor
@biomejs/cli-darwin-x64 Minor
@biomejs/cli-darwin-arm64 Minor
@biomejs/cli-linux-x64 Minor
@biomejs/cli-linux-arm64 Minor
@biomejs/cli-linux-x64-musl Minor
@biomejs/cli-linux-arm64-musl Minor
@biomejs/wasm-web Minor
@biomejs/wasm-bundler Minor
@biomejs/wasm-nodejs Minor
@biomejs/backend-jsonrpc Patch
@biomejs/js-api Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added A-CLI Area: CLI A-Project Area: project A-Linter Area: linter L-Grit Language: GritQL labels Feb 14, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 14, 2026

Walkthrough

Plugins can now emit fixable rewrite actions: added PluginActionData and PluginEvalResult, changed AnalyzerPlugin::evaluate to return PluginEvalResult, and propagate actions through PluginVisitor and PluginSignal. Grit pattern linearization and effect application were implemented to produce rewritten text, and plugin loaders convert rewrite effects into PluginActionData. The CLI/service fix-all path records and applies plugin-produced text edits; new tests exercise applying rewrites via biome check --write --unsafe.

Possibly related PRs

Suggested labels

A-Core, L-JavaScript

Suggested reviewers

  • dyc3
  • arendjr
  • ematipico
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(biome_service): support applying GritQL plugin rewrites via --write' clearly summarises the main change—enabling GritQL plugin rewrites to be applied through the CLI --write flag.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, outlining the feature, changes to multiple files, and test coverage for plugin rewrite functionality.
Linked Issues check ✅ Passed The PR successfully addresses #5687 by implementing fixable GritQL plugins with rewrite support, metadata handling, and CLI integration—all key objectives from the linked issue.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing GritQL plugin rewrite support: plugin action data structures, linearization logic, text edit handling, and integration tests for the feature.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
crates/biome_grit_patterns/src/linearization.rs (1)

84-94: debug_assert! won't protect against corrupted output in release builds.

If overlapping replacements somehow slip through in a release build, the assert is gone and you'll silently produce garbled text (replacement content spliced on top of each other). A proper if *start < cursor { return Err(...) } — or at least logging a warning — would be safer for production.

That said, I see this matches the commit message intent ("add debug_assert for overlapping replacements"), so if you're confident the upstream get_top_level_effects guarantees non-overlapping results, this is acceptable as a defensive sanity check during development.

Optional: promote to a hard error
-        debug_assert!(
-            *start >= cursor,
-            "overlapping replacements detected: start={start}, cursor={cursor}"
-        );
+        if *start < cursor {
+            return Err(GritPatternError::new(format!(
+                "overlapping replacements detected: start={start}, cursor={cursor}"
+            )));
+        }
crates/biome_service/src/file_handlers/javascript.rs (2)

1120-1121: Re-parse skips the node cache.

The initial parse path (line 568) uses parse_js_with_cache for performance, but this re-parse uses the cache-less biome_js_parser::parse. In a file with many plugin matches, each iteration re-parses from scratch. Not a blocker, but worth a follow-up if plugin-heavy workloads become common.


1122-1126: Use the action's rule_name instead of hardcoding "gritql" for plugin fixes.

When extracting plugin_text_edit at line 1110, the action object is available and contains rule_name. Hardcoding ("plugin", "gritql") loses the specific pattern identity, which breaks rule identification for conflicting-fix detection when multiple GritQL plugins are configured. Extract both text_edit and rule_name from the action, then pass the actual rule name to record_text_edit_fix.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@crates/biome_grit_patterns/src/linearization.rs`:
- Around line 47-50: The code in linearize_binding uses expect("binding should
have a range") on binding.range(language), which can legitimately be None for
some Binding variants (e.g. File or Constant); replace the panicking expect with
proper error propagation by returning a GritResult::Err when range is None.
Specifically, in function linearize_binding locate the two places that call
binding.range(language) and instead handle the Option by using ok_or_else(...)
or match to produce a descriptive GritError (or use ? after converting with
.ok_or(...)), then use the obtained byte_range for
replacements.push((byte_range.start, byte_range.end, cached_text.clone())).
Ensure both occurrences (the one around replacements.push((byte_range.start,
byte_range.end, cached_text.clone())) and the second occurrence later in the
function) are changed to propagate a GritResult error instead of panicking.

In `@crates/biome_service/src/file_handlers/mod.rs`:
- Around line 845-853: record_text_edit_fix currently ignores the boolean
returned by growth_guard.check(), so runaway plugin rewrites can bypass the same
conflict detection used in process_action; update record_text_edit_fix to call
growth_guard.check(new_text_len) and handle a false result the same way as
process_action (e.g., return a Result and propagate/return a
ConflictingRuleFixesError or run the same error path), or perform the check
before pushing the FixAction and abort (return Err) when check returns false;
reference the function names record_text_edit_fix and growth_guard.check (and
mirror process_action's error handling) so the file growth guard behavior is
consistent.
🧹 Nitpick comments (7)
crates/biome_cli/tests/commands/check.rs (1)

3489-3531: Missing result assertion.

The other two new tests assert result.is_ok(), but this one silently drops the result status. Since the plugin emits a warning-level diagnostic and no --write is passed, it would be good to assert the expected outcome (likely is_err) for clarity and to catch regressions.

Proposed fix
     let (fs, result) = run_cli_with_server_workspace(
         fs,
         &mut console,
         Args::from(["check", file_path.as_str()].as_slice()),
     );
 
+    assert!(result.is_err(), "run_cli returned {result:?}");
     assert_file_contents(&fs, file_path, "console.log(\"hello\");\n");
crates/biome_plugin_loader/src/analyzer_grit_plugin.rs (2)

74-99: Consider extracting the duplicated closure into a helper.

The three match arms perform identical work (grab range + text + send-node), differing only in the downcast type. This is a pre-existing pattern, but the new fields make the duplication more visible.

Not blocking — just a future readability win.


140-146: All rewrite effects are converted to actions — even if the diagnostic has no span.

If a Grit pattern produces a rewrite effect but register_diagnostic was never called (or was called without a span), the action still gets created with the root-level source_range. This seems intentional for the current design, but worth noting that the action's source_range always covers the entire root node, not the matched sub-expression.

crates/biome_analyze/src/analyzer_plugin.rs (1)

127-153: Each diagnostic signal receives a clone of all actions — O(N×M) duplication.

When a plugin produces N diagnostics and M rewrite effects, every signal gets all M actions cloned onto it. For 3 console.log matches this means 3 signals × 3 actions = 9 AnalyzerActions emitted, even though only 3 are meaningful.

The fix-all loop and no-op guard likely handle this correctly, but:

  1. It's wasteful (N full Vec clones).
  2. It can confuse action counts in diagnostics/UX.

A future improvement could pair each diagnostic with its corresponding action(s) — e.g. by correlating the diagnostic span with the rewrite's source range.

Not blocking this PR, but worth tracking.

crates/biome_analyze/src/signals.rs (1)

188-196: Good doc comment on text_edit.

One small note: the unwrap_or_default() at the end of the or_else chain (lines 229 and 243) means a completely empty (TextRange::default(), TextEdit::default()) is produced if neither path yields a result. This is a safe fallback but could mask bugs where an action was expected to carry edits. Probably fine for now — just flagging.

crates/biome_grit_patterns/src/linearization.rs (1)

84-91: Overlapping or out-of-order replacements silently produce garbled output.

If two replacements overlap (i.e. *start < cursor after processing the previous replacement), the gap copy is silently skipped but the replacement text is still appended, leading to corrupted output. Consider adding a diagnostic or assertion when *start < cursor.

Defensive guard
     for (start, end, replacement) in &replacements {
+        if *start < cursor {
+            // Overlapping replacements — skip or error
+            continue;
+        }
         // Copy the gap from cursor to this replacement.
         if *start > cursor {
             result.push_str(&source[cursor..*start]);
         }
crates/biome_grit_patterns/src/grit_resolved_pattern.rs (1)

593-671: linearized_text() — clean delegation across all variants.

One small observation: the List variant joins with "," (no space) on line 636, while the Map variant joins with ", " (with space) on line 653. The text() method (line 772) also uses "," for lists and ", " for maps, so this is at least internally consistent. Just flagging in case the comma-only separator for lists was unintentional.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@crates/biome_service/src/file_handlers/mod.rs`:
- Around line 845-872: record_text_edit_fix currently pushes FixAction with
rule_name: None which makes the ConflictingRuleFixesError list empty when recent
actions are all plugin edits; update record_text_edit_fix (and the FixAction
creation) or the error-collection logic so the error preserves context: either
set a synthetic rule_name when creating the FixAction (e.g.,
Some("plugin:<plugin-id>" or Some("plugin:anonymous")) so these edits appear in
the reversed .iter().rev().filter_map(...) collection, or keep rule_name as None
but change the error construction after growth_guard.check to also count
anonymous actions and include a summary string like "plugin_edits:N" in the
rules vector passed to ConflictingRuleFixesError (modify the block that builds
rules from self.actions.iter().rev().take(10) to collect both Some(rule_name)
values and a count of None entries and include that synthetic entry in the
resulting Vec).
🧹 Nitpick comments (2)
crates/biome_grit_patterns/src/linearization.rs (2)

84-91: Defensive guard for overlapping replacements.

If two replacements ever overlap (i.e. *start < cursor), the current loop silently stitches garbled text. get_top_level_effects should prevent this by contract, but a debug_assert! or skip would be cheap insurance against subtle bugs during future refactors.

Suggested defensive check
     for (start, end, replacement) in &replacements {
-        // Copy the gap from cursor to this replacement.
-        if *start > cursor {
+        // Skip overlapping replacements (should not happen with top-level effects).
+        if *start < cursor {
+            debug_assert!(false, "overlapping replacements detected: start={start}, cursor={cursor}");
+            continue;
+        }
+        if *start > cursor {
             result.push_str(&source[cursor..*start]);
         }

11-16: Consider extracting args into a struct to avoid the too_many_arguments suppression.

Seven parameters is a lot to thread around. A small context struct (e.g. LinearizationContext { language, effects, files, memo, logs }) would tidy call sites and remove the need for #[expect(clippy::too_many_arguments)]. No rush — just something to keep in mind if the signature grows further.

@chocky335
Copy link
Author

@siketyan @dyc3 @arendjr Please review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-CLI Area: CLI A-Linter Area: linter A-Project Area: project L-Grit Language: GritQL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

📎 Implement fixable GritQL plugins

1 participant