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 if rule.should_skip(&ctx) {
220 continue;
221 }
222
223 let warnings = match rule.check(&ctx) {
225 Ok(w) => w,
226 Err(_) => continue,
227 };
228
229 if warnings.is_empty() {
230 continue;
231 }
232
233 let has_fixable = warnings.iter().any(|w| w.fix.is_some());
235 if !has_fixable {
236 continue;
237 }
238
239 match rule.fix(&ctx) {
241 Ok(fixed_content) => {
242 if fixed_content != *content {
243 *content = fixed_content;
244 total_fixed += 1;
245 any_fix_applied = true;
246 fixed_rule_names.insert(rule.name().to_string());
247
248 break;
252 }
253 }
254 Err(_) => {
255 continue;
257 }
258 }
259 }
260
261 let current_hash = hash_content(content);
263 if current_hash == previous_hash {
264 return Ok(FixResult {
266 rules_fixed: total_fixed,
267 iterations,
268 context_creations: total_ctx_creations,
269 fixed_rule_names,
270 converged: true,
271 });
272 }
273 previous_hash = current_hash;
274
275 if !any_fix_applied {
277 return Ok(FixResult {
278 rules_fixed: total_fixed,
279 iterations,
280 context_creations: total_ctx_creations,
281 fixed_rule_names,
282 converged: true,
283 });
284 }
285 }
286
287 Ok(FixResult {
289 rules_fixed: total_fixed,
290 iterations,
291 context_creations: total_ctx_creations,
292 fixed_rule_names,
293 converged: false,
294 })
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
302 use std::sync::atomic::{AtomicUsize, Ordering};
303
304 #[derive(Clone)]
306 struct ConditionalFixRule {
307 name: &'static str,
308 check_fn: fn(&str) -> bool,
310 fix_fn: fn(&str) -> String,
312 }
313
314 impl Rule for ConditionalFixRule {
315 fn name(&self) -> &'static str {
316 self.name
317 }
318
319 fn check(&self, ctx: &LintContext) -> LintResult {
320 if (self.check_fn)(ctx.content) {
321 Ok(vec![LintWarning {
322 line: 1,
323 column: 1,
324 end_line: 1,
325 end_column: 1,
326 message: format!("{} issue found", self.name),
327 rule_name: Some(self.name.to_string()),
328 severity: Severity::Error,
329 fix: Some(Fix {
330 range: 0..0,
331 replacement: String::new(),
332 }),
333 }])
334 } else {
335 Ok(vec![])
336 }
337 }
338
339 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
340 Ok((self.fix_fn)(ctx.content))
341 }
342
343 fn description(&self) -> &'static str {
344 "Conditional fix rule for testing"
345 }
346
347 fn category(&self) -> RuleCategory {
348 RuleCategory::Whitespace
349 }
350
351 fn as_any(&self) -> &dyn std::any::Any {
352 self
353 }
354 }
355
356 #[derive(Clone)]
358 struct MockRule {
359 name: &'static str,
360 warnings: Vec<LintWarning>,
361 fix_content: String,
362 }
363
364 impl Rule for MockRule {
365 fn name(&self) -> &'static str {
366 self.name
367 }
368
369 fn check(&self, _ctx: &LintContext) -> LintResult {
370 Ok(self.warnings.clone())
371 }
372
373 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
374 Ok(self.fix_content.clone())
375 }
376
377 fn description(&self) -> &'static str {
378 "Mock rule for testing"
379 }
380
381 fn category(&self) -> RuleCategory {
382 RuleCategory::Whitespace
383 }
384
385 fn as_any(&self) -> &dyn std::any::Any {
386 self
387 }
388 }
389
390 #[test]
391 fn test_dependency_ordering() {
392 let coordinator = FixCoordinator::new();
393
394 let rules: Vec<Box<dyn Rule>> = vec![
395 Box::new(MockRule {
396 name: "MD009",
397 warnings: vec![],
398 fix_content: "".to_string(),
399 }),
400 Box::new(MockRule {
401 name: "MD013",
402 warnings: vec![],
403 fix_content: "".to_string(),
404 }),
405 Box::new(MockRule {
406 name: "MD010",
407 warnings: vec![],
408 fix_content: "".to_string(),
409 }),
410 Box::new(MockRule {
411 name: "MD007",
412 warnings: vec![],
413 fix_content: "".to_string(),
414 }),
415 ];
416
417 let ordered = coordinator.get_optimal_order(&rules);
418 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
419
420 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
422 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
423 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
424
425 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
427 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
428 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
429 }
430
431 #[test]
432 fn test_single_rule_fix() {
433 let coordinator = FixCoordinator::new();
434
435 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
437 name: "RemoveBad",
438 check_fn: |content| content.contains("BAD"),
439 fix_fn: |content| content.replace("BAD", "GOOD"),
440 })];
441
442 let mut content = "This is BAD content".to_string();
443 let config = Config::default();
444
445 let result = coordinator
446 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
447 .unwrap();
448
449 assert_eq!(content, "This is GOOD content");
450 assert_eq!(result.rules_fixed, 1);
451 assert!(result.converged);
452 }
453
454 #[test]
455 fn test_cascading_fixes() {
456 let coordinator = FixCoordinator::new();
460
461 let rules: Vec<Box<dyn Rule>> = vec![
462 Box::new(ConditionalFixRule {
463 name: "Rule1_IndentToFence",
464 check_fn: |content| content.contains("INDENT"),
465 fix_fn: |content| content.replace("INDENT", "FENCE"),
466 }),
467 Box::new(ConditionalFixRule {
468 name: "Rule2_FenceToLang",
469 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
470 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
471 }),
472 ];
473
474 let mut content = "Code: INDENT".to_string();
475 let config = Config::default();
476
477 let result = coordinator
478 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
479 .unwrap();
480
481 assert_eq!(content, "Code: FENCE_LANG");
483 assert_eq!(result.rules_fixed, 2);
484 assert!(result.converged);
485 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
486 }
487
488 #[test]
489 fn test_indirect_cascade() {
490 let coordinator = FixCoordinator::new();
495
496 let rules: Vec<Box<dyn Rule>> = vec![
497 Box::new(ConditionalFixRule {
498 name: "Rule1_AddBlank",
499 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
500 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
501 }),
502 Box::new(ConditionalFixRule {
503 name: "Rule2_CodeToFence",
504 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
506 fix_fn: |content| content.replace("CODE", "FENCE"),
507 }),
508 Box::new(ConditionalFixRule {
509 name: "Rule3_AddLang",
510 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
511 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
512 }),
513 ];
514
515 let mut content = "HEADING CODE".to_string();
516 let config = Config::default();
517
518 let result = coordinator
519 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
520 .unwrap();
521
522 assert_eq!(content, "HEADING BLANK FENCE_LANG");
524 assert_eq!(result.rules_fixed, 3);
525 assert!(result.converged);
526 }
527
528 #[test]
529 fn test_unfixable_rules_skipped() {
530 let coordinator = FixCoordinator::new();
531
532 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
533 name: "MD001",
534 check_fn: |content| content.contains("BAD"),
535 fix_fn: |content| content.replace("BAD", "GOOD"),
536 })];
537
538 let mut content = "BAD content".to_string();
539 let mut config = Config::default();
540 config.global.unfixable = vec!["MD001".to_string()];
541
542 let result = coordinator
543 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
544 .unwrap();
545
546 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
548 assert!(result.converged);
549 }
550
551 #[test]
552 fn test_fixable_allowlist() {
553 let coordinator = FixCoordinator::new();
554
555 let rules: Vec<Box<dyn Rule>> = vec![
556 Box::new(ConditionalFixRule {
557 name: "AllowedRule",
558 check_fn: |content| content.contains("A"),
559 fix_fn: |content| content.replace("A", "X"),
560 }),
561 Box::new(ConditionalFixRule {
562 name: "NotAllowedRule",
563 check_fn: |content| content.contains("B"),
564 fix_fn: |content| content.replace("B", "Y"),
565 }),
566 ];
567
568 let mut content = "AB".to_string();
569 let mut config = Config::default();
570 config.global.fixable = vec!["AllowedRule".to_string()];
571
572 let result = coordinator
573 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
574 .unwrap();
575
576 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
578 }
579
580 #[test]
581 fn test_max_iterations_limit() {
582 let coordinator = FixCoordinator::new();
583
584 static COUNTER: AtomicUsize = AtomicUsize::new(0);
586
587 #[derive(Clone)]
588 struct AlwaysChangeRule;
589 impl Rule for AlwaysChangeRule {
590 fn name(&self) -> &'static str {
591 "AlwaysChange"
592 }
593 fn check(&self, _: &LintContext) -> LintResult {
594 Ok(vec![LintWarning {
595 line: 1,
596 column: 1,
597 end_line: 1,
598 end_column: 1,
599 message: "Always".to_string(),
600 rule_name: Some("AlwaysChange".to_string()),
601 severity: Severity::Error,
602 fix: Some(Fix {
603 range: 0..0,
604 replacement: String::new(),
605 }),
606 }])
607 }
608 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
609 COUNTER.fetch_add(1, Ordering::SeqCst);
610 Ok(format!("{}x", ctx.content))
611 }
612 fn description(&self) -> &'static str {
613 "Always changes"
614 }
615 fn category(&self) -> RuleCategory {
616 RuleCategory::Whitespace
617 }
618 fn as_any(&self) -> &dyn std::any::Any {
619 self
620 }
621 }
622
623 COUNTER.store(0, Ordering::SeqCst);
624 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
625
626 let mut content = "test".to_string();
627 let config = Config::default();
628
629 let result = coordinator
630 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
631 .unwrap();
632
633 assert_eq!(result.iterations, 5);
635 assert!(!result.converged);
636 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
637 }
638
639 #[test]
640 fn test_empty_rules() {
641 let coordinator = FixCoordinator::new();
642 let rules: Vec<Box<dyn Rule>> = vec![];
643
644 let mut content = "unchanged".to_string();
645 let config = Config::default();
646
647 let result = coordinator
648 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
649 .unwrap();
650
651 assert_eq!(result.rules_fixed, 0);
652 assert_eq!(result.iterations, 1);
653 assert!(result.converged);
654 assert_eq!(content, "unchanged");
655 }
656
657 #[test]
658 fn test_no_warnings_no_changes() {
659 let coordinator = FixCoordinator::new();
660
661 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
663 name: "NoIssues",
664 check_fn: |_| false, fix_fn: |content| content.to_string(),
666 })];
667
668 let mut content = "clean content".to_string();
669 let config = Config::default();
670
671 let result = coordinator
672 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
673 .unwrap();
674
675 assert_eq!(content, "clean content");
676 assert_eq!(result.rules_fixed, 0);
677 assert!(result.converged);
678 }
679
680 #[test]
681 fn test_cyclic_dependencies_handled() {
682 let mut coordinator = FixCoordinator::new();
683
684 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
686 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
687 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
688
689 let rules: Vec<Box<dyn Rule>> = vec![
690 Box::new(MockRule {
691 name: "RuleA",
692 warnings: vec![],
693 fix_content: "".to_string(),
694 }),
695 Box::new(MockRule {
696 name: "RuleB",
697 warnings: vec![],
698 fix_content: "".to_string(),
699 }),
700 Box::new(MockRule {
701 name: "RuleC",
702 warnings: vec![],
703 fix_content: "".to_string(),
704 }),
705 ];
706
707 let ordered = coordinator.get_optimal_order(&rules);
709
710 assert_eq!(ordered.len(), 3);
712 }
713
714 #[test]
715 fn test_fix_is_idempotent() {
716 let coordinator = FixCoordinator::new();
718
719 let rules: Vec<Box<dyn Rule>> = vec![
720 Box::new(ConditionalFixRule {
721 name: "Rule1",
722 check_fn: |content| content.contains("A"),
723 fix_fn: |content| content.replace("A", "B"),
724 }),
725 Box::new(ConditionalFixRule {
726 name: "Rule2",
727 check_fn: |content| content.contains("B") && !content.contains("C"),
728 fix_fn: |content| content.replace("B", "BC"),
729 }),
730 ];
731
732 let config = Config::default();
733
734 let mut content1 = "A".to_string();
736 let result1 = coordinator
737 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10)
738 .unwrap();
739
740 let mut content2 = content1.clone();
742 let result2 = coordinator
743 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10)
744 .unwrap();
745
746 assert_eq!(content1, content2);
748 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
749 assert!(result1.converged);
750 assert!(result2.converged);
751 }
752}