From cc75070cedcb007aa56ddbec35102b8101ff8278 Mon Sep 17 00:00:00 2001 From: Bohdan Pomohaibo Date: Sat, 14 Feb 2026 21:53:00 +0200 Subject: [PATCH 01/35] feat(biome_grit_patterns): implement rewrite linearization --- .../biome_grit_patterns/src/grit_binding.rs | 48 ++++- .../biome_grit_patterns/src/grit_context.rs | 28 ++- .../src/grit_resolved_pattern.rs | 174 ++++++++++++++++-- crates/biome_grit_patterns/src/lib.rs | 1 + .../biome_grit_patterns/src/linearization.rs | 122 ++++++++++++ .../tests/specs/ts/duplicateVariable.snap | 11 +- .../tests/specs/ts/functionToArrow.snap | 11 +- .../tests/specs/ts/log.snap | 11 +- .../tests/specs/ts/patternDefinition.snap | 11 +- .../tests/specs/ts/rawSnippet.snap | 16 +- .../tests/specs/ts/regex.snap | 11 +- .../tests/specs/ts/whereClause.snap | 11 +- 12 files changed, 422 insertions(+), 33 deletions(-) create mode 100644 crates/biome_grit_patterns/src/linearization.rs diff --git a/crates/biome_grit_patterns/src/grit_binding.rs b/crates/biome_grit_patterns/src/grit_binding.rs index f6ff28be6f95..dcc2527523e9 100644 --- a/crates/biome_grit_patterns/src/grit_binding.rs +++ b/crates/biome_grit_patterns/src/grit_binding.rs @@ -1,6 +1,7 @@ use crate::{ grit_context::GritQueryContext, grit_target_language::GritTargetLanguage, - grit_target_node::GritTargetNode, source_location_ext::SourceFileExt, util::TextRangeGritExt, + grit_target_node::GritTargetNode, linearization::linearize_binding, + source_location_ext::SourceFileExt, util::TextRangeGritExt, }; use biome_diagnostics::{SourceCode, display::SourceFile}; use biome_rowan::TextRange; @@ -157,14 +158,45 @@ impl<'a> Binding<'a, GritQueryContext> for GritBinding<'a> { fn linearized_text( &self, - _language: &GritTargetLanguage, - _effects: &[Effect<'a, GritQueryContext>], - _files: &FileRegistry<'a, GritQueryContext>, - _memo: &mut HashMap>, - _distributed_indent: Option, - _logs: &mut AnalysisLogs, + language: &GritTargetLanguage, + effects: &[Effect<'a, GritQueryContext>], + files: &FileRegistry<'a, GritQueryContext>, + memo: &mut HashMap>, + distributed_indent: Option, + logs: &mut AnalysisLogs, ) -> GritResult> { - Err(GritPatternError::new("Not implemented")) // TODO: Implement rewriting + match self { + Self::Node(node) => { + let source = node.source(); + let range = node.code_range(); + linearize_binding( + language, + effects, + files, + memo, + source, + range, + distributed_indent, + logs, + ) + } + Self::Range(text_range, source) => { + let range = text_range.to_code_range(source); + linearize_binding( + language, + effects, + files, + memo, + source, + range, + distributed_indent, + logs, + ) + } + Self::Empty(..) => Ok(Cow::Borrowed("")), + Self::File(path) => Ok(path.to_string_lossy()), + Self::Constant(constant) => Ok(constant.to_string().into()), + } } fn text(&self, _language: &GritTargetLanguage) -> GritResult> { diff --git a/crates/biome_grit_patterns/src/grit_context.rs b/crates/biome_grit_patterns/src/grit_context.rs index a2c04b4ef58a..feb8a073e3a5 100644 --- a/crates/biome_grit_patterns/src/grit_context.rs +++ b/crates/biome_grit_patterns/src/grit_context.rs @@ -7,6 +7,7 @@ use crate::grit_resolved_pattern::GritResolvedPattern; use crate::grit_target_language::GritTargetLanguage; use crate::grit_target_node::GritTargetNode; use crate::grit_tree::GritTargetTree; +use crate::linearization::apply_effects; use biome_analyze::RuleDiagnostic; use biome_parser::AnyParse; use camino::Utf8PathBuf; @@ -176,15 +177,36 @@ impl<'a> ExecContext<'a, GritQueryContext> for GritExecContext<'a> { variables, suppressed, }; - for file_ptr in files { - let file = state.files.get_file_owner(file_ptr); + for file_ptr in &files { + let file = state.files.get_file_owner(*file_ptr); let mut match_log = file.matches.borrow_mut(); if match_log.input_matches.is_none() { match_log.input_matches = Some(input_ranges.clone()); } + } - // TODO: Implement effect application + // Apply effects: if there are accumulated effects, linearize + // them to produce rewritten source and push a new file version. + if !state.effects.is_empty() { + for file_ptr in &files { + let file_owner = state.files.get_file_owner(*file_ptr); + let source = file_owner.tree.text(); + + let new_src = + apply_effects(source, &state.effects, &state.files, &self.lang, logs)?; + + if new_src != source { + let file_name = file_owner.name.clone(); + if let Some(new_owner) = new_file_owner(file_name, &new_src, &self.lang, logs)? + { + self.files().push(new_owner); + state + .files + .push_revision(file_ptr, self.files().last().unwrap()); + } + } + } } let new_files_binding = &mut state.bindings[GLOBAL_VARS_SCOPE_INDEX as usize] diff --git a/crates/biome_grit_patterns/src/grit_resolved_pattern.rs b/crates/biome_grit_patterns/src/grit_resolved_pattern.rs index 99658e9a2b84..ee665e1908b3 100644 --- a/crates/biome_grit_patterns/src/grit_resolved_pattern.rs +++ b/crates/biome_grit_patterns/src/grit_resolved_pattern.rs @@ -345,11 +345,75 @@ impl<'a> ResolvedPattern<'a, GritQueryContext> for GritResolvedPattern<'a> { fn extend( &mut self, - _with: Self, - _effects: &mut Vec>, - _language: &::Language<'a>, + mut with: Self, + effects: &mut Vec>, + language: &::Language<'a>, ) -> GritResult<()> { - Err(GritPatternError::new("Not implemented")) // TODO: Implement rewriting + match self { + Self::Binding(bindings) => { + let new_effects: GritResult>> = bindings + .iter() + .map(|b| { + let is_first = !effects.iter().any(|e| e.binding == *b); + with.normalize_insert(b, is_first, language)?; + Ok(Effect { + binding: b.clone(), + pattern: with.clone(), + kind: grit_util::EffectKind::Insert, + }) + }) + .collect(); + effects.extend(new_effects?); + Ok(()) + } + Self::Snippets(snippets) => { + match with { + Self::Snippets(with_snippets) => snippets.extend(with_snippets), + Self::Binding(binding) => { + let binding = binding.last().ok_or_else(|| { + GritPatternError::new("cannot extend with empty binding") + })?; + snippets.push(ResolvedSnippet::Binding(binding.clone())); + } + Self::Constant(c) => { + snippets.push(ResolvedSnippet::Text(c.to_string().into())); + } + Self::List(_) | Self::File(_) | Self::Files(_) | Self::Map(_) => { + return Err(GritPatternError::new( + "cannot extend ResolvedPattern::Snippet with this type", + )); + } + } + Ok(()) + } + Self::List(lst) => { + lst.push(with); + Ok(()) + } + Self::Constant(Constant::Integer(i)) => { + if let Self::Constant(Constant::Integer(j)) = with { + *i += j; + Ok(()) + } else { + Err(GritPatternError::new( + "can only extend Constant::Integer with another Constant::Integer", + )) + } + } + Self::Constant(Constant::Float(x)) => { + if let Self::Constant(Constant::Float(y)) = with { + *x += y; + Ok(()) + } else { + Err(GritPatternError::new( + "can only extend Constant::Float with another Constant::Float", + )) + } + } + Self::File(_) | Self::Files(_) | Self::Map(_) | Self::Constant(_) => { + Err(GritPatternError::new("cannot extend this resolved pattern")) + } + } } fn float( @@ -528,14 +592,83 @@ impl<'a> ResolvedPattern<'a, GritQueryContext> for GritResolvedPattern<'a> { fn linearized_text( &self, - _language: &GritTargetLanguage, - _effects: &[Effect<'a, GritQueryContext>], - _files: &FileRegistry<'a, GritQueryContext>, - _memo: &mut HashMap>, - _should_pad_snippet: bool, - _logs: &mut AnalysisLogs, + language: &GritTargetLanguage, + effects: &[Effect<'a, GritQueryContext>], + files: &FileRegistry<'a, GritQueryContext>, + memo: &mut HashMap>, + should_pad_snippet: bool, + logs: &mut AnalysisLogs, ) -> GritResult> { - Err(GritPatternError::new("Not implemented")) // TODO: Implement rewriting + match self { + Self::Snippets(snippets) => Ok(snippets + .iter() + .map(|snippet| snippet.linearized_text(language, effects, files, memo, None, logs)) + .collect::>>()? + .join("") + .into()), + Self::Binding(bindings) => Ok(bindings + .last() + .ok_or_else(|| { + GritPatternError::new("cannot grab text of resolved_pattern with no binding") + })? + .linearized_text( + language, + effects, + files, + memo, + should_pad_snippet.then_some(0), + logs, + )?), + Self::Constant(c) => Ok(c.to_string().into()), + Self::List(list) => Ok(list + .iter() + .map(|pattern| { + pattern.linearized_text( + language, + effects, + files, + memo, + should_pad_snippet, + logs, + ) + }) + .collect::>>()? + .join(",") + .into()), + Self::Map(map) => Ok(("{".to_string() + + &map + .iter() + .map(|(key, value)| { + let linearized = value.linearized_text( + language, + effects, + files, + memo, + should_pad_snippet, + logs, + )?; + Ok(format!("\"{key}\": {linearized}")) + }) + .collect::>>()? + .join(", ") + + "}") + .into()), + Self::File(file) => Ok(format!( + "{}:\n{}", + file.name(files) + .linearized_text(language, effects, files, memo, false, logs)?, + file.body(files).linearized_text( + language, + effects, + files, + memo, + should_pad_snippet, + logs, + )? + ) + .into()), + Self::Files(_) => self.text(files, language), + } } fn matches_undefined(&self) -> bool { @@ -561,11 +694,22 @@ impl<'a> ResolvedPattern<'a, GritQueryContext> for GritResolvedPattern<'a> { fn normalize_insert( &mut self, - _binding: &GritBinding, - _is_first: bool, - _language: &GritTargetLanguage, + binding: &GritBinding, + is_first: bool, + language: &GritTargetLanguage, ) -> GritResult<()> { - Err(GritPatternError::new("Not implemented")) // TODO: Implement insertion padding + let Self::Snippets(snippets) = self else { + return Ok(()); + }; + let Some(ResolvedSnippet::Text(text)) = snippets.first() else { + return Ok(()); + }; + if let Some(padding) = binding.get_insertion_padding(text, is_first, language) + && padding.chars().next() != binding.text(language)?.chars().last() + { + snippets.insert(0, ResolvedSnippet::Text(padding.into())); + } + Ok(()) } fn position(&self, language: &GritTargetLanguage) -> Option { diff --git a/crates/biome_grit_patterns/src/lib.rs b/crates/biome_grit_patterns/src/lib.rs index 689d2fed9b5c..d7a972f29f59 100644 --- a/crates/biome_grit_patterns/src/lib.rs +++ b/crates/biome_grit_patterns/src/lib.rs @@ -19,6 +19,7 @@ mod grit_resolved_pattern; mod grit_target_language; mod grit_target_node; mod grit_tree; +mod linearization; mod pattern_compiler; mod source_location_ext; mod util; diff --git a/crates/biome_grit_patterns/src/linearization.rs b/crates/biome_grit_patterns/src/linearization.rs new file mode 100644 index 000000000000..d17e1cdcaebe --- /dev/null +++ b/crates/biome_grit_patterns/src/linearization.rs @@ -0,0 +1,122 @@ +use crate::grit_context::GritQueryContext; +use crate::grit_target_language::GritTargetLanguage; +use grit_pattern_matcher::binding::Binding; +use grit_pattern_matcher::effects::Effect; +use grit_pattern_matcher::pattern::{FileRegistry, ResolvedPattern, get_top_level_effects}; +use grit_util::error::GritResult; +use grit_util::{AnalysisLogs, CodeRange, EffectKind}; +use std::borrow::Cow; +use std::collections::HashMap; + +/// Simplified linearization function that applies effects to produce rewritten +/// source text. +/// +/// This is a simplified version of the upstream `linearize_binding` that skips +/// padding/indent alignment (not yet implemented for Biome's target languages). +#[expect(clippy::too_many_arguments)] +pub(crate) fn linearize_binding<'a>( + language: &GritTargetLanguage, + effects: &[Effect<'a, GritQueryContext>], + files: &FileRegistry<'a, GritQueryContext>, + memo: &mut HashMap>, + source: &'a str, + range: CodeRange, + _distributed_indent: Option, + logs: &mut AnalysisLogs, +) -> GritResult> { + // Get only top-level effects within this range. + let top_level_effects = get_top_level_effects(effects, memo, &range, language, logs)?; + + if top_level_effects.is_empty() { + return Ok(Cow::Borrowed( + &source[range.start as usize..range.end as usize], + )); + } + + // For each effect, compute the linearized replacement text. + let mut replacements: Vec<(usize, usize, String)> = Vec::new(); + for effect in &top_level_effects { + let binding = &effect.binding; + let binding_range = binding.code_range(language); + + if let Some(ref br) = binding_range { + // Check memo cache for rewrites. + if matches!(effect.kind, EffectKind::Rewrite) { + if let Some(cached) = memo.get(br) { + if let Some(cached_text) = cached { + let byte_range = binding + .range(language) + .expect("binding should have a range"); + replacements.push((byte_range.start, byte_range.end, cached_text.clone())); + continue; + } + } else { + // Mark as "in progress" to prevent infinite recursion. + memo.insert(br.clone(), None); + } + } + } + + // Recursively linearize the replacement pattern. + let res = effect + .pattern + .linearized_text(language, effects, files, memo, false, logs)?; + + if let Some(ref br) = binding_range + && matches!(effect.kind, EffectKind::Rewrite) + { + memo.insert(br.clone(), Some(res.to_string())); + } + + let byte_range = binding + .range(language) + .expect("binding should have a range"); + replacements.push((byte_range.start, byte_range.end, res.into_owned())); + } + + // Sort replacements by start offset. + replacements.sort_by_key(|(start, _, _)| *start); + + // Walk source, copying gaps and inserting replacements. + let mut result = String::new(); + let mut cursor = range.start as usize; + + for (start, end, replacement) in &replacements { + // Copy the gap from cursor to this replacement. + if *start > cursor { + result.push_str(&source[cursor..*start]); + } + result.push_str(replacement); + cursor = *end; + } + + // Copy any remaining source after the last replacement. + if cursor < range.end as usize { + result.push_str(&source[cursor..range.end as usize]); + } + + memo.insert(range, Some(result.clone())); + Ok(Cow::Owned(result)) +} + +/// Simplified apply_effects: applies accumulated effects to produce rewritten +/// source for a file. Returns the rewritten source as an owned String. +pub(crate) fn apply_effects<'a>( + source: &'a str, + effects: &[Effect<'a, GritQueryContext>], + files: &FileRegistry<'a, GritQueryContext>, + language: &GritTargetLanguage, + logs: &mut AnalysisLogs, +) -> GritResult { + if effects.is_empty() { + return Ok(source.to_string()); + } + + let mut memo: HashMap> = HashMap::new(); + let range = CodeRange::new(0, source.len() as u32, source); + + let result = linearize_binding( + language, effects, files, &mut memo, source, range, None, logs, + )?; + Ok(result.into_owned()) +} diff --git a/crates/biome_grit_patterns/tests/specs/ts/duplicateVariable.snap b/crates/biome_grit_patterns/tests/specs/ts/duplicateVariable.snap index f630bc8da8d6..0128d3a16144 100644 --- a/crates/biome_grit_patterns/tests/specs/ts/duplicateVariable.snap +++ b/crates/biome_grit_patterns/tests/specs/ts/duplicateVariable.snap @@ -1,5 +1,6 @@ --- source: crates/biome_grit_patterns/tests/spec_tests.rs +assertion_line: 94 expression: duplicateVariable --- SnapshotResult { @@ -8,6 +9,14 @@ SnapshotResult { "2:1-2:13", "6:1-6:21", ], - rewritten_files: [], + rewritten_files: [ + OutputFile { + messages: [], + variables: [], + source_file: "tests/specs/ts/duplicateVariable.ts", + content: "\nfoo?.();\nfoo && bar();\nfoo && foo.bar();\nbar || bar();\nfoo.bar?.();\n", + byte_ranges: None, + }, + ], created_files: [], } diff --git a/crates/biome_grit_patterns/tests/specs/ts/functionToArrow.snap b/crates/biome_grit_patterns/tests/specs/ts/functionToArrow.snap index 0ed5c4abee45..28fb568a5e43 100644 --- a/crates/biome_grit_patterns/tests/specs/ts/functionToArrow.snap +++ b/crates/biome_grit_patterns/tests/specs/ts/functionToArrow.snap @@ -1,5 +1,6 @@ --- source: crates/biome_grit_patterns/tests/spec_tests.rs +assertion_line: 94 expression: functionToArrow --- SnapshotResult { @@ -8,6 +9,14 @@ SnapshotResult { "1:1-2:2", "4:1-6:2", ], - rewritten_files: [], + rewritten_files: [ + OutputFile { + messages: [], + variables: [], + source_file: "tests/specs/ts/functionToArrow.ts", + content: "const foo = (apple) => { }\n\nconst bar = (apple, pear) => { console.log(\"fruits\"); }\n\nfunction baz(pear) {\n}\n", + byte_ranges: None, + }, + ], created_files: [], } diff --git a/crates/biome_grit_patterns/tests/specs/ts/log.snap b/crates/biome_grit_patterns/tests/specs/ts/log.snap index 438da694bf63..867d8d9ea17d 100644 --- a/crates/biome_grit_patterns/tests/specs/ts/log.snap +++ b/crates/biome_grit_patterns/tests/specs/ts/log.snap @@ -1,5 +1,6 @@ --- source: crates/biome_grit_patterns/tests/spec_tests.rs +assertion_line: 94 expression: log --- SnapshotResult { @@ -7,7 +8,15 @@ SnapshotResult { matched_ranges: [ "1:1-1:21", ], - rewritten_files: [], + rewritten_files: [ + OutputFile { + messages: [], + variables: [], + source_file: "tests/specs/ts/log.ts", + content: ";\n", + byte_ranges: None, + }, + ], created_files: [], } diff --git a/crates/biome_grit_patterns/tests/specs/ts/patternDefinition.snap b/crates/biome_grit_patterns/tests/specs/ts/patternDefinition.snap index 36ecd4420476..71f9521fe2e6 100644 --- a/crates/biome_grit_patterns/tests/specs/ts/patternDefinition.snap +++ b/crates/biome_grit_patterns/tests/specs/ts/patternDefinition.snap @@ -1,5 +1,6 @@ --- source: crates/biome_grit_patterns/tests/spec_tests.rs +assertion_line: 94 expression: patternDefinition --- SnapshotResult { @@ -7,6 +8,14 @@ SnapshotResult { matched_ranges: [ "1:1-1:29", ], - rewritten_files: [], + rewritten_files: [ + OutputFile { + messages: [], + variables: [], + source_file: "tests/specs/ts/patternDefinition.ts", + content: "console.info('Hello, world!');\nconsole.warn('Can you hear me?');\n", + byte_ranges: None, + }, + ], created_files: [], } diff --git a/crates/biome_grit_patterns/tests/specs/ts/rawSnippet.snap b/crates/biome_grit_patterns/tests/specs/ts/rawSnippet.snap index 159eab2ef19e..dc9236956f54 100644 --- a/crates/biome_grit_patterns/tests/specs/ts/rawSnippet.snap +++ b/crates/biome_grit_patterns/tests/specs/ts/rawSnippet.snap @@ -1,5 +1,6 @@ --- source: crates/biome_grit_patterns/tests/spec_tests.rs +assertion_line: 94 expression: rawSnippet --- SnapshotResult { @@ -7,6 +8,19 @@ SnapshotResult { matched_ranges: [ "1:1-1:29", ], - rewritten_files: [], + rewritten_files: [ + OutputFile { + messages: [], + variables: [], + source_file: "tests/specs/ts/rawSnippet.ts", + content: "if(' // I like broken code\";\n", + byte_ranges: None, + }, + ], created_files: [], } + +## Logs + +Message: unterminated string literalSyntax: +Message: expected `)` but instead the file endsSyntax: diff --git a/crates/biome_grit_patterns/tests/specs/ts/regex.snap b/crates/biome_grit_patterns/tests/specs/ts/regex.snap index ca2afac0acce..6e1a576daf94 100644 --- a/crates/biome_grit_patterns/tests/specs/ts/regex.snap +++ b/crates/biome_grit_patterns/tests/specs/ts/regex.snap @@ -1,5 +1,6 @@ --- source: crates/biome_grit_patterns/tests/spec_tests.rs +assertion_line: 94 expression: regex --- SnapshotResult { @@ -7,6 +8,14 @@ SnapshotResult { matched_ranges: [ "2:1-2:27", ], - rewritten_files: [], + rewritten_files: [ + OutputFile { + messages: [], + variables: [], + source_file: "tests/specs/ts/regex.ts", + content: "console.log(\"Hello, Bert\");\nconsole.log(Lucy, Hello);\n", + byte_ranges: None, + }, + ], created_files: [], } diff --git a/crates/biome_grit_patterns/tests/specs/ts/whereClause.snap b/crates/biome_grit_patterns/tests/specs/ts/whereClause.snap index f37d4b1f7b77..708ff8dc8df9 100644 --- a/crates/biome_grit_patterns/tests/specs/ts/whereClause.snap +++ b/crates/biome_grit_patterns/tests/specs/ts/whereClause.snap @@ -1,5 +1,6 @@ --- source: crates/biome_grit_patterns/tests/spec_tests.rs +assertion_line: 94 expression: whereClause --- SnapshotResult { @@ -7,6 +8,14 @@ SnapshotResult { matched_ranges: [ "2:1-2:29", ], - rewritten_files: [], + rewritten_files: [ + OutputFile { + messages: [], + variables: [], + source_file: "tests/specs/ts/whereClause.ts", + content: "console.log('Hi');\n;\n", + byte_ranges: None, + }, + ], created_files: [], } From d77242a94c80d87c25ac5bb60f636e548cb63609 Mon Sep 17 00:00:00 2001 From: Bohdan Pomohaibo Date: Sat, 14 Feb 2026 21:53:11 +0200 Subject: [PATCH 02/35] feat(biome_analyze): support code actions from analyzer plugins --- Cargo.lock | 1 + crates/biome_analyze/Cargo.toml | 1 + crates/biome_analyze/src/analyzer_plugin.rs | 41 ++++++++++-- crates/biome_analyze/src/lib.rs | 3 +- crates/biome_analyze/src/signals.rs | 67 +++++++++++++++++-- .../src/analyzer_grit_plugin.rs | 62 +++++++++++++---- .../src/analyzer_js_plugin.rs | 32 ++++++--- 7 files changed, 167 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43a28fccf7a1..2f1e217b913b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,7 @@ dependencies = [ "biome_parser", "biome_rowan", "biome_suppression", + "biome_text_edit", "camino", "enumflags2", "indexmap", diff --git a/crates/biome_analyze/Cargo.toml b/crates/biome_analyze/Cargo.toml index bf09f589f532..82ab054d9089 100644 --- a/crates/biome_analyze/Cargo.toml +++ b/crates/biome_analyze/Cargo.toml @@ -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 } diff --git a/crates/biome_analyze/src/analyzer_plugin.rs b/crates/biome_analyze/src/analyzer_plugin.rs index 7028267172a6..79dfaef5ab27 100644 --- a/crates/biome_analyze/src/analyzer_plugin.rs +++ b/crates/biome_analyze/src/analyzer_plugin.rs @@ -1,10 +1,11 @@ +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, @@ -16,13 +17,33 @@ pub type AnalyzerPluginSlice<'a> = &'a [Arc>]; /// Vector of analyzer plugins that can be cheaply cloned. pub type AnalyzerPluginVec = Vec>>; +/// 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, +} + +/// Result of evaluating a plugin, containing diagnostics and optional code actions. +#[derive(Debug, Default)] +pub struct PluginEvalResult { + pub diagnostics: Vec, + pub actions: Vec, +} + /// Definition of an analyzer plugin. pub trait AnalyzerPlugin: Debug + Send + Sync { fn language(&self) -> PluginTargetLanguage; fn query(&self) -> Vec; - fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> Vec; + fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> PluginEvalResult; } #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] @@ -103,20 +124,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 actions = eval_result.actions; + let signals = eval_result.diagnostics.into_iter().map(|diagnostic| { let name = diagnostic .subcategory .clone() .unwrap_or_else(|| "anonymous".into()); + let text_range = diagnostic.span().unwrap_or_default(); + + let signal = PluginSignal::::new(diagnostic) + .with_actions(actions.clone()) + .with_root(node.clone()); SignalEntry { - text_range: diagnostic.span().unwrap_or_default(), - signal: Box::new(PluginSignal::::new(diagnostic)), + text_range, + signal: Box::new(signal), rule: SignalRuleKey::Plugin(name.into()), category: RuleCategory::Lint, instances: Default::default(), diff --git a/crates/biome_analyze/src/lib.rs b/crates/biome_analyze/src/lib.rs index 5d08efefd195..242fb38cef91 100644 --- a/crates/biome_analyze/src/lib.rs +++ b/crates/biome_analyze/src/lib.rs @@ -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, PluginEvalResult, + PluginTargetLanguage, PluginVisitor, }; pub use crate::categories::{ ActionCategory, OtherActionCategory, RefactorKind, RuleCategories, RuleCategoriesBuilder, diff --git a/crates/biome_analyze/src/signals.rs b/crates/biome_analyze/src/signals.rs index 4ebd62ea216c..b93bb2d32c65 100644 --- a/crates/biome_analyze/src/signals.rs +++ b/crates/biome_analyze/src/signals.rs @@ -11,7 +11,9 @@ use crate::{ }; 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; @@ -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 { +pub struct PluginSignal { diagnostic: RuleDiagnostic, - _phantom: PhantomData, + plugin_actions: Vec, + root: Option>, } impl PluginSignal { pub fn new(diagnostic: RuleDiagnostic) -> Self { Self { diagnostic, - _phantom: PhantomData, + plugin_actions: Vec::new(), + root: None, } } + + pub fn with_actions(mut self, actions: Vec) -> Self { + self.plugin_actions = actions; + self + } + + pub fn with_root(mut self, root: SyntaxNode) -> Self { + self.root = Some(root); + self + } } impl AnalyzerSignal for PluginSignal { @@ -129,7 +143,35 @@ impl AnalyzerSignal for PluginSignal { } fn actions(&self) -> AnalyzerActionIter { - AnalyzerActionIter::new(vec![]) + if self.plugin_actions.is_empty() { + return AnalyzerActionIter::new(vec![]); + } + + let Some(root) = &self.root else { + return AnalyzerActionIter::new(vec![]); + }; + + let actions: Vec<_> = self + .plugin_actions + .iter() + .map(|action_data| { + let text_edit = TextEdit::from_unicode_words( + &action_data.original_text, + &action_data.rewritten_text, + ); + + AnalyzerAction { + rule_name: None, + category: ActionCategory::QuickFix(Cow::Borrowed("plugin.fix")), + applicability: Applicability::MaybeIncorrect, + message: markup!({ action_data.message }).to_owned(), + mutation: BatchMutation::new(root.clone()), + text_edit: Some((action_data.source_range, text_edit)), + } + }) + .collect(); + + AnalyzerActionIter::new(actions) } fn transformations(&self) -> AnalyzerTransformationIter { @@ -149,6 +191,8 @@ pub struct AnalyzerAction { pub applicability: Applicability, pub message: MarkupBuf, pub mutation: BatchMutation, + /// Pre-computed text edit for plugin rewrites. Takes precedence over mutation. + pub text_edit: Option<(TextRange, TextEdit)>, } impl AnalyzerAction { @@ -179,7 +223,10 @@ impl Default for AnalyzerActionIter { impl From> for CodeSuggestionAdvice { fn from(action: AnalyzerAction) -> 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, @@ -190,7 +237,10 @@ impl From> for CodeSuggestionAdvice { impl From> for CodeSuggestionItem { fn from(action: AnalyzerAction) -> 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, @@ -469,6 +519,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) @@ -485,6 +536,7 @@ where applicability: Applicability::Always, mutation: suppression_action.mutation, message: suppression_action.message, + text_edit: None, }; actions.push(action); } @@ -498,6 +550,7 @@ where applicability: Applicability::Always, mutation: suppression_action.mutation, message: suppression_action.message, + text_edit: None, }; actions.push(action); } diff --git a/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs b/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs index ee2b0ad8b179..2d98e15a7d48 100644 --- a/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs +++ b/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs @@ -1,13 +1,13 @@ use crate::{AnalyzerPlugin, PluginDiagnostic}; -use biome_analyze::{PluginTargetLanguage, RuleDiagnostic}; +use biome_analyze::{PluginActionData, PluginEvalResult, PluginTargetLanguage, RuleDiagnostic}; use biome_console::markup; use biome_css_syntax::{CssRoot, CssSyntaxNode}; use biome_diagnostics::{Severity, category}; use biome_fs::FileSystem; use biome_grit_patterns::{ BuiltInFunction, CompilePatternOptions, GritBinding, GritExecContext, GritPattern, GritQuery, - GritQueryContext, GritQueryState, GritResolvedPattern, GritTargetFile, GritTargetLanguage, - compile_pattern_with_options, + GritQueryContext, GritQueryEffect, GritQueryState, GritResolvedPattern, GritTargetFile, + GritTargetLanguage, compile_pattern_with_options, }; use biome_js_syntax::{AnyJsRoot, JsSyntaxNode}; use biome_json_syntax::{JsonRoot, JsonSyntaxNode}; @@ -68,19 +68,34 @@ impl AnalyzerPlugin for AnalyzerGritPlugin { } } - fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> Vec { + fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> PluginEvalResult { let name: &str = self.grit_query.name.as_deref().unwrap_or("anonymous"); - let root = match self.language() { + let (root, source_range, original_text) = match self.language() { PluginTargetLanguage::JavaScript => node .downcast_ref::() - .and_then(|node| node.as_send()), + .map(|node| { + let range = node.text_range_with_trivia(); + let text = node.text_with_trivia().to_string(); + (node.as_send(), range, text) + }) + .unwrap(), PluginTargetLanguage::Css => node .downcast_ref::() - .and_then(|node| node.as_send()), + .map(|node| { + let range = node.text_range_with_trivia(); + let text = node.text_with_trivia().to_string(); + (node.as_send(), range, text) + }) + .unwrap(), PluginTargetLanguage::Json => node .downcast_ref::() - .and_then(|node| node.as_send()), + .map(|node| { + let range = node.text_range_with_trivia(); + let text = node.text_with_trivia().to_string(); + (node.as_send(), range, text) + }) + .unwrap(), }; let parse = AnyParse::Node(NodeParse::new(root.unwrap(), vec![])); @@ -118,13 +133,32 @@ impl AnalyzerPlugin for AnalyzerGritPlugin { )); } - diagnostics + // Convert rewrite effects to plugin actions. + let mut actions = Vec::new(); + for effect in &result.effects { + if let GritQueryEffect::Rewrite(rewrite) = effect { + actions.push(PluginActionData { + source_range, + original_text: original_text.clone(), + rewritten_text: rewrite.rewritten.content.clone(), + message: format!("Rewrite suggested by plugin `{name}`"), + }); + } + } + + PluginEvalResult { + diagnostics, + actions, + } } - Err(error) => vec![RuleDiagnostic::new( - category!("plugin"), - None::, - markup!({name}" errored: "{error.to_string()}), - )], + Err(error) => PluginEvalResult { + diagnostics: vec![RuleDiagnostic::new( + category!("plugin"), + None::, + markup!({name}" errored: "{error.to_string()}), + )], + actions: Vec::new(), + }, } } } diff --git a/crates/biome_plugin_loader/src/analyzer_js_plugin.rs b/crates/biome_plugin_loader/src/analyzer_js_plugin.rs index 4013f672f8b6..03e73221314a 100644 --- a/crates/biome_plugin_loader/src/analyzer_js_plugin.rs +++ b/crates/biome_plugin_loader/src/analyzer_js_plugin.rs @@ -6,7 +6,7 @@ use boa_engine::object::builtins::JsFunction; use boa_engine::{JsNativeError, JsResult, JsString, JsValue}; use camino::{Utf8Path, Utf8PathBuf}; -use biome_analyze::{AnalyzerPlugin, PluginTargetLanguage, RuleDiagnostic}; +use biome_analyze::{AnalyzerPlugin, PluginEvalResult, PluginTargetLanguage, RuleDiagnostic}; use biome_console::markup; use biome_diagnostics::category; use biome_js_runtime::JsExecContext; @@ -85,25 +85,28 @@ impl AnalyzerPlugin for AnalyzerJsPlugin { .collect() } - fn evaluate(&self, _node: AnySyntaxNode, path: Arc) -> Vec { + fn evaluate(&self, _node: AnySyntaxNode, path: Arc) -> PluginEvalResult { let mut plugin = match self .loaded .get_mut_or_try_init(|| load_plugin(self.fs.clone(), &self.path)) { Ok(plugin) => plugin, Err(err) => { - return vec![RuleDiagnostic::new( - category!("plugin"), - None::, - markup!("Could not load the plugin: "{err.to_string()}), - )]; + return PluginEvalResult { + diagnostics: vec![RuleDiagnostic::new( + category!("plugin"), + None::, + markup!("Could not load the plugin: "{err.to_string()}), + )], + actions: Vec::new(), + }; } }; let plugin = plugin.deref_mut(); // TODO: pass the AST to the plugin - plugin + let diagnostics = plugin .ctx .call_function( &plugin.entrypoint, @@ -119,7 +122,12 @@ impl AnalyzerPlugin for AnalyzerJsPlugin { )] }, |_| plugin.ctx.pull_diagnostics(), - ) + ); + + PluginEvalResult { + diagnostics, + actions: Vec::new(), + } } } @@ -190,8 +198,10 @@ mod tests { }) }; - let mut diagnostics = worker1.join().unwrap(); - diagnostics.extend(worker2.join().unwrap()); + let result1 = worker1.join().unwrap(); + let result2 = worker2.join().unwrap(); + let mut diagnostics = result1.diagnostics; + diagnostics.extend(result2.diagnostics); assert_eq!(diagnostics.len(), 2); snap_diagnostics( From e8f051df863d12f98053e0c2d1d3d01bf536ec58 Mon Sep 17 00:00:00 2001 From: Bohdan Pomohaibo Date: Sat, 14 Feb 2026 23:27:19 +0200 Subject: [PATCH 03/35] fix(biome_service): prevent infinite loop when applying empty mutations --- crates/biome_service/src/file_handlers/mod.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/biome_service/src/file_handlers/mod.rs b/crates/biome_service/src/file_handlers/mod.rs index a6e0048f810b..21dd8738eddb 100644 --- a/crates/biome_service/src/file_handlers/mod.rs +++ b/crates/biome_service/src/file_handlers/mod.rs @@ -831,14 +831,27 @@ impl<'a> ProcessFixAll<'a> { }, )); }; - }; - Ok(Some(())) + Ok(Some(())) + } else { + // Mutation was empty (no tree changes), signal no fix applied + Ok(None) + } } None => Ok(None), } } + /// Record a text-edit-based fix (e.g. from a plugin rewrite) that was + /// applied outside of the normal mutation path. + pub(crate) fn record_text_edit_fix(&mut self, range: TextRange, new_text_len: u32) { + self.actions.push(FixAction { + rule_name: None, + range, + }); + self.growth_guard.check(new_text_len); + } + /// Finish processing the fix all actions. Returns the result of the fix-all actions. The `format_tree` /// is a closure that must return the new code (formatted, if needed). pub(crate) fn finish(self, format_tree: F) -> Result From 5319ad2ca6910b185cd759238041f3fe0e577398 Mon Sep 17 00:00:00 2001 From: Bohdan Pomohaibo Date: Sat, 14 Feb 2026 23:27:32 +0200 Subject: [PATCH 04/35] feat(biome_service): apply plugin rewrite text edits via --write --- crates/biome_cli/tests/commands/check.rs | 141 ++++++++++++++++++ .../check_plugin_apply_rewrite.snap | 37 +++++ .../check_plugin_multiple_rewrites.snap | 39 +++++ .../check_plugin_rewrite_no_write.snap | 67 +++++++++ .../src/file_handlers/javascript.rs | 24 +++ 5 files changed, 308 insertions(+) create mode 100644 crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_apply_rewrite.snap create mode 100644 crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_multiple_rewrites.snap create mode 100644 crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_rewrite_no_write.snap diff --git a/crates/biome_cli/tests/commands/check.rs b/crates/biome_cli/tests/commands/check.rs index f9d5eb08c32a..ea959e00ed3e 100644 --- a/crates/biome_cli/tests/commands/check.rs +++ b/crates/biome_cli/tests/commands/check.rs @@ -3441,6 +3441,147 @@ const foo = 'bad' )); } +#[test] +fn check_plugin_apply_rewrite() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["useConsoleInfo.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("useConsoleInfo.grit"), + br#"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)` +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert(file_path.into(), b"console.log(\"hello\");\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", "--write", "--unsafe", file_path.as_str()].as_slice()), + ); + + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents(&fs, file_path, "console.info(\"hello\");\n"); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_apply_rewrite", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_rewrite_no_write() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["useConsoleInfo.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("useConsoleInfo.grit"), + br#"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)` +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert(file_path.into(), b"console.log(\"hello\");\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", file_path.as_str()].as_slice()), + ); + + assert_file_contents(&fs, file_path, "console.log(\"hello\");\n"); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_rewrite_no_write", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_multiple_rewrites() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["useLoggerInfo.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("useLoggerInfo.grit"), + br#"language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use logger.info instead of console.log.", severity = "warning"), + $call => `logger.info($msg)` +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert( + file_path.into(), + b"console.log(\"hello\");\nconsole.log(\"world\");\nconsole.log(\"!\");\n", + ); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", "--write", "--unsafe", file_path.as_str()].as_slice()), + ); + + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents( + &fs, + file_path, + "logger.info(\"hello\");\nlogger.info(\"world\");\nlogger.info(\"!\");\n", + ); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_multiple_rewrites", + fs, + console, + result, + )); +} + #[test] fn doesnt_check_file_when_assist_is_disabled() { let fs = MemoryFileSystem::default(); diff --git a/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_apply_rewrite.snap b/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_apply_rewrite.snap new file mode 100644 index 000000000000..629bce309d85 --- /dev/null +++ b/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_apply_rewrite.snap @@ -0,0 +1,37 @@ +--- +source: crates/biome_cli/tests/snap_test.rs +expression: redactor(content) +--- +## `biome.json` + +```json +{ + "plugins": ["useConsoleInfo.grit"], + "formatter": { "enabled": false } +} +``` + +## `input.js` + +```js +console.info("hello"); + +``` + +## `useConsoleInfo.grit` + +```grit +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)` +} + +``` + +# Emitted Messages + +```block +Checked 1 file in