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