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(
170 &self,
171 rules: &[Box<dyn Rule>],
172 _all_warnings: &[LintWarning], content: &mut String,
174 config: &Config,
175 max_iterations: usize,
176 ) -> Result<FixResult, String> {
177 let max_iterations = max_iterations.min(MAX_ITERATIONS);
179
180 let ordered_rules = self.get_optimal_order(rules);
182
183 let mut total_fixed = 0;
184 let mut total_ctx_creations = 0;
185 let mut iterations = 0;
186 let mut previous_hash = hash_content(content);
187
188 let mut fixed_rule_names = HashSet::new();
190
191 let unfixable_rules: HashSet<&str> = config.global.unfixable.iter().map(|s| s.as_str()).collect();
193
194 let fixable_rules: HashSet<&str> = config.global.fixable.iter().map(|s| s.as_str()).collect();
196 let has_fixable_allowlist = !fixable_rules.is_empty();
197
198 while iterations < max_iterations {
200 iterations += 1;
201
202 let ctx = LintContext::new(content, config.markdown_flavor(), None);
204 total_ctx_creations += 1;
205
206 let mut any_fix_applied = false;
207
208 for rule in &ordered_rules {
210 if unfixable_rules.contains(rule.name()) {
212 continue;
213 }
214 if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
215 continue;
216 }
217
218 let warnings = match rule.check(&ctx) {
220 Ok(w) => w,
221 Err(_) => continue,
222 };
223
224 if warnings.is_empty() {
225 continue;
226 }
227
228 let has_fixable = warnings.iter().any(|w| w.fix.is_some());
230 if !has_fixable {
231 continue;
232 }
233
234 match rule.fix(&ctx) {
236 Ok(fixed_content) => {
237 if fixed_content != *content {
238 *content = fixed_content;
239 total_fixed += 1;
240 any_fix_applied = true;
241 fixed_rule_names.insert(rule.name().to_string());
242
243 break;
247 }
248 }
249 Err(_) => {
250 continue;
252 }
253 }
254 }
255
256 let current_hash = hash_content(content);
258 if current_hash == previous_hash {
259 return Ok(FixResult {
261 rules_fixed: total_fixed,
262 iterations,
263 context_creations: total_ctx_creations,
264 fixed_rule_names,
265 converged: true,
266 });
267 }
268 previous_hash = current_hash;
269
270 if !any_fix_applied {
272 return Ok(FixResult {
273 rules_fixed: total_fixed,
274 iterations,
275 context_creations: total_ctx_creations,
276 fixed_rule_names,
277 converged: true,
278 });
279 }
280 }
281
282 Ok(FixResult {
284 rules_fixed: total_fixed,
285 iterations,
286 context_creations: total_ctx_creations,
287 fixed_rule_names,
288 converged: false,
289 })
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::config::GlobalConfig;
297 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
298 use indexmap::IndexMap;
299 use std::sync::atomic::{AtomicUsize, Ordering};
300
301 #[derive(Clone)]
303 struct ConditionalFixRule {
304 name: &'static str,
305 check_fn: fn(&str) -> bool,
307 fix_fn: fn(&str) -> String,
309 }
310
311 impl Rule for ConditionalFixRule {
312 fn name(&self) -> &'static str {
313 self.name
314 }
315
316 fn check(&self, ctx: &LintContext) -> LintResult {
317 if (self.check_fn)(ctx.content) {
318 Ok(vec![LintWarning {
319 line: 1,
320 column: 1,
321 end_line: 1,
322 end_column: 1,
323 message: format!("{} issue found", self.name),
324 rule_name: Some(self.name.to_string()),
325 severity: Severity::Error,
326 fix: Some(Fix {
327 range: 0..0,
328 replacement: String::new(),
329 }),
330 }])
331 } else {
332 Ok(vec![])
333 }
334 }
335
336 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
337 Ok((self.fix_fn)(ctx.content))
338 }
339
340 fn description(&self) -> &'static str {
341 "Conditional fix rule for testing"
342 }
343
344 fn category(&self) -> RuleCategory {
345 RuleCategory::Whitespace
346 }
347
348 fn as_any(&self) -> &dyn std::any::Any {
349 self
350 }
351 }
352
353 #[derive(Clone)]
355 struct MockRule {
356 name: &'static str,
357 warnings: Vec<LintWarning>,
358 fix_content: String,
359 }
360
361 impl Rule for MockRule {
362 fn name(&self) -> &'static str {
363 self.name
364 }
365
366 fn check(&self, _ctx: &LintContext) -> LintResult {
367 Ok(self.warnings.clone())
368 }
369
370 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
371 Ok(self.fix_content.clone())
372 }
373
374 fn description(&self) -> &'static str {
375 "Mock rule for testing"
376 }
377
378 fn category(&self) -> RuleCategory {
379 RuleCategory::Whitespace
380 }
381
382 fn as_any(&self) -> &dyn std::any::Any {
383 self
384 }
385 }
386
387 #[test]
388 fn test_dependency_ordering() {
389 let coordinator = FixCoordinator::new();
390
391 let rules: Vec<Box<dyn Rule>> = vec![
392 Box::new(MockRule {
393 name: "MD009",
394 warnings: vec![],
395 fix_content: "".to_string(),
396 }),
397 Box::new(MockRule {
398 name: "MD013",
399 warnings: vec![],
400 fix_content: "".to_string(),
401 }),
402 Box::new(MockRule {
403 name: "MD010",
404 warnings: vec![],
405 fix_content: "".to_string(),
406 }),
407 Box::new(MockRule {
408 name: "MD007",
409 warnings: vec![],
410 fix_content: "".to_string(),
411 }),
412 ];
413
414 let ordered = coordinator.get_optimal_order(&rules);
415 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
416
417 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
419 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
420 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
421
422 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
424 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
425 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
426 }
427
428 #[test]
429 fn test_single_rule_fix() {
430 let coordinator = FixCoordinator::new();
431
432 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
434 name: "RemoveBad",
435 check_fn: |content| content.contains("BAD"),
436 fix_fn: |content| content.replace("BAD", "GOOD"),
437 })];
438
439 let mut content = "This is BAD content".to_string();
440 let config = Config {
441 global: GlobalConfig::default(),
442 per_file_ignores: HashMap::new(),
443 per_file_flavor: IndexMap::new(),
444 rules: Default::default(),
445 project_root: None,
446 };
447
448 let result = coordinator
449 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
450 .unwrap();
451
452 assert_eq!(content, "This is GOOD content");
453 assert_eq!(result.rules_fixed, 1);
454 assert!(result.converged);
455 }
456
457 #[test]
458 fn test_cascading_fixes() {
459 let coordinator = FixCoordinator::new();
463
464 let rules: Vec<Box<dyn Rule>> = vec![
465 Box::new(ConditionalFixRule {
466 name: "Rule1_IndentToFence",
467 check_fn: |content| content.contains("INDENT"),
468 fix_fn: |content| content.replace("INDENT", "FENCE"),
469 }),
470 Box::new(ConditionalFixRule {
471 name: "Rule2_FenceToLang",
472 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
473 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
474 }),
475 ];
476
477 let mut content = "Code: INDENT".to_string();
478 let config = Config {
479 global: GlobalConfig::default(),
480 per_file_ignores: HashMap::new(),
481 per_file_flavor: IndexMap::new(),
482 rules: Default::default(),
483 project_root: None,
484 };
485
486 let result = coordinator
487 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
488 .unwrap();
489
490 assert_eq!(content, "Code: FENCE_LANG");
492 assert_eq!(result.rules_fixed, 2);
493 assert!(result.converged);
494 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
495 }
496
497 #[test]
498 fn test_indirect_cascade() {
499 let coordinator = FixCoordinator::new();
504
505 let rules: Vec<Box<dyn Rule>> = vec![
506 Box::new(ConditionalFixRule {
507 name: "Rule1_AddBlank",
508 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
509 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
510 }),
511 Box::new(ConditionalFixRule {
512 name: "Rule2_CodeToFence",
513 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
515 fix_fn: |content| content.replace("CODE", "FENCE"),
516 }),
517 Box::new(ConditionalFixRule {
518 name: "Rule3_AddLang",
519 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
520 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
521 }),
522 ];
523
524 let mut content = "HEADING CODE".to_string();
525 let config = Config {
526 global: GlobalConfig::default(),
527 per_file_ignores: HashMap::new(),
528 per_file_flavor: IndexMap::new(),
529 rules: Default::default(),
530 project_root: None,
531 };
532
533 let result = coordinator
534 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
535 .unwrap();
536
537 assert_eq!(content, "HEADING BLANK FENCE_LANG");
539 assert_eq!(result.rules_fixed, 3);
540 assert!(result.converged);
541 }
542
543 #[test]
544 fn test_unfixable_rules_skipped() {
545 let coordinator = FixCoordinator::new();
546
547 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
548 name: "MD001",
549 check_fn: |content| content.contains("BAD"),
550 fix_fn: |content| content.replace("BAD", "GOOD"),
551 })];
552
553 let mut content = "BAD content".to_string();
554 let mut config = Config {
555 global: GlobalConfig::default(),
556 per_file_ignores: HashMap::new(),
557 per_file_flavor: IndexMap::new(),
558 rules: Default::default(),
559 project_root: None,
560 };
561 config.global.unfixable = vec!["MD001".to_string()];
562
563 let result = coordinator
564 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
565 .unwrap();
566
567 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
569 assert!(result.converged);
570 }
571
572 #[test]
573 fn test_fixable_allowlist() {
574 let coordinator = FixCoordinator::new();
575
576 let rules: Vec<Box<dyn Rule>> = vec![
577 Box::new(ConditionalFixRule {
578 name: "AllowedRule",
579 check_fn: |content| content.contains("A"),
580 fix_fn: |content| content.replace("A", "X"),
581 }),
582 Box::new(ConditionalFixRule {
583 name: "NotAllowedRule",
584 check_fn: |content| content.contains("B"),
585 fix_fn: |content| content.replace("B", "Y"),
586 }),
587 ];
588
589 let mut content = "AB".to_string();
590 let mut config = Config {
591 global: GlobalConfig::default(),
592 per_file_ignores: HashMap::new(),
593 per_file_flavor: IndexMap::new(),
594 rules: Default::default(),
595 project_root: None,
596 };
597 config.global.fixable = vec!["AllowedRule".to_string()];
598
599 let result = coordinator
600 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
601 .unwrap();
602
603 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
605 }
606
607 #[test]
608 fn test_max_iterations_limit() {
609 let coordinator = FixCoordinator::new();
610
611 static COUNTER: AtomicUsize = AtomicUsize::new(0);
613
614 #[derive(Clone)]
615 struct AlwaysChangeRule;
616 impl Rule for AlwaysChangeRule {
617 fn name(&self) -> &'static str {
618 "AlwaysChange"
619 }
620 fn check(&self, _: &LintContext) -> LintResult {
621 Ok(vec![LintWarning {
622 line: 1,
623 column: 1,
624 end_line: 1,
625 end_column: 1,
626 message: "Always".to_string(),
627 rule_name: Some("AlwaysChange".to_string()),
628 severity: Severity::Error,
629 fix: Some(Fix {
630 range: 0..0,
631 replacement: String::new(),
632 }),
633 }])
634 }
635 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
636 COUNTER.fetch_add(1, Ordering::SeqCst);
637 Ok(format!("{}x", ctx.content))
638 }
639 fn description(&self) -> &'static str {
640 "Always changes"
641 }
642 fn category(&self) -> RuleCategory {
643 RuleCategory::Whitespace
644 }
645 fn as_any(&self) -> &dyn std::any::Any {
646 self
647 }
648 }
649
650 COUNTER.store(0, Ordering::SeqCst);
651 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
652
653 let mut content = "test".to_string();
654 let config = Config {
655 global: GlobalConfig::default(),
656 per_file_ignores: HashMap::new(),
657 per_file_flavor: IndexMap::new(),
658 rules: Default::default(),
659 project_root: None,
660 };
661
662 let result = coordinator
663 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
664 .unwrap();
665
666 assert_eq!(result.iterations, 5);
668 assert!(!result.converged);
669 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
670 }
671
672 #[test]
673 fn test_empty_rules() {
674 let coordinator = FixCoordinator::new();
675 let rules: Vec<Box<dyn Rule>> = vec![];
676
677 let mut content = "unchanged".to_string();
678 let config = Config {
679 global: GlobalConfig::default(),
680 per_file_ignores: HashMap::new(),
681 per_file_flavor: IndexMap::new(),
682 rules: Default::default(),
683 project_root: None,
684 };
685
686 let result = coordinator
687 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
688 .unwrap();
689
690 assert_eq!(result.rules_fixed, 0);
691 assert_eq!(result.iterations, 1);
692 assert!(result.converged);
693 assert_eq!(content, "unchanged");
694 }
695
696 #[test]
697 fn test_no_warnings_no_changes() {
698 let coordinator = FixCoordinator::new();
699
700 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
702 name: "NoIssues",
703 check_fn: |_| false, fix_fn: |content| content.to_string(),
705 })];
706
707 let mut content = "clean content".to_string();
708 let config = Config {
709 global: GlobalConfig::default(),
710 per_file_ignores: HashMap::new(),
711 per_file_flavor: IndexMap::new(),
712 rules: Default::default(),
713 project_root: None,
714 };
715
716 let result = coordinator
717 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
718 .unwrap();
719
720 assert_eq!(content, "clean content");
721 assert_eq!(result.rules_fixed, 0);
722 assert!(result.converged);
723 }
724
725 #[test]
726 fn test_cyclic_dependencies_handled() {
727 let mut coordinator = FixCoordinator::new();
728
729 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
731 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
732 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
733
734 let rules: Vec<Box<dyn Rule>> = vec![
735 Box::new(MockRule {
736 name: "RuleA",
737 warnings: vec![],
738 fix_content: "".to_string(),
739 }),
740 Box::new(MockRule {
741 name: "RuleB",
742 warnings: vec![],
743 fix_content: "".to_string(),
744 }),
745 Box::new(MockRule {
746 name: "RuleC",
747 warnings: vec![],
748 fix_content: "".to_string(),
749 }),
750 ];
751
752 let ordered = coordinator.get_optimal_order(&rules);
754
755 assert_eq!(ordered.len(), 3);
757 }
758
759 #[test]
760 fn test_fix_is_idempotent() {
761 let coordinator = FixCoordinator::new();
763
764 let rules: Vec<Box<dyn Rule>> = vec![
765 Box::new(ConditionalFixRule {
766 name: "Rule1",
767 check_fn: |content| content.contains("A"),
768 fix_fn: |content| content.replace("A", "B"),
769 }),
770 Box::new(ConditionalFixRule {
771 name: "Rule2",
772 check_fn: |content| content.contains("B") && !content.contains("C"),
773 fix_fn: |content| content.replace("B", "BC"),
774 }),
775 ];
776
777 let config = Config {
778 global: GlobalConfig::default(),
779 per_file_ignores: HashMap::new(),
780 per_file_flavor: IndexMap::new(),
781 rules: Default::default(),
782 project_root: None,
783 };
784
785 let mut content1 = "A".to_string();
787 let result1 = coordinator
788 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10)
789 .unwrap();
790
791 let mut content2 = content1.clone();
793 let result2 = coordinator
794 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10)
795 .unwrap();
796
797 assert_eq!(content1, content2);
799 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
800 assert!(result1.converged);
801 assert!(result2.converged);
802 }
803}