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;