use std::{
cmp::Reverse, collections::HashMap, fmt::Write, fs, io::Error as IoError, path::PathBuf,
vec::Vec,
};
use crate::{
engine::LintEngine,
format::format_diff_context,
violation::{Fix, Violation},
};
#[derive(Debug)]
pub struct FixResult {
pub file_path: PathBuf,
pub original_content: String,
pub fixed_content: String,
pub fixes_applied: usize,
}
#[must_use]
pub fn apply_fixes_to_stdin(violations: &[Violation]) -> Option<String> {
let stdin_violations: Vec<&Violation> = violations
.iter()
.filter(|v| {
v.file
.as_ref()
.is_some_and(super::violation::SourceFile::is_stdin)
&& v.fix.is_some()
})
.collect();
if stdin_violations.is_empty() {
return None;
}
let original_content = stdin_violations
.first()
.and_then(|v| v.source.as_ref())
.map(std::borrow::Cow::as_ref)?;
let fixed_content = apply_fixes_to_content(original_content, &stdin_violations);
Some(fixed_content)
}
pub fn apply_fixes(
violations: &[Violation],
dry_run: bool,
lint_engine: &LintEngine,
) -> Vec<FixResult> {
group_violations_by_file(violations)
.into_iter()
.filter_map(|(file_path, _file_violations)| {
apply_fix_to_file(&file_path, dry_run, lint_engine).ok()
})
.collect()
}
fn apply_fix_to_file(
file_path: &PathBuf,
dry_run: bool,
lint_engine: &LintEngine,
) -> Result<FixResult, IoError> {
let original_content = fs::read_to_string(file_path)?;
let (fixed_content, fixes_applied) = apply_fixes_iteratively(&original_content, lint_engine);
log::debug!(
"File: {}, Fixes: {}, Original len: {}, Fixed len: {}",
file_path.display(),
fixes_applied,
original_content.len(),
fixed_content.len()
);
if fixes_applied == 0 {
return Err(IoError::other("No fixes to apply"));
}
if !dry_run {
fs::write(file_path, &fixed_content)?;
}
Ok(FixResult {
file_path: file_path.clone(),
original_content,
fixed_content,
fixes_applied,
})
}
#[must_use]
pub fn apply_fixes_iteratively(content: &str, lint_engine: &LintEngine) -> (String, usize) {
let mut current_content = content.to_string();
let mut total_fixes_applied = 0;
let max_iterations = 100;
for iteration in 0..max_iterations {
let violations = lint_engine.lint_str(¤t_content);
let fixable_violation = violations.iter().find(|v| v.fix.is_some());
if fixable_violation.is_none() {
log::debug!(
"Iterative fix complete after {iteration} iterations, {total_fixes_applied} fixes \
applied"
);
break;
}
let violation = fixable_violation.unwrap();
let fix = violation.fix.as_ref().unwrap();
let new_content = apply_single_fix_to_content(¤t_content, fix);
if new_content == current_content {
log::warn!("Fix did not change content, stopping to avoid infinite loop");
break;
}
current_content = new_content;
total_fixes_applied += 1;
log::debug!(
"Applied fix {} from rule '{}' at iteration {}",
total_fixes_applied,
violation.rule_id.as_deref().unwrap_or("unknown"),
iteration
);
}
if total_fixes_applied >= max_iterations {
log::warn!("Reached maximum iteration limit ({max_iterations})");
}
(current_content, total_fixes_applied)
}
fn apply_single_fix_to_content(content: &str, fix: &Fix) -> String {
let mut replacements = fix.replacements.clone();
if replacements.is_empty() {
return content.to_string();
}
replacements.sort_by_key(|b| Reverse(b.file_span().start));
let mut result = content.to_string();
for replacement in replacements {
let start = replacement.file_span().start;
let end = replacement.file_span().end;
if start > result.len() || end > result.len() || start > end {
log::warn!(
"Invalid replacement span: start={}, end={}, content_len={}",
start,
end,
result.len()
);
continue;
}
if !result.is_char_boundary(start) || !result.is_char_boundary(end) {
log::warn!("Replacement span not on UTF-8 boundary: start={start}, end={end}");
continue;
}
result.replace_range(start..end, &replacement.replacement_text);
}
result
}
fn group_violations_by_file(violations: &[Violation]) -> HashMap<PathBuf, Vec<&Violation>> {
let mut grouped: HashMap<PathBuf, Vec<&Violation>> = HashMap::new();
for violation in violations {
if let Some(file) = &violation.file
&& let Some(path) = file.as_path()
{
grouped
.entry(path.to_path_buf())
.or_default()
.push(violation);
}
}
grouped
}
fn apply_fixes_to_content(content: &str, violations: &[&Violation]) -> String {
let mut replacements = Vec::new();
for violation in violations {
if let Some(fix) = &violation.fix {
replacements.extend(fix.replacements.clone());
}
}
if replacements.is_empty() {
return content.to_string();
}
replacements.sort_by_key(|b| Reverse(b.file_span().start));
replacements.dedup_by(|a, b| {
a.file_span().start == b.file_span().start && a.file_span().end == b.file_span().end
});
let mut result = content.to_string();
let content_bytes = content.as_bytes();
for replacement in replacements {
let start = replacement.file_span().start;
let end = replacement.file_span().end;
if start > content_bytes.len() || end > content_bytes.len() || start > end {
log::warn!(
"Invalid replacement span: start={}, end={}, content_len={}",
start,
end,
content_bytes.len()
);
continue;
}
if !result.is_char_boundary(start) || !result.is_char_boundary(end) {
log::warn!("Replacement span not on UTF-8 boundary: start={start}, end={end}");
continue;
}
result.replace_range(start..end, &replacement.replacement_text);
}
result
}
#[must_use]
pub fn format_fix_results(results: &[FixResult], dry_run: bool) -> String {
let mut output = String::new();
if results.is_empty() {
output.push_str("No fixable violations found.\n");
return output;
}
if dry_run {
writeln!(
output,
"The following changes would be applied ({} file{}):\n",
results.len(),
if results.len() == 1 { "" } else { "s" }
)
.unwrap();
for result in results {
writeln!(output, "File: {}", result.file_path.display()).unwrap();
writeln!(output, "Fixes to apply: {}\n", result.fixes_applied).unwrap();
let diff = format_diff_context(&result.original_content, &result.fixed_content);
output.push_str(&diff);
output.push('\n');
}
} else {
writeln!(
output,
"Fixed {} file{}:\n",
results.len(),
if results.len() == 1 { "" } else { "s" }
)
.unwrap();
for result in results {
writeln!(
output,
" {} ({} fix{})",
result.file_path.display(),
result.fixes_applied,
if result.fixes_applied == 1 { "" } else { "es" }
)
.unwrap();
}
}
output
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use miette::Severity;
use nu_protocol::Span;
use super::*;
use crate::violation::{Fix, Replacement, SourceFile, Violation};
#[test]
fn test_apply_multiple_replacements() {
use crate::span::FileSpan;
let content = "let x = 5; let y = 10";
let replacements = vec![
Replacement::with_file_span(FileSpan::new(4, 5), "a"),
Replacement::with_file_span(FileSpan::new(15, 16), "b"),
];
let fix = Fix {
explanation: "Rename variables".into(),
replacements,
};
let violation = Violation {
rule_id: Some(Cow::Borrowed("test_rule")),
lint_level: Severity::Warning,
message: Cow::Borrowed("Test"),
span: FileSpan::new(0, 21).into(),
primary_label: None,
extra_labels: vec![],
long_description: None,
fix: Some(fix),
file: Some(SourceFile::from("test.nu")),
source: None,
doc_url: None,
short_description: None,
diagnostic_tags: vec![],
external_detections: vec![],
};
let fixed = apply_fixes_to_content(content, &[&violation]);
assert_eq!(fixed, "let a = 5; let b = 10");
}
#[test]
fn test_iterative_fixes_with_overlapping_spans() {
use crate::{config::Config, engine::LintEngine};
let content = "^evtest /dev/input/event0 err> /dev/null | lines\n";
let config = Config::default();
let engine = LintEngine::new(config);
let (fixed, count) = apply_fixes_iteratively(content, &engine);
assert!(count > 0, "Expected at least one fix to be applied");
assert!(
!fixed.contains("err> /dev/null"),
"Fixed content should not contain err> /dev/null"
);
assert!(
fixed.contains("evtest"),
"Fixed content should still contain command name"
);
assert!(
fixed.contains("lines"),
"Fixed content should still contain pipeline command"
);
}
#[test]
fn test_iterative_fixes_multiple_rules_same_line() {
use crate::{config::Config, engine::LintEngine};
let content = "^grep pattern file.txt err> /dev/null | lines\n";
let config = Config::default();
let engine = LintEngine::new(config);
let (fixed, count) = apply_fixes_iteratively(content, &engine);
assert!(count > 0, "Expected at least one fix to be applied");
assert!(!fixed.is_empty(), "Fixed content should not be empty");
assert!(
fixed.len() < 200,
"Fixed content should not be unreasonably long (corruption check)"
);
}
#[test]
fn test_iterative_fixes_converge() {
use crate::{config::Config, engine::LintEngine};
let content = "^curl https://example.com err> /dev/null | str trim\n";
let config = Config::default();
let engine = LintEngine::new(config);
let (fixed, count) = apply_fixes_iteratively(content, &engine);
assert!(
count < 50,
"Should converge within 50 iterations, got {count}"
);
let violations_after = engine.lint_str(&fixed);
let fixable_after = violations_after.iter().filter(|v| v.fix.is_some()).count();
assert_eq!(
fixable_after, 0,
"After applying all fixes, there should be no more fixable violations"
);
}
#[test]
fn test_iterative_fixes_preserve_utf8() {
use crate::{config::Config, engine::LintEngine};
let content = "^echo 测试 err> /dev/null | lines\n";
let config = Config::default();
let engine = LintEngine::new(config);
let (fixed, count) = apply_fixes_iteratively(content, &engine);
assert!(count > 0, "Expected at least one fix to be applied");
assert!(
fixed.contains("测试"),
"UTF-8 characters should be preserved"
);
assert!(
!fixed.contains("err> /dev/null"),
"Redirect should be removed"
);
assert!(
!fixed.is_empty() && fixed.chars().all(|c| !c.is_control() || c.is_whitespace()),
"Result should contain valid characters"
);
}
#[test]
fn test_count_applicable_fixes() {
let fix = Fix {
explanation: "Test fix".into(),
replacements: vec![],
};
let with_fix = Violation {
rule_id: Some(Cow::Borrowed("test_rule")),
lint_level: Severity::Warning,
message: Cow::Borrowed("Test"),
span: Span::new(0, 5).into(),
primary_label: None,
extra_labels: vec![],
long_description: None,
fix: Some(fix),
file: Some(SourceFile::from("test.nu")),
source: None,
doc_url: None,
short_description: None,
diagnostic_tags: vec![],
external_detections: vec![],
};
let without_fix = Violation {
rule_id: Some(Cow::Borrowed("test_rule")),
lint_level: Severity::Warning,
message: Cow::Borrowed("Test"),
span: Span::new(0, 5).into(),
primary_label: None,
extra_labels: vec![],
long_description: None,
fix: None,
file: Some(SourceFile::from("test.nu")),
source: None,
doc_url: None,
short_description: None,
diagnostic_tags: vec![],
external_detections: vec![],
};
let violations = [&with_fix, &without_fix, &with_fix];
let count = violations.iter().filter(|v| v.fix.is_some()).count();
assert_eq!(count, 2);
}
#[test]
fn test_group_violations_by_file() {
let v1 = Violation {
rule_id: Some(Cow::Borrowed("test_rule")),
lint_level: Severity::Warning,
message: Cow::Borrowed("Test"),
span: Span::new(0, 5).into(),
primary_label: None,
extra_labels: vec![],
long_description: None,
fix: None,
file: Some(SourceFile::from("file1.nu")),
source: None,
doc_url: None,
short_description: None,
diagnostic_tags: vec![],
external_detections: vec![],
};
let v2 = Violation {
rule_id: Some(Cow::Borrowed("test_rule")),
lint_level: Severity::Warning,
message: Cow::Borrowed("Test"),
span: Span::new(0, 5).into(),
primary_label: None,
extra_labels: vec![],
long_description: None,
fix: None,
file: Some(SourceFile::from("file2.nu")),
source: None,
doc_url: None,
short_description: None,
diagnostic_tags: vec![],
external_detections: vec![],
};
let v3 = Violation {
rule_id: Some(Cow::Borrowed("test_rule")),
lint_level: Severity::Warning,
message: Cow::Borrowed("Test"),
span: Span::new(5, 10).into(),
primary_label: None,
extra_labels: vec![],
long_description: None,
fix: None,
file: Some(SourceFile::from("file1.nu")),
source: None,
doc_url: None,
short_description: None,
diagnostic_tags: vec![],
external_detections: vec![],
};
let violations = vec![v1, v2, v3];
let grouped = group_violations_by_file(&violations);
assert_eq!(grouped.len(), 2);
assert_eq!(grouped[&PathBuf::from("file1.nu")].len(), 2);
assert_eq!(grouped[&PathBuf::from("file2.nu")].len(), 1);
}
}