diff --git a/crates/biome_analyze/src/diagnostics.rs b/crates/biome_analyze/src/diagnostics.rs
index 9991f850549b..193f900044e2 100644
--- a/crates/biome_analyze/src/diagnostics.rs
+++ b/crates/biome_analyze/src/diagnostics.rs
@@ -240,9 +240,43 @@ pub enum RuleError {
ReplacedRootWithNonRootError {
rule_name: Option<(Cow<'static, str>, Cow<'static, str>)>,
},
+ /// The rules listed below caused an infinite loop when applying fixes to the file.
+ ConflictingRuleFixesError {
+ rules: Vec<(Cow<'static, str>, Cow<'static, str>)>,
+ },
}
-impl Diagnostic for RuleError {}
+impl Diagnostic for RuleError {
+ fn category(&self) -> Option<&'static Category> {
+ Some(category!("internalError/panic"))
+ }
+
+ fn severity(&self) -> Severity {
+ Severity::Error
+ }
+
+ fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(
+ fmt,
+ "An internal error occurred when analyzing this file.\n\n{}\n\nThis is likely a bug in Biome, not an error in your code. Please consider filing an issue on GitHub with a reproduction of this error.",
+ self
+ )?;
+ Ok(())
+ }
+
+ fn message(&self, fmt: &mut biome_console::fmt::Formatter<'_>) -> std::io::Result<()> {
+ fmt.write_markup(markup! {
+ "An internal error occurred when analyzing this file."
+ })?;
+ fmt.write_markup(markup! {
+ {self}
+ })?;
+ fmt.write_markup(markup! {
+ "This is likely a bug in Biome, not an error in your code. Please consider filing an issue on ""GitHub"" with a reproduction of this error."
+ })?;
+ Ok(())
+ }
+}
impl std::fmt::Display for RuleError {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
@@ -252,13 +286,27 @@ impl std::fmt::Display for RuleError {
} => {
std::write!(
fmt,
- "the rule '{group}/{rule}' replaced the root of the file with a non-root node."
+ "The rule '{group}/{rule}' replaced the root of the file with a non-root node."
)
}
Self::ReplacedRootWithNonRootError { rule_name: None } => {
std::write!(
fmt,
- "a code action replaced the root of the file with a non-root node."
+ "A code action replaced the root of the file with a non-root node."
+ )
+ }
+ Self::ConflictingRuleFixesError { rules } => {
+ if rules.is_empty() {
+ return std::write!(fmt, "conflicting rule fixes detected");
+ }
+ let rules_list = rules
+ .iter()
+ .map(|(group, rule)| format!("'{group}/{rule}'"))
+ .collect::>()
+ .join(", ");
+ std::write!(
+ fmt,
+ "The rules {rules_list} caused an infinite loop when applying fixes to the file."
)
}
}
@@ -273,13 +321,27 @@ impl biome_console::fmt::Display for RuleError {
} => {
std::write!(
fmt,
- "the rule '{group}/{rule}' replaced the root of the file with a non-root node."
+ "The rule '{group}/{rule}' replaced the root of the file with a non-root node."
)
}
Self::ReplacedRootWithNonRootError { rule_name: None } => {
std::write!(
fmt,
- "a code action replaced the root of the file with a non-root node."
+ "A code action replaced the root of the file with a non-root node."
+ )
+ }
+ Self::ConflictingRuleFixesError { rules } => {
+ if rules.is_empty() {
+ return std::write!(fmt, "Conflicting rule fixes detected.");
+ }
+ let rules_list = rules
+ .iter()
+ .map(|(group, rule)| format!("'{group}/{rule}'"))
+ .collect::>()
+ .join(", ");
+ std::write!(
+ fmt,
+ "The rules {rules_list} caused an infinite loop when applying fixes to the file."
)
}
}
diff --git a/crates/biome_service/src/file_handlers/css.rs b/crates/biome_service/src/file_handlers/css.rs
index 6d1e89324d7b..dff0fdc8929e 100644
--- a/crates/biome_service/src/file_handlers/css.rs
+++ b/crates/biome_service/src/file_handlers/css.rs
@@ -13,6 +13,7 @@ use crate::settings::{
FormatSettings, LanguageListSettings, LanguageSettings, OverrideSettings, ServiceLanguage,
Settings, check_feature_activity, check_override_feature_activity,
};
+use crate::utils::growth_guard::GrowthGuard;
use crate::workspace::{
CodeAction, DocumentFileSource, FixAction, FixFileMode, FixFileResult, GetSyntaxTreeResult,
PullActionsResult,
@@ -42,6 +43,7 @@ use biome_rowan::{AstNode, NodeCache};
use biome_rowan::{TextRange, TextSize, TokenAtOffset};
use camino::Utf8Path;
use std::borrow::Cow;
+use std::collections::HashSet;
use tracing::{debug_span, error, info, trace_span};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
@@ -634,6 +636,7 @@ pub(crate) fn fix_all(params: FixAllParams) -> Result Result {
diff --git a/crates/biome_service/src/file_handlers/graphql.rs b/crates/biome_service/src/file_handlers/graphql.rs
index 91ab88dd35c5..7f009ae61393 100644
--- a/crates/biome_service/src/file_handlers/graphql.rs
+++ b/crates/biome_service/src/file_handlers/graphql.rs
@@ -12,6 +12,7 @@ use crate::settings::{
FormatSettings, LanguageListSettings, LanguageSettings, OverrideSettings, ServiceLanguage,
Settings, check_feature_activity, check_override_feature_activity,
};
+use crate::utils::growth_guard::GrowthGuard;
use crate::workspace::{
CodeAction, FixAction, FixFileMode, FixFileResult, GetSyntaxTreeResult, PullActionsResult,
};
@@ -35,6 +36,7 @@ use biome_parser::AnyParse;
use biome_rowan::{AstNode, NodeCache, TokenAtOffset};
use camino::Utf8Path;
use std::borrow::Cow;
+use std::collections::HashSet;
use tracing::{debug_span, error, info, trace_span};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
@@ -544,6 +546,7 @@ pub(crate) fn fix_all(params: FixAllParams) -> Result Result {
diff --git a/crates/biome_service/src/file_handlers/javascript.rs b/crates/biome_service/src/file_handlers/javascript.rs
index 4eebbcd8c67e..e72945c81ceb 100644
--- a/crates/biome_service/src/file_handlers/javascript.rs
+++ b/crates/biome_service/src/file_handlers/javascript.rs
@@ -9,6 +9,7 @@ use crate::file_handlers::{FixAllParams, is_diagnostic_error};
use crate::settings::{
OverrideSettings, Settings, check_feature_activity, check_override_feature_activity,
};
+use crate::utils::growth_guard::GrowthGuard;
use crate::workspace::DocumentFileSource;
use crate::{
WorkspaceError,
@@ -56,6 +57,7 @@ use biome_rowan::{AstNode, BatchMutationExt, Direction, NodeCache, WalkEvent};
use camino::Utf8Path;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
+use std::collections::HashSet;
use std::fmt::Debug;
use std::sync::Arc;
use tracing::{debug, debug_span, error, trace_span};
@@ -870,6 +872,8 @@ pub(crate) fn fix_all(params: FixAllParams) -> Result Result {
diff --git a/crates/biome_service/src/file_handlers/json.rs b/crates/biome_service/src/file_handlers/json.rs
index 1e5549fa445e..fed54cd0ad67 100644
--- a/crates/biome_service/src/file_handlers/json.rs
+++ b/crates/biome_service/src/file_handlers/json.rs
@@ -12,6 +12,7 @@ use crate::settings::{
FormatSettings, LanguageListSettings, LanguageSettings, OverrideSettings, ServiceLanguage,
Settings, check_feature_activity, check_override_feature_activity,
};
+use crate::utils::growth_guard::GrowthGuard;
use crate::workspace::{
CodeAction, FixAction, FixFileMode, FixFileResult, GetSyntaxTreeResult, PullActionsResult,
};
@@ -42,6 +43,7 @@ use biome_rowan::{AstNode, NodeCache};
use biome_rowan::{TextRange, TextSize, TokenAtOffset};
use camino::Utf8Path;
use std::borrow::Cow;
+use std::collections::HashSet;
use tracing::{debug_span, error, instrument};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
@@ -644,6 +646,7 @@ fn fix_all(params: FixAllParams) -> Result {
let mut actions = Vec::new();
let mut skipped_suggested_fixes = 0;
let mut errors: u16 = 0;
+ let mut growth_guard = GrowthGuard::new(tree.syntax().text_range_with_trivia().len().into());
loop {
let (action, _) = analyze(&tree, filter, &analyzer_options, file_source, |signal| {
@@ -712,6 +715,27 @@ fn fix_all(params: FixAllParams) -> Result {
.map(|(group, rule)| (Cow::Borrowed(group), Cow::Borrowed(rule))),
range,
});
+
+ // Check for runaway edit growth
+ let curr_len: u32 = tree.syntax().text_range_with_trivia().len().into();
+ if !growth_guard.check(curr_len) {
+ // In order to provide a useful diagnostic, we want to flag the rules that caused the conflict.
+ // We can do this by inspecting the last few fixes that were applied.
+ // We limit it to the last 10 fixes. If there is a chain of conflicting fixes longer than that, something is **really** fucked up.
+
+ let mut seen_rules = HashSet::new();
+ for action in actions.iter().rev().take(10) {
+ if let Some((group, rule)) = action.rule_name.as_ref() {
+ seen_rules.insert((group.clone(), rule.clone()));
+ }
+ }
+
+ return Err(WorkspaceError::RuleError(
+ RuleError::ConflictingRuleFixesError {
+ rules: seen_rules.into_iter().collect(),
+ },
+ ));
+ }
}
}
None => {
diff --git a/crates/biome_service/src/lib.rs b/crates/biome_service/src/lib.rs
index 1af530f168de..f076a62a8a60 100644
--- a/crates/biome_service/src/lib.rs
+++ b/crates/biome_service/src/lib.rs
@@ -12,6 +12,7 @@ pub mod workspace;
pub mod workspace_types;
mod scanner;
+mod utils;
#[cfg(test)]
mod test_utils;
diff --git a/crates/biome_service/src/utils/growth_guard.rs b/crates/biome_service/src/utils/growth_guard.rs
new file mode 100644
index 000000000000..ca06bc622b39
--- /dev/null
+++ b/crates/biome_service/src/utils/growth_guard.rs
@@ -0,0 +1,70 @@
+const RATIO_Q: u32 = 100; // fixed-point 2 decimals
+const RATIO_GROWTH: u32 = 150; // 1.5x growth increase
+const RATIO_ACCEL: u32 = 180; // 1.8x delta increase
+const STREAK_LIMIT: u8 = 10;
+
+/// A guard that ensures a value does not grow too quickly.
+///
+/// Used to prevent runaway growth of files when applying fixes.
+pub(crate) struct GrowthGuard {
+ previous: u32,
+ previous_diff: u32,
+ /// multiplicative growth streak
+ growth_streak: u8,
+ /// delta acceleration streak
+ accel_streak: u8,
+}
+
+impl GrowthGuard {
+ pub fn new(initial: u32) -> Self {
+ Self {
+ previous: initial,
+ previous_diff: 0,
+ growth_streak: 0,
+ accel_streak: 0,
+ }
+ }
+
+ /// Check if the new value is allowed based on growth constraints.
+ ///
+ /// Returns `true` if the new value is allowed, `false` otherwise.
+ pub fn check(&mut self, new: u32) -> bool {
+ if new < self.previous {
+ // Allow decreases
+ self.previous = new;
+ self.previous_diff = 0;
+ self.growth_streak = 0;
+ self.accel_streak = 0;
+ return true;
+ }
+
+ let diff = new.saturating_sub(self.previous);
+
+ // Check for multiplicative growth
+ if new.saturating_mul(RATIO_Q) >= self.previous.saturating_mul(RATIO_GROWTH) {
+ self.growth_streak = self.growth_streak.saturating_add(1);
+ } else {
+ self.growth_streak = 0;
+ }
+
+ // Check for delta acceleration
+ if diff.saturating_mul(RATIO_Q) >= self.previous_diff.saturating_mul(RATIO_ACCEL)
+ && self.previous_diff > 0
+ {
+ self.accel_streak = self.accel_streak.saturating_add(1);
+ } else {
+ self.accel_streak = 0;
+ }
+
+ // Update state
+ self.previous = new;
+ self.previous_diff = diff;
+
+ // Enforce limits
+ if self.growth_streak >= STREAK_LIMIT || self.accel_streak >= STREAK_LIMIT {
+ return false;
+ }
+
+ true
+ }
+}
diff --git a/crates/biome_service/src/utils/mod.rs b/crates/biome_service/src/utils/mod.rs
new file mode 100644
index 000000000000..61797edd0700
--- /dev/null
+++ b/crates/biome_service/src/utils/mod.rs
@@ -0,0 +1 @@
+pub mod growth_guard;