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 std::sync::atomic::{AtomicUsize, Ordering};
299
300 #[derive(Clone)]
302 struct ConditionalFixRule {
303 name: &'static str,
304 check_fn: fn(&str) -> bool,
306 fix_fn: fn(&str) -> String,
308 }
309
310 impl Rule for ConditionalFixRule {
311 fn name(&self) -> &'static str {
312 self.name
313 }
314
315 fn check(&self, ctx: &LintContext) -> LintResult {
316 if (self.check_fn)(ctx.content) {
317 Ok(vec![LintWarning {
318 line: 1,
319 column: 1,
320 end_line: 1,
321 end_column: 1,
322 message: format!("{} issue found", self.name),
323 rule_name: Some(self.name.to_string()),
324 severity: Severity::Error,
325 fix: Some(Fix {
326 range: 0..0,
327 replacement: String::new(),
328 }),
329 }])
330 } else {
331 Ok(vec![])
332 }
333 }
334
335 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
336 Ok((self.fix_fn)(ctx.content))
337 }
338
339 fn description(&self) -> &'static str {
340 "Conditional fix rule for testing"
341 }
342
343 fn category(&self) -> RuleCategory {
344 RuleCategory::Whitespace
345 }
346
347 fn as_any(&self) -> &dyn std::any::Any {
348 self
349 }
350 }
351
352 #[derive(Clone)]
354 struct MockRule {
355 name: &'static str,
356 warnings: Vec<LintWarning>,
357 fix_content: String,
358 }
359
360 impl Rule for MockRule {
361 fn name(&self) -> &'static str {
362 self.name
363 }
364
365 fn check(&self, _ctx: &LintContext) -> LintResult {
366 Ok(self.warnings.clone())
367 }
368
369 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
370 Ok(self.fix_content.clone())
371 }
372
373 fn description(&self) -> &'static str {
374 "Mock rule for testing"
375 }
376
377 fn category(&self) -> RuleCategory {
378 RuleCategory::Whitespace
379 }
380
381 fn as_any(&self) -> &dyn std::any::Any {
382 self
383 }
384 }
385
386 #[test]
387 fn test_dependency_ordering() {
388 let coordinator = FixCoordinator::new();
389
390 let rules: Vec<Box<dyn Rule>> = vec![
391 Box::new(MockRule {
392 name: "MD009",
393 warnings: vec![],
394 fix_content: "".to_string(),
395 }),
396 Box::new(MockRule {
397 name: "MD013",
398 warnings: vec![],
399 fix_content: "".to_string(),
400 }),
401 Box::new(MockRule {
402 name: "MD010",
403 warnings: vec![],
404 fix_content: "".to_string(),
405 }),
406 Box::new(MockRule {
407 name: "MD007",
408 warnings: vec![],
409 fix_content: "".to_string(),
410 }),
411 ];
412
413 let ordered = coordinator.get_optimal_order(&rules);
414 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
415
416 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
418 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
419 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
420
421 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
423 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
424 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
425 }
426
427 #[test]
428 fn test_single_rule_fix() {
429 let coordinator = FixCoordinator::new();
430
431 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
433 name: "RemoveBad",
434 check_fn: |content| content.contains("BAD"),
435 fix_fn: |content| content.replace("BAD", "GOOD"),
436 })];
437
438 let mut content = "This is BAD content".to_string();
439 let config = Config {
440 global: GlobalConfig::default(),
441 per_file_ignores: HashMap::new(),
442 rules: Default::default(),
443 project_root: None,
444 };
445
446 let result = coordinator
447 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
448 .unwrap();
449
450 assert_eq!(content, "This is GOOD content");
451 assert_eq!(result.rules_fixed, 1);
452 assert!(result.converged);
453 }
454
455 #[test]
456 fn test_cascading_fixes() {
457 let coordinator = FixCoordinator::new();
461
462 let rules: Vec<Box<dyn Rule>> = vec![
463 Box::new(ConditionalFixRule {
464 name: "Rule1_IndentToFence",
465 check_fn: |content| content.contains("INDENT"),
466 fix_fn: |content| content.replace("INDENT", "FENCE"),
467 }),
468 Box::new(ConditionalFixRule {
469 name: "Rule2_FenceToLang",
470 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
471 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
472 }),
473 ];
474
475 let mut content = "Code: INDENT".to_string();
476 let config = Config {
477 global: GlobalConfig::default(),
478 per_file_ignores: HashMap::new(),
479 rules: Default::default(),
480 project_root: None,
481 };
482
483 let result = coordinator
484 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
485 .unwrap();
486
487 assert_eq!(content, "Code: FENCE_LANG");
489 assert_eq!(result.rules_fixed, 2);
490 assert!(result.converged);
491 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
492 }
493
494 #[test]
495 fn test_indirect_cascade() {
496 let coordinator = FixCoordinator::new();
501
502 let rules: Vec<Box<dyn Rule>> = vec![
503 Box::new(ConditionalFixRule {
504 name: "Rule1_AddBlank",
505 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
506 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
507 }),
508 Box::new(ConditionalFixRule {
509 name: "Rule2_CodeToFence",
510 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
512 fix_fn: |content| content.replace("CODE", "FENCE"),
513 }),
514 Box::new(ConditionalFixRule {
515 name: "Rule3_AddLang",
516 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
517 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
518 }),
519 ];
520
521 let mut content = "HEADING CODE".to_string();
522 let config = Config {
523 global: GlobalConfig::default(),
524 per_file_ignores: HashMap::new(),
525 rules: Default::default(),
526 project_root: None,
527 };
528
529 let result = coordinator
530 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
531 .unwrap();
532
533 assert_eq!(content, "HEADING BLANK FENCE_LANG");
535 assert_eq!(result.rules_fixed, 3);
536 assert!(result.converged);
537 }
538
539 #[test]
540 fn test_unfixable_rules_skipped() {
541 let coordinator = FixCoordinator::new();
542
543 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
544 name: "MD001",
545 check_fn: |content| content.contains("BAD"),
546 fix_fn: |content| content.replace("BAD", "GOOD"),
547 })];
548
549 let mut content = "BAD content".to_string();
550 let mut config = Config {
551 global: GlobalConfig::default(),
552 per_file_ignores: HashMap::new(),
553 rules: Default::default(),
554 project_root: None,
555 };
556 config.global.unfixable = vec!["MD001".to_string()];
557
558 let result = coordinator
559 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
560 .unwrap();
561
562 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
564 assert!(result.converged);
565 }
566
567 #[test]
568 fn test_fixable_allowlist() {
569 let coordinator = FixCoordinator::new();
570
571 let rules: Vec<Box<dyn Rule>> = vec![
572 Box::new(ConditionalFixRule {
573 name: "AllowedRule",
574 check_fn: |content| content.contains("A"),
575 fix_fn: |content| content.replace("A", "X"),
576 }),
577 Box::new(ConditionalFixRule {
578 name: "NotAllowedRule",
579 check_fn: |content| content.contains("B"),
580 fix_fn: |content| content.replace("B", "Y"),
581 }),
582 ];
583
584 let mut content = "AB".to_string();
585 let mut config = Config {
586 global: GlobalConfig::default(),
587 per_file_ignores: HashMap::new(),
588 rules: Default::default(),
589 project_root: None,
590 };
591 config.global.fixable = vec!["AllowedRule".to_string()];
592
593 let result = coordinator
594 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
595 .unwrap();
596
597 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
599 }
600
601 #[test]
602 fn test_max_iterations_limit() {
603 let coordinator = FixCoordinator::new();
604
605 static COUNTER: AtomicUsize = AtomicUsize::new(0);
607
608 #[derive(Clone)]
609 struct AlwaysChangeRule;
610 impl Rule for AlwaysChangeRule {
611 fn name(&self) -> &'static str {
612 "AlwaysChange"
613 }
614 fn check(&self, _: &LintContext) -> LintResult {
615 Ok(vec![LintWarning {
616 line: 1,
617 column: 1,
618 end_line: 1,
619 end_column: 1,
620 message: "Always".to_string(),
621 rule_name: Some("AlwaysChange".to_string()),
622 severity: Severity::Error,
623 fix: Some(Fix {
624 range: 0..0,
625 replacement: String::new(),
626 }),
627 }])
628 }
629 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
630 COUNTER.fetch_add(1, Ordering::SeqCst);
631 Ok(format!("{}x", ctx.content))
632 }
633 fn description(&self) -> &'static str {
634 "Always changes"
635 }
636 fn category(&self) -> RuleCategory {
637 RuleCategory::Whitespace
638 }
639 fn as_any(&self) -> &dyn std::any::Any {
640 self
641 }
642 }
643
644 COUNTER.store(0, Ordering::SeqCst);
645 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
646
647 let mut content = "test".to_string();
648 let config = Config {
649 global: GlobalConfig::default(),
650 per_file_ignores: HashMap::new(),
651 rules: Default::default(),
652 project_root: None,
653 };
654
655 let result = coordinator
656 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
657 .unwrap();
658
659 assert_eq!(result.iterations, 5);
661 assert!(!result.converged);
662 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
663 }
664
665 #[test]
666 fn test_empty_rules() {
667 let coordinator = FixCoordinator::new();
668 let rules: Vec<Box<dyn Rule>> = vec![];
669
670 let mut content = "unchanged".to_string();
671 let config = Config {
672 global: GlobalConfig::default(),
673 per_file_ignores: HashMap::new(),
674 rules: Default::default(),
675 project_root: None,
676 };
677
678 let result = coordinator
679 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
680 .unwrap();
681
682 assert_eq!(result.rules_fixed, 0);
683 assert_eq!(result.iterations, 1);
684 assert!(result.converged);
685 assert_eq!(content, "unchanged");
686 }
687
688 #[test]
689 fn test_no_warnings_no_changes() {
690 let coordinator = FixCoordinator::new();
691
692 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
694 name: "NoIssues",
695 check_fn: |_| false, fix_fn: |content| content.to_string(),
697 })];
698
699 let mut content = "clean content".to_string();
700 let config = Config {
701 global: GlobalConfig::default(),
702 per_file_ignores: HashMap::new(),
703 rules: Default::default(),
704 project_root: None,
705 };
706
707 let result = coordinator
708 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
709 .unwrap();
710
711 assert_eq!(content, "clean content");
712 assert_eq!(result.rules_fixed, 0);
713 assert!(result.converged);
714 }
715
716 #[test]
717 fn test_cyclic_dependencies_handled() {
718 let mut coordinator = FixCoordinator::new();
719
720 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
722 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
723 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
724
725 let rules: Vec<Box<dyn Rule>> = vec![
726 Box::new(MockRule {
727 name: "RuleA",
728 warnings: vec![],
729 fix_content: "".to_string(),
730 }),
731 Box::new(MockRule {
732 name: "RuleB",
733 warnings: vec![],
734 fix_content: "".to_string(),
735 }),
736 Box::new(MockRule {
737 name: "RuleC",
738 warnings: vec![],
739 fix_content: "".to_string(),
740 }),
741 ];
742
743 let ordered = coordinator.get_optimal_order(&rules);
745
746 assert_eq!(ordered.len(), 3);
748 }
749
750 #[test]
751 fn test_fix_is_idempotent() {
752 let coordinator = FixCoordinator::new();
754
755 let rules: Vec<Box<dyn Rule>> = vec![
756 Box::new(ConditionalFixRule {
757 name: "Rule1",
758 check_fn: |content| content.contains("A"),
759 fix_fn: |content| content.replace("A", "B"),
760 }),
761 Box::new(ConditionalFixRule {
762 name: "Rule2",
763 check_fn: |content| content.contains("B") && !content.contains("C"),
764 fix_fn: |content| content.replace("B", "BC"),
765 }),
766 ];
767
768 let config = Config {
769 global: GlobalConfig::default(),
770 per_file_ignores: HashMap::new(),
771 rules: Default::default(),
772 project_root: None,
773 };
774
775 let mut content1 = "A".to_string();
777 let result1 = coordinator
778 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10)
779 .unwrap();
780
781 let mut content2 = content1.clone();
783 let result2 = coordinator
784 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10)
785 .unwrap();
786
787 assert_eq!(content1, content2);
789 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
790 assert!(result1.converged);
791 assert!(result2.converged);
792 }
793}