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 pub conflicting_rules: Vec<String>,
31 pub conflict_cycle: Vec<String>,
35}
36
37fn hash_content(content: &str) -> u64 {
39 let mut hasher = DefaultHasher::new();
40 content.hash(&mut hasher);
41 hasher.finish()
42}
43
44pub struct FixCoordinator {
46 dependencies: HashMap<&'static str, Vec<&'static str>>,
48}
49
50impl Default for FixCoordinator {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl FixCoordinator {
57 pub fn new() -> Self {
58 let mut dependencies = HashMap::new();
59
60 dependencies.insert("MD064", vec!["MD010"]);
67
68 dependencies.insert("MD010", vec!["MD007", "MD005"]);
72
73 dependencies.insert("MD013", vec!["MD009", "MD012"]);
78
79 dependencies.insert("MD004", vec!["MD007"]);
82
83 dependencies.insert("MD022", vec!["MD012"]);
86 dependencies.insert("MD023", vec!["MD012"]);
87
88 dependencies.insert("MD070", vec!["MD040", "MD031"]);
92
93 Self { dependencies }
94 }
95
96 pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
98 let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
100
101 let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
103 for (prereq, dependents) in &self.dependencies {
104 for dependent in dependents {
105 reverse_deps.entry(dependent).or_default().insert(prereq);
106 }
107 }
108
109 let mut sorted = Vec::new();
111 let mut visited = HashSet::new();
112 let mut visiting = HashSet::new();
113
114 fn visit<'a>(
115 rule_name: &str,
116 rule_map: &HashMap<&str, &'a dyn Rule>,
117 reverse_deps: &HashMap<&str, HashSet<&str>>,
118 visited: &mut HashSet<String>,
119 visiting: &mut HashSet<String>,
120 sorted: &mut Vec<&'a dyn Rule>,
121 ) {
122 if visited.contains(rule_name) {
123 return;
124 }
125
126 if visiting.contains(rule_name) {
127 return;
129 }
130
131 visiting.insert(rule_name.to_string());
132
133 if let Some(deps) = reverse_deps.get(rule_name) {
135 for dep in deps {
136 if rule_map.contains_key(dep) {
137 visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
138 }
139 }
140 }
141
142 visiting.remove(rule_name);
143 visited.insert(rule_name.to_string());
144
145 if let Some(&rule) = rule_map.get(rule_name) {
147 sorted.push(rule);
148 }
149 }
150
151 for rule in rules {
153 visit(
154 rule.name(),
155 &rule_map,
156 &reverse_deps,
157 &mut visited,
158 &mut visiting,
159 &mut sorted,
160 );
161 }
162
163 for rule in rules {
165 if !sorted.iter().any(|r| r.name() == rule.name()) {
166 sorted.push(rule.as_ref());
167 }
168 }
169
170 sorted
171 }
172
173 pub fn apply_fixes_iterative(
181 &self,
182 rules: &[Box<dyn Rule>],
183 _all_warnings: &[LintWarning], content: &mut String,
185 config: &Config,
186 max_iterations: usize,
187 file_path: Option<&std::path::Path>,
188 ) -> Result<FixResult, String> {
189 let max_iterations = max_iterations.min(MAX_ITERATIONS);
191
192 let ordered_rules = self.get_optimal_order(rules);
194
195 let mut total_fixed = 0;
196 let mut total_ctx_creations = 0;
197 let mut iterations = 0;
198
199 let mut history: Vec<(u64, String)> = vec![(hash_content(content), String::new())];
202
203 let mut fixed_rule_names = HashSet::new();
205
206 let unfixable_rules: HashSet<&str> = config.global.unfixable.iter().map(|s| s.as_str()).collect();
208
209 let fixable_rules: HashSet<&str> = config.global.fixable.iter().map(|s| s.as_str()).collect();
211 let has_fixable_allowlist = !fixable_rules.is_empty();
212
213 while iterations < max_iterations {
215 iterations += 1;
216
217 let flavor = file_path
220 .map(|p| config.get_flavor_for_file(p))
221 .unwrap_or_else(|| config.markdown_flavor());
222 let ctx = LintContext::new(content, flavor, None);
223 total_ctx_creations += 1;
224
225 let mut any_fix_applied = false;
226 let mut this_iter_rule = String::new();
228
229 for rule in &ordered_rules {
231 if unfixable_rules.contains(rule.name()) {
233 continue;
234 }
235 if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
236 continue;
237 }
238
239 if rule.should_skip(&ctx) {
241 continue;
242 }
243
244 let warnings = match rule.check(&ctx) {
246 Ok(w) => w,
247 Err(_) => continue,
248 };
249
250 if warnings.is_empty() {
251 continue;
252 }
253
254 let has_fixable = warnings.iter().any(|w| w.fix.is_some());
256 if !has_fixable {
257 continue;
258 }
259
260 match rule.fix(&ctx) {
262 Ok(fixed_content) => {
263 if fixed_content != *content {
264 *content = fixed_content;
265 total_fixed += 1;
266 any_fix_applied = true;
267 this_iter_rule = rule.name().to_string();
268 fixed_rule_names.insert(rule.name().to_string());
269
270 break;
274 }
275 }
276 Err(_) => {
277 continue;
279 }
280 }
281 }
282
283 let current_hash = hash_content(content);
284
285 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
287 if cycle_start == history.len() - 1 {
288 return Ok(FixResult {
290 rules_fixed: total_fixed,
291 iterations,
292 context_creations: total_ctx_creations,
293 fixed_rule_names,
294 converged: true,
295 conflicting_rules: Vec::new(),
296 conflict_cycle: Vec::new(),
297 });
298 } else {
299 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
302 .iter()
303 .map(|(_, r)| r.clone())
304 .chain(std::iter::once(this_iter_rule.clone()))
305 .filter(|r| !r.is_empty())
306 .collect();
307 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
308 .iter()
309 .map(|(_, r)| r.clone())
310 .chain(std::iter::once(this_iter_rule))
311 .filter(|r| !r.is_empty())
312 .collect::<HashSet<_>>()
313 .into_iter()
314 .collect();
315 return Ok(FixResult {
316 rules_fixed: total_fixed,
317 iterations,
318 context_creations: total_ctx_creations,
319 fixed_rule_names,
320 converged: false,
321 conflicting_rules,
322 conflict_cycle,
323 });
324 }
325 }
326
327 history.push((current_hash, this_iter_rule));
329
330 if !any_fix_applied {
332 return Ok(FixResult {
333 rules_fixed: total_fixed,
334 iterations,
335 context_creations: total_ctx_creations,
336 fixed_rule_names,
337 converged: true,
338 conflicting_rules: Vec::new(),
339 conflict_cycle: Vec::new(),
340 });
341 }
342 }
343
344 Ok(FixResult {
346 rules_fixed: total_fixed,
347 iterations,
348 context_creations: total_ctx_creations,
349 fixed_rule_names,
350 converged: false,
351 conflicting_rules: Vec::new(),
352 conflict_cycle: Vec::new(),
353 })
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
361 use std::sync::atomic::{AtomicUsize, Ordering};
362
363 #[derive(Clone)]
365 struct ConditionalFixRule {
366 name: &'static str,
367 check_fn: fn(&str) -> bool,
369 fix_fn: fn(&str) -> String,
371 }
372
373 impl Rule for ConditionalFixRule {
374 fn name(&self) -> &'static str {
375 self.name
376 }
377
378 fn check(&self, ctx: &LintContext) -> LintResult {
379 if (self.check_fn)(ctx.content) {
380 Ok(vec![LintWarning {
381 line: 1,
382 column: 1,
383 end_line: 1,
384 end_column: 1,
385 message: format!("{} issue found", self.name),
386 rule_name: Some(self.name.to_string()),
387 severity: Severity::Error,
388 fix: Some(Fix {
389 range: 0..0,
390 replacement: String::new(),
391 }),
392 }])
393 } else {
394 Ok(vec![])
395 }
396 }
397
398 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
399 Ok((self.fix_fn)(ctx.content))
400 }
401
402 fn description(&self) -> &'static str {
403 "Conditional fix rule for testing"
404 }
405
406 fn category(&self) -> RuleCategory {
407 RuleCategory::Whitespace
408 }
409
410 fn as_any(&self) -> &dyn std::any::Any {
411 self
412 }
413 }
414
415 #[derive(Clone)]
417 struct MockRule {
418 name: &'static str,
419 warnings: Vec<LintWarning>,
420 fix_content: String,
421 }
422
423 impl Rule for MockRule {
424 fn name(&self) -> &'static str {
425 self.name
426 }
427
428 fn check(&self, _ctx: &LintContext) -> LintResult {
429 Ok(self.warnings.clone())
430 }
431
432 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
433 Ok(self.fix_content.clone())
434 }
435
436 fn description(&self) -> &'static str {
437 "Mock rule for testing"
438 }
439
440 fn category(&self) -> RuleCategory {
441 RuleCategory::Whitespace
442 }
443
444 fn as_any(&self) -> &dyn std::any::Any {
445 self
446 }
447 }
448
449 #[test]
450 fn test_dependency_ordering() {
451 let coordinator = FixCoordinator::new();
452
453 let rules: Vec<Box<dyn Rule>> = vec![
454 Box::new(MockRule {
455 name: "MD009",
456 warnings: vec![],
457 fix_content: "".to_string(),
458 }),
459 Box::new(MockRule {
460 name: "MD013",
461 warnings: vec![],
462 fix_content: "".to_string(),
463 }),
464 Box::new(MockRule {
465 name: "MD010",
466 warnings: vec![],
467 fix_content: "".to_string(),
468 }),
469 Box::new(MockRule {
470 name: "MD007",
471 warnings: vec![],
472 fix_content: "".to_string(),
473 }),
474 ];
475
476 let ordered = coordinator.get_optimal_order(&rules);
477 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
478
479 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
481 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
482 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
483
484 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
486 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
487 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
488 }
489
490 #[test]
491 fn test_single_rule_fix() {
492 let coordinator = FixCoordinator::new();
493
494 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
496 name: "RemoveBad",
497 check_fn: |content| content.contains("BAD"),
498 fix_fn: |content| content.replace("BAD", "GOOD"),
499 })];
500
501 let mut content = "This is BAD content".to_string();
502 let config = Config::default();
503
504 let result = coordinator
505 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
506 .unwrap();
507
508 assert_eq!(content, "This is GOOD content");
509 assert_eq!(result.rules_fixed, 1);
510 assert!(result.converged);
511 }
512
513 #[test]
514 fn test_cascading_fixes() {
515 let coordinator = FixCoordinator::new();
519
520 let rules: Vec<Box<dyn Rule>> = vec![
521 Box::new(ConditionalFixRule {
522 name: "Rule1_IndentToFence",
523 check_fn: |content| content.contains("INDENT"),
524 fix_fn: |content| content.replace("INDENT", "FENCE"),
525 }),
526 Box::new(ConditionalFixRule {
527 name: "Rule2_FenceToLang",
528 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
529 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
530 }),
531 ];
532
533 let mut content = "Code: INDENT".to_string();
534 let config = Config::default();
535
536 let result = coordinator
537 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
538 .unwrap();
539
540 assert_eq!(content, "Code: FENCE_LANG");
542 assert_eq!(result.rules_fixed, 2);
543 assert!(result.converged);
544 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
545 }
546
547 #[test]
548 fn test_indirect_cascade() {
549 let coordinator = FixCoordinator::new();
554
555 let rules: Vec<Box<dyn Rule>> = vec![
556 Box::new(ConditionalFixRule {
557 name: "Rule1_AddBlank",
558 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
559 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
560 }),
561 Box::new(ConditionalFixRule {
562 name: "Rule2_CodeToFence",
563 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
565 fix_fn: |content| content.replace("CODE", "FENCE"),
566 }),
567 Box::new(ConditionalFixRule {
568 name: "Rule3_AddLang",
569 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
570 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
571 }),
572 ];
573
574 let mut content = "HEADING CODE".to_string();
575 let config = Config::default();
576
577 let result = coordinator
578 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
579 .unwrap();
580
581 assert_eq!(content, "HEADING BLANK FENCE_LANG");
583 assert_eq!(result.rules_fixed, 3);
584 assert!(result.converged);
585 }
586
587 #[test]
588 fn test_unfixable_rules_skipped() {
589 let coordinator = FixCoordinator::new();
590
591 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
592 name: "MD001",
593 check_fn: |content| content.contains("BAD"),
594 fix_fn: |content| content.replace("BAD", "GOOD"),
595 })];
596
597 let mut content = "BAD content".to_string();
598 let mut config = Config::default();
599 config.global.unfixable = vec!["MD001".to_string()];
600
601 let result = coordinator
602 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
603 .unwrap();
604
605 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
607 assert!(result.converged);
608 }
609
610 #[test]
611 fn test_fixable_allowlist() {
612 let coordinator = FixCoordinator::new();
613
614 let rules: Vec<Box<dyn Rule>> = vec![
615 Box::new(ConditionalFixRule {
616 name: "AllowedRule",
617 check_fn: |content| content.contains("A"),
618 fix_fn: |content| content.replace("A", "X"),
619 }),
620 Box::new(ConditionalFixRule {
621 name: "NotAllowedRule",
622 check_fn: |content| content.contains("B"),
623 fix_fn: |content| content.replace("B", "Y"),
624 }),
625 ];
626
627 let mut content = "AB".to_string();
628 let mut config = Config::default();
629 config.global.fixable = vec!["AllowedRule".to_string()];
630
631 let result = coordinator
632 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
633 .unwrap();
634
635 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
637 }
638
639 #[test]
640 fn test_max_iterations_limit() {
641 let coordinator = FixCoordinator::new();
642
643 static COUNTER: AtomicUsize = AtomicUsize::new(0);
645
646 #[derive(Clone)]
647 struct AlwaysChangeRule;
648 impl Rule for AlwaysChangeRule {
649 fn name(&self) -> &'static str {
650 "AlwaysChange"
651 }
652 fn check(&self, _: &LintContext) -> LintResult {
653 Ok(vec![LintWarning {
654 line: 1,
655 column: 1,
656 end_line: 1,
657 end_column: 1,
658 message: "Always".to_string(),
659 rule_name: Some("AlwaysChange".to_string()),
660 severity: Severity::Error,
661 fix: Some(Fix {
662 range: 0..0,
663 replacement: String::new(),
664 }),
665 }])
666 }
667 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
668 COUNTER.fetch_add(1, Ordering::SeqCst);
669 Ok(format!("{}x", ctx.content))
670 }
671 fn description(&self) -> &'static str {
672 "Always changes"
673 }
674 fn category(&self) -> RuleCategory {
675 RuleCategory::Whitespace
676 }
677 fn as_any(&self) -> &dyn std::any::Any {
678 self
679 }
680 }
681
682 COUNTER.store(0, Ordering::SeqCst);
683 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
684
685 let mut content = "test".to_string();
686 let config = Config::default();
687
688 let result = coordinator
689 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
690 .unwrap();
691
692 assert_eq!(result.iterations, 5);
694 assert!(!result.converged);
695 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
696 }
697
698 #[test]
699 fn test_empty_rules() {
700 let coordinator = FixCoordinator::new();
701 let rules: Vec<Box<dyn Rule>> = vec![];
702
703 let mut content = "unchanged".to_string();
704 let config = Config::default();
705
706 let result = coordinator
707 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
708 .unwrap();
709
710 assert_eq!(result.rules_fixed, 0);
711 assert_eq!(result.iterations, 1);
712 assert!(result.converged);
713 assert_eq!(content, "unchanged");
714 }
715
716 #[test]
717 fn test_no_warnings_no_changes() {
718 let coordinator = FixCoordinator::new();
719
720 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
722 name: "NoIssues",
723 check_fn: |_| false, fix_fn: |content| content.to_string(),
725 })];
726
727 let mut content = "clean content".to_string();
728 let config = Config::default();
729
730 let result = coordinator
731 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
732 .unwrap();
733
734 assert_eq!(content, "clean content");
735 assert_eq!(result.rules_fixed, 0);
736 assert!(result.converged);
737 }
738
739 #[test]
740 fn test_oscillation_detection() {
741 let coordinator = FixCoordinator::new();
745
746 let rules: Vec<Box<dyn Rule>> = vec![
747 Box::new(ConditionalFixRule {
748 name: "RuleA",
749 check_fn: |content| content.contains("foo"),
750 fix_fn: |content| content.replace("foo", "bar"),
751 }),
752 Box::new(ConditionalFixRule {
753 name: "RuleB",
754 check_fn: |content| content.contains("bar"),
755 fix_fn: |content| content.replace("bar", "foo"),
756 }),
757 ];
758
759 let mut content = "foo".to_string();
760 let config = Config::default();
761
762 let result = coordinator
763 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
764 .unwrap();
765
766 assert!(!result.converged, "Should not converge in an oscillating pair");
768 assert!(
769 result.iterations < 10,
770 "Cycle detection should stop well before max_iterations (got {})",
771 result.iterations
772 );
773
774 let mut conflicting = result.conflicting_rules.clone();
776 conflicting.sort();
777 assert_eq!(
778 conflicting,
779 vec!["RuleA".to_string(), "RuleB".to_string()],
780 "Both oscillating rules must be reported"
781 );
782 assert_eq!(
783 result.conflict_cycle,
784 vec!["RuleA".to_string(), "RuleB".to_string()],
785 "Cycle should preserve the observed application order"
786 );
787 }
788
789 #[test]
790 fn test_cyclic_dependencies_handled() {
791 let mut coordinator = FixCoordinator::new();
792
793 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
795 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
796 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
797
798 let rules: Vec<Box<dyn Rule>> = vec![
799 Box::new(MockRule {
800 name: "RuleA",
801 warnings: vec![],
802 fix_content: "".to_string(),
803 }),
804 Box::new(MockRule {
805 name: "RuleB",
806 warnings: vec![],
807 fix_content: "".to_string(),
808 }),
809 Box::new(MockRule {
810 name: "RuleC",
811 warnings: vec![],
812 fix_content: "".to_string(),
813 }),
814 ];
815
816 let ordered = coordinator.get_optimal_order(&rules);
818
819 assert_eq!(ordered.len(), 3);
821 }
822
823 #[test]
824 fn test_fix_is_idempotent() {
825 let coordinator = FixCoordinator::new();
827
828 let rules: Vec<Box<dyn Rule>> = vec![
829 Box::new(ConditionalFixRule {
830 name: "Rule1",
831 check_fn: |content| content.contains("A"),
832 fix_fn: |content| content.replace("A", "B"),
833 }),
834 Box::new(ConditionalFixRule {
835 name: "Rule2",
836 check_fn: |content| content.contains("B") && !content.contains("C"),
837 fix_fn: |content| content.replace("B", "BC"),
838 }),
839 ];
840
841 let config = Config::default();
842
843 let mut content1 = "A".to_string();
845 let result1 = coordinator
846 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
847 .unwrap();
848
849 let mut content2 = content1.clone();
851 let result2 = coordinator
852 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
853 .unwrap();
854
855 assert_eq!(content1, content2);
857 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
858 assert!(result1.converged);
859 assert!(result2.converged);
860 }
861}