1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{LintWarning, Rule};
4use std::collections::hash_map::DefaultHasher;
5use std::collections::{HashMap, HashSet};
6use std::hash::{Hash, Hasher};
7
8const MAX_ITERATIONS: usize = 100;
10
11#[derive(Debug, Clone)]
16pub struct FixResult {
17 pub rules_fixed: usize,
19 pub iterations: usize,
21 pub context_creations: usize,
23 pub fixed_rule_names: HashSet<String>,
25 pub converged: bool,
27}
28
29fn hash_content(content: &str) -> u64 {
31 let mut hasher = DefaultHasher::new();
32 content.hash(&mut hasher);
33 hasher.finish()
34}
35
36pub struct FixCoordinator {
38 dependencies: HashMap<&'static str, Vec<&'static str>>,
40}
41
42impl Default for FixCoordinator {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl FixCoordinator {
49 pub fn new() -> Self {
50 let mut dependencies = HashMap::new();
51
52 dependencies.insert("MD064", vec!["MD010"]);
59
60 dependencies.insert("MD010", vec!["MD007", "MD005"]);
64
65 dependencies.insert("MD013", vec!["MD009", "MD012"]);
70
71 dependencies.insert("MD004", vec!["MD007"]);
74
75 dependencies.insert("MD022", vec!["MD012"]);
78 dependencies.insert("MD023", vec!["MD012"]);
79
80 dependencies.insert("MD070", vec!["MD040", "MD031"]);
84
85 Self { dependencies }
86 }
87
88 pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
90 let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
92
93 let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
95 for (prereq, dependents) in &self.dependencies {
96 for dependent in dependents {
97 reverse_deps.entry(dependent).or_default().insert(prereq);
98 }
99 }
100
101 let mut sorted = Vec::new();
103 let mut visited = HashSet::new();
104 let mut visiting = HashSet::new();
105
106 fn visit<'a>(
107 rule_name: &str,
108 rule_map: &HashMap<&str, &'a dyn Rule>,
109 reverse_deps: &HashMap<&str, HashSet<&str>>,
110 visited: &mut HashSet<String>,
111 visiting: &mut HashSet<String>,
112 sorted: &mut Vec<&'a dyn Rule>,
113 ) {
114 if visited.contains(rule_name) {
115 return;
116 }
117
118 if visiting.contains(rule_name) {
119 return;
121 }
122
123 visiting.insert(rule_name.to_string());
124
125 if let Some(deps) = reverse_deps.get(rule_name) {
127 for dep in deps {
128 if rule_map.contains_key(dep) {
129 visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
130 }
131 }
132 }
133
134 visiting.remove(rule_name);
135 visited.insert(rule_name.to_string());
136
137 if let Some(&rule) = rule_map.get(rule_name) {
139 sorted.push(rule);
140 }
141 }
142
143 for rule in rules {
145 visit(
146 rule.name(),
147 &rule_map,
148 &reverse_deps,
149 &mut visited,
150 &mut visiting,
151 &mut sorted,
152 );
153 }
154
155 for rule in rules {
157 if !sorted.iter().any(|r| r.name() == rule.name()) {
158 sorted.push(rule.as_ref());
159 }
160 }
161
162 sorted
163 }
164
165 pub fn apply_fixes_iterative(
173 &self,
174 rules: &[Box<dyn Rule>],
175 _all_warnings: &[LintWarning], content: &mut String,
177 config: &Config,
178 max_iterations: usize,
179 file_path: Option<&std::path::Path>,
180 ) -> Result<FixResult, String> {
181 let max_iterations = max_iterations.min(MAX_ITERATIONS);
183
184 let ordered_rules = self.get_optimal_order(rules);
186
187 let mut total_fixed = 0;
188 let mut total_ctx_creations = 0;
189 let mut iterations = 0;
190 let mut previous_hash = hash_content(content);
191
192 let mut fixed_rule_names = HashSet::new();
194
195 let unfixable_rules: HashSet<&str> = config.global.unfixable.iter().map(|s| s.as_str()).collect();
197
198 let fixable_rules: HashSet<&str> = config.global.fixable.iter().map(|s| s.as_str()).collect();
200 let has_fixable_allowlist = !fixable_rules.is_empty();
201
202 while iterations < max_iterations {
204 iterations += 1;
205
206 let flavor = file_path
209 .map(|p| config.get_flavor_for_file(p))
210 .unwrap_or_else(|| config.markdown_flavor());
211 let ctx = LintContext::new(content, flavor, None);
212 total_ctx_creations += 1;
213
214 let mut any_fix_applied = false;
215
216 for rule in &ordered_rules {
218 if unfixable_rules.contains(rule.name()) {
220 continue;
221 }
222 if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
223 continue;
224 }
225
226 if rule.should_skip(&ctx) {
228 continue;
229 }
230
231 let warnings = match rule.check(&ctx) {
233 Ok(w) => w,
234 Err(_) => continue,
235 };
236
237 if warnings.is_empty() {
238 continue;
239 }
240
241 let has_fixable = warnings.iter().any(|w| w.fix.is_some());
243 if !has_fixable {
244 continue;
245 }
246
247 match rule.fix(&ctx) {
249 Ok(fixed_content) => {
250 if fixed_content != *content {
251 *content = fixed_content;
252 total_fixed += 1;
253 any_fix_applied = true;
254 fixed_rule_names.insert(rule.name().to_string());
255
256 break;
260 }
261 }
262 Err(_) => {
263 continue;
265 }
266 }
267 }
268
269 let current_hash = hash_content(content);
271 if current_hash == previous_hash {
272 return Ok(FixResult {
274 rules_fixed: total_fixed,
275 iterations,
276 context_creations: total_ctx_creations,
277 fixed_rule_names,
278 converged: true,
279 });
280 }
281 previous_hash = current_hash;
282
283 if !any_fix_applied {
285 return Ok(FixResult {
286 rules_fixed: total_fixed,
287 iterations,
288 context_creations: total_ctx_creations,
289 fixed_rule_names,
290 converged: true,
291 });
292 }
293 }
294
295 Ok(FixResult {
297 rules_fixed: total_fixed,
298 iterations,
299 context_creations: total_ctx_creations,
300 fixed_rule_names,
301 converged: false,
302 })
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
310 use std::sync::atomic::{AtomicUsize, Ordering};
311
312 #[derive(Clone)]
314 struct ConditionalFixRule {
315 name: &'static str,
316 check_fn: fn(&str) -> bool,
318 fix_fn: fn(&str) -> String,
320 }
321
322 impl Rule for ConditionalFixRule {
323 fn name(&self) -> &'static str {
324 self.name
325 }
326
327 fn check(&self, ctx: &LintContext) -> LintResult {
328 if (self.check_fn)(ctx.content) {
329 Ok(vec![LintWarning {
330 line: 1,
331 column: 1,
332 end_line: 1,
333 end_column: 1,
334 message: format!("{} issue found", self.name),
335 rule_name: Some(self.name.to_string()),
336 severity: Severity::Error,
337 fix: Some(Fix {
338 range: 0..0,
339 replacement: String::new(),
340 }),
341 }])
342 } else {
343 Ok(vec![])
344 }
345 }
346
347 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
348 Ok((self.fix_fn)(ctx.content))
349 }
350
351 fn description(&self) -> &'static str {
352 "Conditional fix rule for testing"
353 }
354
355 fn category(&self) -> RuleCategory {
356 RuleCategory::Whitespace
357 }
358
359 fn as_any(&self) -> &dyn std::any::Any {
360 self
361 }
362 }
363
364 #[derive(Clone)]
366 struct MockRule {
367 name: &'static str,
368 warnings: Vec<LintWarning>,
369 fix_content: String,
370 }
371
372 impl Rule for MockRule {
373 fn name(&self) -> &'static str {
374 self.name
375 }
376
377 fn check(&self, _ctx: &LintContext) -> LintResult {
378 Ok(self.warnings.clone())
379 }
380
381 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
382 Ok(self.fix_content.clone())
383 }
384
385 fn description(&self) -> &'static str {
386 "Mock rule for testing"
387 }
388
389 fn category(&self) -> RuleCategory {
390 RuleCategory::Whitespace
391 }
392
393 fn as_any(&self) -> &dyn std::any::Any {
394 self
395 }
396 }
397
398 #[test]
399 fn test_dependency_ordering() {
400 let coordinator = FixCoordinator::new();
401
402 let rules: Vec<Box<dyn Rule>> = vec![
403 Box::new(MockRule {
404 name: "MD009",
405 warnings: vec![],
406 fix_content: "".to_string(),
407 }),
408 Box::new(MockRule {
409 name: "MD013",
410 warnings: vec![],
411 fix_content: "".to_string(),
412 }),
413 Box::new(MockRule {
414 name: "MD010",
415 warnings: vec![],
416 fix_content: "".to_string(),
417 }),
418 Box::new(MockRule {
419 name: "MD007",
420 warnings: vec![],
421 fix_content: "".to_string(),
422 }),
423 ];
424
425 let ordered = coordinator.get_optimal_order(&rules);
426 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
427
428 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
430 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
431 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
432
433 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
435 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
436 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
437 }
438
439 #[test]
440 fn test_single_rule_fix() {
441 let coordinator = FixCoordinator::new();
442
443 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
445 name: "RemoveBad",
446 check_fn: |content| content.contains("BAD"),
447 fix_fn: |content| content.replace("BAD", "GOOD"),
448 })];
449
450 let mut content = "This is BAD content".to_string();
451 let config = Config::default();
452
453 let result = coordinator
454 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
455 .unwrap();
456
457 assert_eq!(content, "This is GOOD content");
458 assert_eq!(result.rules_fixed, 1);
459 assert!(result.converged);
460 }
461
462 #[test]
463 fn test_cascading_fixes() {
464 let coordinator = FixCoordinator::new();
468
469 let rules: Vec<Box<dyn Rule>> = vec![
470 Box::new(ConditionalFixRule {
471 name: "Rule1_IndentToFence",
472 check_fn: |content| content.contains("INDENT"),
473 fix_fn: |content| content.replace("INDENT", "FENCE"),
474 }),
475 Box::new(ConditionalFixRule {
476 name: "Rule2_FenceToLang",
477 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
478 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
479 }),
480 ];
481
482 let mut content = "Code: INDENT".to_string();
483 let config = Config::default();
484
485 let result = coordinator
486 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
487 .unwrap();
488
489 assert_eq!(content, "Code: FENCE_LANG");
491 assert_eq!(result.rules_fixed, 2);
492 assert!(result.converged);
493 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
494 }
495
496 #[test]
497 fn test_indirect_cascade() {
498 let coordinator = FixCoordinator::new();
503
504 let rules: Vec<Box<dyn Rule>> = vec![
505 Box::new(ConditionalFixRule {
506 name: "Rule1_AddBlank",
507 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
508 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
509 }),
510 Box::new(ConditionalFixRule {
511 name: "Rule2_CodeToFence",
512 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
514 fix_fn: |content| content.replace("CODE", "FENCE"),
515 }),
516 Box::new(ConditionalFixRule {
517 name: "Rule3_AddLang",
518 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
519 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
520 }),
521 ];
522
523 let mut content = "HEADING CODE".to_string();
524 let config = Config::default();
525
526 let result = coordinator
527 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
528 .unwrap();
529
530 assert_eq!(content, "HEADING BLANK FENCE_LANG");
532 assert_eq!(result.rules_fixed, 3);
533 assert!(result.converged);
534 }
535
536 #[test]
537 fn test_unfixable_rules_skipped() {
538 let coordinator = FixCoordinator::new();
539
540 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
541 name: "MD001",
542 check_fn: |content| content.contains("BAD"),
543 fix_fn: |content| content.replace("BAD", "GOOD"),
544 })];
545
546 let mut content = "BAD content".to_string();
547 let mut config = Config::default();
548 config.global.unfixable = vec!["MD001".to_string()];
549
550 let result = coordinator
551 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
552 .unwrap();
553
554 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
556 assert!(result.converged);
557 }
558
559 #[test]
560 fn test_fixable_allowlist() {
561 let coordinator = FixCoordinator::new();
562
563 let rules: Vec<Box<dyn Rule>> = vec![
564 Box::new(ConditionalFixRule {
565 name: "AllowedRule",
566 check_fn: |content| content.contains("A"),
567 fix_fn: |content| content.replace("A", "X"),
568 }),
569 Box::new(ConditionalFixRule {
570 name: "NotAllowedRule",
571 check_fn: |content| content.contains("B"),
572 fix_fn: |content| content.replace("B", "Y"),
573 }),
574 ];
575
576 let mut content = "AB".to_string();
577 let mut config = Config::default();
578 config.global.fixable = vec!["AllowedRule".to_string()];
579
580 let result = coordinator
581 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
582 .unwrap();
583
584 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
586 }
587
588 #[test]
589 fn test_max_iterations_limit() {
590 let coordinator = FixCoordinator::new();
591
592 static COUNTER: AtomicUsize = AtomicUsize::new(0);
594
595 #[derive(Clone)]
596 struct AlwaysChangeRule;
597 impl Rule for AlwaysChangeRule {
598 fn name(&self) -> &'static str {
599 "AlwaysChange"
600 }
601 fn check(&self, _: &LintContext) -> LintResult {
602 Ok(vec![LintWarning {
603 line: 1,
604 column: 1,
605 end_line: 1,
606 end_column: 1,
607 message: "Always".to_string(),
608 rule_name: Some("AlwaysChange".to_string()),
609 severity: Severity::Error,
610 fix: Some(Fix {
611 range: 0..0,
612 replacement: String::new(),
613 }),
614 }])
615 }
616 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
617 COUNTER.fetch_add(1, Ordering::SeqCst);
618 Ok(format!("{}x", ctx.content))
619 }
620 fn description(&self) -> &'static str {
621 "Always changes"
622 }
623 fn category(&self) -> RuleCategory {
624 RuleCategory::Whitespace
625 }
626 fn as_any(&self) -> &dyn std::any::Any {
627 self
628 }
629 }
630
631 COUNTER.store(0, Ordering::SeqCst);
632 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
633
634 let mut content = "test".to_string();
635 let config = Config::default();
636
637 let result = coordinator
638 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
639 .unwrap();
640
641 assert_eq!(result.iterations, 5);
643 assert!(!result.converged);
644 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
645 }
646
647 #[test]
648 fn test_empty_rules() {
649 let coordinator = FixCoordinator::new();
650 let rules: Vec<Box<dyn Rule>> = vec![];
651
652 let mut content = "unchanged".to_string();
653 let config = Config::default();
654
655 let result = coordinator
656 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
657 .unwrap();
658
659 assert_eq!(result.rules_fixed, 0);
660 assert_eq!(result.iterations, 1);
661 assert!(result.converged);
662 assert_eq!(content, "unchanged");
663 }
664
665 #[test]
666 fn test_no_warnings_no_changes() {
667 let coordinator = FixCoordinator::new();
668
669 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
671 name: "NoIssues",
672 check_fn: |_| false, fix_fn: |content| content.to_string(),
674 })];
675
676 let mut content = "clean content".to_string();
677 let config = Config::default();
678
679 let result = coordinator
680 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
681 .unwrap();
682
683 assert_eq!(content, "clean content");
684 assert_eq!(result.rules_fixed, 0);
685 assert!(result.converged);
686 }
687
688 #[test]
689 fn test_cyclic_dependencies_handled() {
690 let mut coordinator = FixCoordinator::new();
691
692 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
694 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
695 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
696
697 let rules: Vec<Box<dyn Rule>> = vec![
698 Box::new(MockRule {
699 name: "RuleA",
700 warnings: vec![],
701 fix_content: "".to_string(),
702 }),
703 Box::new(MockRule {
704 name: "RuleB",
705 warnings: vec![],
706 fix_content: "".to_string(),
707 }),
708 Box::new(MockRule {
709 name: "RuleC",
710 warnings: vec![],
711 fix_content: "".to_string(),
712 }),
713 ];
714
715 let ordered = coordinator.get_optimal_order(&rules);
717
718 assert_eq!(ordered.len(), 3);
720 }
721
722 #[test]
723 fn test_fix_is_idempotent() {
724 let coordinator = FixCoordinator::new();
726
727 let rules: Vec<Box<dyn Rule>> = vec![
728 Box::new(ConditionalFixRule {
729 name: "Rule1",
730 check_fn: |content| content.contains("A"),
731 fix_fn: |content| content.replace("A", "B"),
732 }),
733 Box::new(ConditionalFixRule {
734 name: "Rule2",
735 check_fn: |content| content.contains("B") && !content.contains("C"),
736 fix_fn: |content| content.replace("B", "BC"),
737 }),
738 ];
739
740 let config = Config::default();
741
742 let mut content1 = "A".to_string();
744 let result1 = coordinator
745 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
746 .unwrap();
747
748 let mut content2 = content1.clone();
750 let result2 = coordinator
751 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
752 .unwrap();
753
754 assert_eq!(content1, content2);
756 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
757 assert!(result1.converged);
758 assert!(result2.converged);
759 }
760}