1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{FixCapability, 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
11const LAYOUT_RULES: &[&str] = &["MD013"];
23
24#[derive(Debug, Clone)]
29pub struct FixResult {
30 pub rules_fixed: usize,
32 pub iterations: usize,
34 pub context_creations: usize,
36 pub fixed_rule_names: HashSet<String>,
38 pub converged: bool,
40 pub conflicting_rules: Vec<String>,
44 pub conflict_cycle: Vec<String>,
48}
49
50fn hash_content(content: &str) -> u64 {
52 let mut hasher = DefaultHasher::new();
53 content.hash(&mut hasher);
54 hasher.finish()
55}
56
57pub struct FixCoordinator {
59 dependencies: HashMap<&'static str, Vec<&'static str>>,
61}
62
63impl Default for FixCoordinator {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69impl FixCoordinator {
70 pub fn new() -> Self {
71 let mut dependencies = HashMap::new();
72
73 dependencies.insert("MD064", vec!["MD010"]);
80
81 dependencies.insert("MD010", vec!["MD007", "MD005"]);
85
86 dependencies.insert("MD013", vec!["MD009", "MD012"]);
91
92 dependencies.insert("MD004", vec!["MD007"]);
95
96 dependencies.insert("MD022", vec!["MD012"]);
99 dependencies.insert("MD023", vec!["MD012"]);
100
101 dependencies.insert("MD070", vec!["MD040", "MD031"]);
105
106 dependencies.insert("MD005", vec!["MD032"]);
111 dependencies.insert("MD077", vec!["MD032"]);
112
113 Self { dependencies }
114 }
115
116 fn downstream_of(&self, roots: &[&'static str]) -> HashSet<&'static str> {
118 let mut closure: HashSet<&'static str> = HashSet::new();
119 let mut pending: Vec<&'static str> = roots.to_vec();
120 while let Some(name) = pending.pop() {
121 if !closure.insert(name) {
122 continue;
123 }
124 if let Some(dependents) = self.dependencies.get(name) {
125 pending.extend(dependents.iter().copied());
126 }
127 }
128 closure
129 }
130
131 pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
133 let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
135 let position: HashMap<&str, usize> = rules.iter().enumerate().map(|(i, r)| (r.name(), i)).collect();
141
142 let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
144 for (prereq, dependents) in &self.dependencies {
145 for dependent in dependents {
146 reverse_deps.entry(dependent).or_default().insert(prereq);
147 }
148 }
149
150 let downstream_of_layout = self.downstream_of(LAYOUT_RULES);
154 for &layout_rule in LAYOUT_RULES {
155 let prereqs = reverse_deps.entry(layout_rule).or_default();
156 prereqs.extend(
157 rule_map
158 .keys()
159 .copied()
160 .filter(|name| *name != layout_rule && !downstream_of_layout.contains(name)),
161 );
162 }
163
164 let mut sorted = Vec::new();
166 let mut visited: HashSet<&str> = HashSet::new();
167 let mut visiting: HashSet<&str> = HashSet::new();
168
169 fn visit<'a, 'b>(
170 rule_name: &'b str,
171 rule_map: &HashMap<&str, &'a dyn Rule>,
172 position: &HashMap<&str, usize>,
173 reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
174 visited: &mut HashSet<&'b str>,
175 visiting: &mut HashSet<&'b str>,
176 sorted: &mut Vec<&'a dyn Rule>,
177 ) where
178 'a: 'b,
179 {
180 if visited.contains(rule_name) {
181 return;
182 }
183
184 if visiting.contains(rule_name) {
185 return;
187 }
188
189 visiting.insert(rule_name);
190
191 if let Some(deps) = reverse_deps.get(rule_name) {
193 let mut deps: Vec<&'b str> = deps.iter().copied().filter(|dep| rule_map.contains_key(dep)).collect();
194 deps.sort_by_key(|dep| position[dep]);
195 for dep in deps {
196 visit(dep, rule_map, position, reverse_deps, visited, visiting, sorted);
197 }
198 }
199
200 visiting.remove(rule_name);
201 visited.insert(rule_name);
202
203 if let Some(&rule) = rule_map.get(rule_name) {
205 sorted.push(rule);
206 }
207 }
208
209 for rule in rules {
211 visit(
212 rule.name(),
213 &rule_map,
214 &position,
215 &reverse_deps,
216 &mut visited,
217 &mut visiting,
218 &mut sorted,
219 );
220 }
221
222 for rule in rules {
224 if !sorted.iter().any(|r| r.name() == rule.name()) {
225 sorted.push(rule.as_ref());
226 }
227 }
228
229 sorted
230 }
231
232 pub fn apply_fixes_iterative(
240 &self,
241 rules: &[Box<dyn Rule>],
242 all_warnings: &[LintWarning], content: &mut String,
244 config: &Config,
245 max_iterations: usize,
246 file_path: Option<&std::path::Path>,
247 ) -> Result<FixResult, String> {
248 self.apply_fixes_iterative_with_paths(
249 rules,
250 all_warnings,
251 content,
252 config,
253 max_iterations,
254 crate::DocumentPaths::same(file_path),
255 )
256 }
257
258 pub fn apply_fixes_iterative_with_paths(
264 &self,
265 rules: &[Box<dyn Rule>],
266 _all_warnings: &[LintWarning],
267 content: &mut String,
268 config: &Config,
269 max_iterations: usize,
270 paths: crate::DocumentPaths<'_>,
271 ) -> Result<FixResult, String> {
272 let max_iterations = max_iterations.min(MAX_ITERATIONS);
274
275 let ordered_rules = self.get_optimal_order(rules);
277
278 let mut total_fixed = 0;
279 let mut total_ctx_creations = 0;
280 let mut iterations = 0;
281
282 let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
285
286 let mut fixed_rule_names: HashSet<&str> = HashSet::new();
288
289 let unfixable_rules: HashSet<String> = config.global.unfixable.iter().cloned().collect();
292 let fixable_rules: HashSet<String> = config.global.fixable.iter().cloned().collect();
293 let has_fixable_allowlist = !fixable_rules.is_empty();
294
295 let ignored_for_file: HashSet<String> = paths
301 .config_path
302 .map(|p| config.get_ignored_rules_for_file(p))
303 .unwrap_or_default();
304
305 while iterations < max_iterations {
307 iterations += 1;
308
309 let flavor = paths
312 .config_path
313 .map_or_else(|| config.markdown_flavor(), |path| config.get_flavor_for_file(path));
314 let ctx = LintContext::new(content, flavor, paths.source_file.map(std::path::Path::to_path_buf));
315 total_ctx_creations += 1;
316
317 let recreated_rules: HashMap<String, Box<dyn Rule>> = {
323 let inline_overrides = ctx.inline_config().get_all_rule_configs();
324 if inline_overrides.is_empty() {
325 HashMap::new()
326 } else {
327 let merged = config.merge_with_inline_config(ctx.inline_config());
328 inline_overrides
329 .keys()
330 .filter_map(|name| {
331 crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
332 })
333 .collect()
334 }
335 };
336
337 let mut any_fix_applied = false;
338 let mut this_iter_rule: &str = "";
340
341 for rule in &ordered_rules {
343 if unfixable_rules.contains(rule.name()) {
345 continue;
346 }
347 if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
348 continue;
349 }
350 if ignored_for_file.contains(rule.name()) {
352 continue;
353 }
354
355 let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
359
360 if effective_rule.should_skip(&ctx) {
362 continue;
363 }
364
365 let Ok(warnings) = effective_rule.check(&ctx) else {
367 continue;
368 };
369
370 if warnings.is_empty() {
371 continue;
372 }
373
374 let inline_config = ctx.inline_config();
376 let filtered_warnings =
377 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
378
379 if filtered_warnings.is_empty() {
380 continue;
381 }
382
383 let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
394 let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
395 if !has_inline_fix && !rule_advertises_fix {
396 continue;
397 }
398
399 match effective_rule.fix(&ctx) {
401 Ok(fixed_content) => {
402 if fixed_content != *content {
403 *content = fixed_content;
404 total_fixed += 1;
405 any_fix_applied = true;
406 this_iter_rule = rule.name();
407 fixed_rule_names.insert(rule.name());
408
409 break;
413 }
414 }
415 Err(_) => {
416 continue;
418 }
419 }
420 }
421
422 let current_hash = hash_content(content);
423
424 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
426 if cycle_start == history.len() - 1 {
427 return Ok(FixResult {
429 rules_fixed: total_fixed,
430 iterations,
431 context_creations: total_ctx_creations,
432 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
433 converged: true,
434 conflicting_rules: Vec::new(),
435 conflict_cycle: Vec::new(),
436 });
437 } else {
438 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
441 .iter()
442 .map(|(_, r)| r.to_string())
443 .chain(std::iter::once(this_iter_rule.to_string()))
444 .filter(|r| !r.is_empty())
445 .collect();
446 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
447 .iter()
448 .map(|(_, r)| *r)
449 .chain(std::iter::once(this_iter_rule))
450 .filter(|r| !r.is_empty())
451 .collect::<HashSet<&str>>()
452 .into_iter()
453 .map(std::string::ToString::to_string)
454 .collect();
455 return Ok(FixResult {
456 rules_fixed: total_fixed,
457 iterations,
458 context_creations: total_ctx_creations,
459 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
460 converged: false,
461 conflicting_rules,
462 conflict_cycle,
463 });
464 }
465 }
466
467 history.push((current_hash, this_iter_rule));
469
470 if !any_fix_applied {
472 return Ok(FixResult {
473 rules_fixed: total_fixed,
474 iterations,
475 context_creations: total_ctx_creations,
476 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
477 converged: true,
478 conflicting_rules: Vec::new(),
479 conflict_cycle: Vec::new(),
480 });
481 }
482 }
483
484 Ok(FixResult {
486 rules_fixed: total_fixed,
487 iterations,
488 context_creations: total_ctx_creations,
489 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
490 converged: false,
491 conflicting_rules: Vec::new(),
492 conflict_cycle: Vec::new(),
493 })
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
501 use std::sync::atomic::{AtomicUsize, Ordering};
502
503 #[derive(Clone)]
505 struct ConditionalFixRule {
506 name: &'static str,
507 check_fn: fn(&str) -> bool,
509 fix_fn: fn(&str) -> String,
511 }
512
513 impl Rule for ConditionalFixRule {
514 fn name(&self) -> &'static str {
515 self.name
516 }
517
518 fn check(&self, ctx: &LintContext) -> LintResult {
519 if (self.check_fn)(ctx.content) {
520 Ok(vec![LintWarning {
521 line: 1,
522 column: 1,
523 end_line: 1,
524 end_column: 1,
525 message: format!("{} issue found", self.name),
526 rule_name: Some(self.name.to_string()),
527 severity: Severity::Error,
528 fix: Some(Fix::new(0..0, String::new())),
529 }])
530 } else {
531 Ok(vec![])
532 }
533 }
534
535 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
536 Ok((self.fix_fn)(ctx.content))
537 }
538
539 fn description(&self) -> &'static str {
540 "Conditional fix rule for testing"
541 }
542
543 fn category(&self) -> RuleCategory {
544 RuleCategory::Whitespace
545 }
546
547 fn as_any(&self) -> &dyn std::any::Any {
548 self
549 }
550 }
551
552 #[derive(Clone)]
554 struct MockRule {
555 name: &'static str,
556 warnings: Vec<LintWarning>,
557 fix_content: String,
558 }
559
560 impl Rule for MockRule {
561 fn name(&self) -> &'static str {
562 self.name
563 }
564
565 fn check(&self, _ctx: &LintContext) -> LintResult {
566 Ok(self.warnings.clone())
567 }
568
569 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
570 Ok(self.fix_content.clone())
571 }
572
573 fn description(&self) -> &'static str {
574 "Mock rule for testing"
575 }
576
577 fn category(&self) -> RuleCategory {
578 RuleCategory::Whitespace
579 }
580
581 fn as_any(&self) -> &dyn std::any::Any {
582 self
583 }
584 }
585
586 #[test]
587 fn test_dependency_ordering() {
588 let coordinator = FixCoordinator::new();
589
590 let rules: Vec<Box<dyn Rule>> = vec![
591 Box::new(MockRule {
592 name: "MD009",
593 warnings: vec![],
594 fix_content: "".to_string(),
595 }),
596 Box::new(MockRule {
597 name: "MD013",
598 warnings: vec![],
599 fix_content: "".to_string(),
600 }),
601 Box::new(MockRule {
602 name: "MD010",
603 warnings: vec![],
604 fix_content: "".to_string(),
605 }),
606 Box::new(MockRule {
607 name: "MD007",
608 warnings: vec![],
609 fix_content: "".to_string(),
610 }),
611 ];
612
613 let ordered = coordinator.get_optimal_order(&rules);
614 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
615
616 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
618 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
619 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
620
621 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
623 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
624 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
625 }
626
627 #[test]
628 fn test_single_rule_fix() {
629 let coordinator = FixCoordinator::new();
630
631 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
633 name: "RemoveBad",
634 check_fn: |content| content.contains("BAD"),
635 fix_fn: |content| content.replace("BAD", "GOOD"),
636 })];
637
638 let mut content = "This is BAD content".to_string();
639 let config = Config::default();
640
641 let result = coordinator
642 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
643 .unwrap();
644
645 assert_eq!(content, "This is GOOD content");
646 assert_eq!(result.rules_fixed, 1);
647 assert!(result.converged);
648 }
649
650 #[test]
651 fn test_cascading_fixes() {
652 let coordinator = FixCoordinator::new();
656
657 let rules: Vec<Box<dyn Rule>> = vec![
658 Box::new(ConditionalFixRule {
659 name: "Rule1_IndentToFence",
660 check_fn: |content| content.contains("INDENT"),
661 fix_fn: |content| content.replace("INDENT", "FENCE"),
662 }),
663 Box::new(ConditionalFixRule {
664 name: "Rule2_FenceToLang",
665 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
666 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
667 }),
668 ];
669
670 let mut content = "Code: INDENT".to_string();
671 let config = Config::default();
672
673 let result = coordinator
674 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
675 .unwrap();
676
677 assert_eq!(content, "Code: FENCE_LANG");
679 assert_eq!(result.rules_fixed, 2);
680 assert!(result.converged);
681 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
682 }
683
684 #[test]
685 fn test_indirect_cascade() {
686 let coordinator = FixCoordinator::new();
691
692 let rules: Vec<Box<dyn Rule>> = vec![
693 Box::new(ConditionalFixRule {
694 name: "Rule1_AddBlank",
695 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
696 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
697 }),
698 Box::new(ConditionalFixRule {
699 name: "Rule2_CodeToFence",
700 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
702 fix_fn: |content| content.replace("CODE", "FENCE"),
703 }),
704 Box::new(ConditionalFixRule {
705 name: "Rule3_AddLang",
706 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
707 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
708 }),
709 ];
710
711 let mut content = "HEADING CODE".to_string();
712 let config = Config::default();
713
714 let result = coordinator
715 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
716 .unwrap();
717
718 assert_eq!(content, "HEADING BLANK FENCE_LANG");
720 assert_eq!(result.rules_fixed, 3);
721 assert!(result.converged);
722 }
723
724 #[test]
725 fn test_unfixable_rules_skipped() {
726 let coordinator = FixCoordinator::new();
727
728 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
729 name: "MD001",
730 check_fn: |content| content.contains("BAD"),
731 fix_fn: |content| content.replace("BAD", "GOOD"),
732 })];
733
734 let mut content = "BAD content".to_string();
735 let mut config = Config::default();
736 config.global.unfixable = vec!["MD001".to_string()];
737
738 let result = coordinator
739 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
740 .unwrap();
741
742 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
744 assert!(result.converged);
745 }
746
747 #[test]
748 fn test_fixable_allowlist() {
749 let coordinator = FixCoordinator::new();
750
751 let rules: Vec<Box<dyn Rule>> = vec![
752 Box::new(ConditionalFixRule {
753 name: "MD001",
754 check_fn: |content| content.contains('A'),
755 fix_fn: |content| content.replace('A', "X"),
756 }),
757 Box::new(ConditionalFixRule {
758 name: "MD002",
759 check_fn: |content| content.contains('B'),
760 fix_fn: |content| content.replace('B', "Y"),
761 }),
762 ];
763
764 let mut content = "AB".to_string();
765 let mut config = Config::default();
766 config.global.fixable = vec!["MD001".to_string()];
767
768 let result = coordinator
769 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
770 .unwrap();
771
772 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
774 }
775
776 #[test]
783 fn test_unfixable_rules_resolved_from_alias() {
784 let coordinator = FixCoordinator::new();
785
786 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
787 name: "MD001",
788 check_fn: |content| content.contains("BAD"),
789 fix_fn: |content| content.replace("BAD", "GOOD"),
790 })];
791
792 let mut content = "BAD content".to_string();
793 let mut config = Config::default();
794 config.global.unfixable = vec!["heading-increment".to_string()];
796 config.canonicalize_rule_lists();
798
799 let result = coordinator
800 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
801 .unwrap();
802
803 assert_eq!(content, "BAD content");
804 assert_eq!(result.rules_fixed, 0);
805 assert!(result.converged);
806 }
807
808 #[test]
812 fn test_fixable_allowlist_resolved_from_alias() {
813 let coordinator = FixCoordinator::new();
814
815 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
816 name: "MD001",
817 check_fn: |content| content.contains("BAD"),
818 fix_fn: |content| content.replace("BAD", "GOOD"),
819 })];
820
821 let mut content = "BAD content".to_string();
822 let mut config = Config::default();
823 config.global.fixable = vec!["heading-increment".to_string()];
824 config.canonicalize_rule_lists();
825
826 let result = coordinator
827 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
828 .unwrap();
829
830 assert_eq!(content, "GOOD content");
831 assert_eq!(result.rules_fixed, 1);
832 }
833
834 #[test]
835 fn test_max_iterations_limit() {
836 let coordinator = FixCoordinator::new();
837
838 static COUNTER: AtomicUsize = AtomicUsize::new(0);
840
841 #[derive(Clone)]
842 struct AlwaysChangeRule;
843 impl Rule for AlwaysChangeRule {
844 fn name(&self) -> &'static str {
845 "AlwaysChange"
846 }
847 fn check(&self, _: &LintContext) -> LintResult {
848 Ok(vec![LintWarning {
849 line: 1,
850 column: 1,
851 end_line: 1,
852 end_column: 1,
853 message: "Always".to_string(),
854 rule_name: Some("AlwaysChange".to_string()),
855 severity: Severity::Error,
856 fix: Some(Fix::new(0..0, String::new())),
857 }])
858 }
859 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
860 COUNTER.fetch_add(1, Ordering::SeqCst);
861 Ok(format!("{}x", ctx.content))
862 }
863 fn description(&self) -> &'static str {
864 "Always changes"
865 }
866 fn category(&self) -> RuleCategory {
867 RuleCategory::Whitespace
868 }
869 fn as_any(&self) -> &dyn std::any::Any {
870 self
871 }
872 }
873
874 COUNTER.store(0, Ordering::SeqCst);
875 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
876
877 let mut content = "test".to_string();
878 let config = Config::default();
879
880 let result = coordinator
881 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
882 .unwrap();
883
884 assert_eq!(result.iterations, 5);
886 assert!(!result.converged);
887 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
888 }
889
890 #[test]
891 fn test_empty_rules() {
892 let coordinator = FixCoordinator::new();
893 let rules: Vec<Box<dyn Rule>> = vec![];
894
895 let mut content = "unchanged".to_string();
896 let config = Config::default();
897
898 let result = coordinator
899 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
900 .unwrap();
901
902 assert_eq!(result.rules_fixed, 0);
903 assert_eq!(result.iterations, 1);
904 assert!(result.converged);
905 assert_eq!(content, "unchanged");
906 }
907
908 #[test]
909 fn test_no_warnings_no_changes() {
910 let coordinator = FixCoordinator::new();
911
912 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
914 name: "NoIssues",
915 check_fn: |_| false, fix_fn: |content| content.to_string(),
917 })];
918
919 let mut content = "clean content".to_string();
920 let config = Config::default();
921
922 let result = coordinator
923 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
924 .unwrap();
925
926 assert_eq!(content, "clean content");
927 assert_eq!(result.rules_fixed, 0);
928 assert!(result.converged);
929 }
930
931 #[test]
932 fn test_oscillation_detection() {
933 let coordinator = FixCoordinator::new();
937
938 let rules: Vec<Box<dyn Rule>> = vec![
939 Box::new(ConditionalFixRule {
940 name: "RuleA",
941 check_fn: |content| content.contains("foo"),
942 fix_fn: |content| content.replace("foo", "bar"),
943 }),
944 Box::new(ConditionalFixRule {
945 name: "RuleB",
946 check_fn: |content| content.contains("bar"),
947 fix_fn: |content| content.replace("bar", "foo"),
948 }),
949 ];
950
951 let mut content = "foo".to_string();
952 let config = Config::default();
953
954 let result = coordinator
955 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
956 .unwrap();
957
958 assert!(!result.converged, "Should not converge in an oscillating pair");
960 assert!(
961 result.iterations < 10,
962 "Cycle detection should stop well before max_iterations (got {})",
963 result.iterations
964 );
965
966 let mut conflicting = result.conflicting_rules.clone();
968 conflicting.sort();
969 assert_eq!(
970 conflicting,
971 vec!["RuleA".to_string(), "RuleB".to_string()],
972 "Both oscillating rules must be reported"
973 );
974 assert_eq!(
975 result.conflict_cycle,
976 vec!["RuleA".to_string(), "RuleB".to_string()],
977 "Cycle should preserve the observed application order"
978 );
979 }
980
981 #[test]
982 fn test_cyclic_dependencies_handled() {
983 let mut coordinator = FixCoordinator::new();
984
985 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
987 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
988 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
989
990 let rules: Vec<Box<dyn Rule>> = vec![
991 Box::new(MockRule {
992 name: "RuleA",
993 warnings: vec![],
994 fix_content: "".to_string(),
995 }),
996 Box::new(MockRule {
997 name: "RuleB",
998 warnings: vec![],
999 fix_content: "".to_string(),
1000 }),
1001 Box::new(MockRule {
1002 name: "RuleC",
1003 warnings: vec![],
1004 fix_content: "".to_string(),
1005 }),
1006 ];
1007
1008 let ordered = coordinator.get_optimal_order(&rules);
1010
1011 assert_eq!(ordered.len(), 3);
1013 }
1014
1015 #[test]
1016 fn test_fix_is_idempotent() {
1017 let coordinator = FixCoordinator::new();
1019
1020 let rules: Vec<Box<dyn Rule>> = vec![
1021 Box::new(ConditionalFixRule {
1022 name: "Rule1",
1023 check_fn: |content| content.contains('A'),
1024 fix_fn: |content| content.replace('A', "B"),
1025 }),
1026 Box::new(ConditionalFixRule {
1027 name: "Rule2",
1028 check_fn: |content| content.contains('B') && !content.contains('C'),
1029 fix_fn: |content| content.replace('B', "BC"),
1030 }),
1031 ];
1032
1033 let config = Config::default();
1034
1035 let mut content1 = "A".to_string();
1037 let result1 = coordinator
1038 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
1039 .unwrap();
1040
1041 let mut content2 = content1.clone();
1043 let result2 = coordinator
1044 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
1045 .unwrap();
1046
1047 assert_eq!(content1, content2);
1049 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
1050 assert!(result1.converged);
1051 assert!(result2.converged);
1052 }
1053
1054 #[test]
1055 fn test_apply_fixes_collapses_double_space_without_inline_override() {
1056 let mut content = String::from("`<svg>`. Fortunately\n");
1059 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1060 FixCoordinator::new()
1061 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1062 .unwrap();
1063 assert_eq!(
1064 content, "`<svg>`. Fortunately\n",
1065 "MD064 collapses the sentence double space when not overridden"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
1071 let coordinator = FixCoordinator::new();
1076 let rules: Vec<Box<dyn Rule>> = vec![
1077 Box::new(ConditionalFixRule {
1078 name: "MD004",
1079 check_fn: |c| c.contains('*'),
1080 fix_fn: |c| c.replace('*', "-"),
1081 }),
1082 Box::new(ConditionalFixRule {
1083 name: "MD032",
1084 check_fn: |c| c.contains("PARENT"),
1085 fix_fn: |c| c.replace("PARENT", "parent"),
1086 }),
1087 ];
1088
1089 let mut config = Config::default();
1090 config
1091 .per_file_ignores
1092 .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1093 config.canonicalize_rule_lists();
1094
1095 let mut content = "* PARENT".to_string();
1097 let result = coordinator
1098 .apply_fixes_iterative(
1099 &rules,
1100 &[],
1101 &mut content,
1102 &config,
1103 10,
1104 Some(std::path::Path::new("slides/deck.md")),
1105 )
1106 .unwrap();
1107 assert_eq!(
1108 content, "* parent",
1109 "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1110 );
1111 assert!(result.fixed_rule_names.contains("MD032"));
1112 assert!(!result.fixed_rule_names.contains("MD004"));
1113
1114 let mut other = "* PARENT".to_string();
1117 coordinator
1118 .apply_fixes_iterative(
1119 &rules,
1120 &[],
1121 &mut other,
1122 &config,
1123 10,
1124 Some(std::path::Path::new("docs/other.md")),
1125 )
1126 .unwrap();
1127 assert_eq!(
1128 other, "- parent",
1129 "both rules apply when the path is not per-file-ignored"
1130 );
1131 }
1132
1133 #[test]
1134 fn test_apply_fixes_honors_inline_configure_file_overrides() {
1135 let mut content = String::from(
1140 "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`. Fortunately\n",
1141 );
1142 let original = content.clone();
1143 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1144 FixCoordinator::new()
1145 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1146 .unwrap();
1147 assert_eq!(
1148 content, original,
1149 "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1150 );
1151 }
1152
1153 #[test]
1154 fn layout_rules_run_after_every_content_rewrite() {
1155 let rules = crate::rules::all_rules(&Config::default());
1161 let ordered = FixCoordinator::new().get_optimal_order(&rules);
1162 let names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
1163 assert_eq!(names.len(), rules.len(), "ordering must keep every rule exactly once");
1164
1165 let md013 = names.iter().position(|&n| n == "MD013").unwrap();
1166 let after: Vec<&str> = names[md013 + 1..].to_vec();
1167 let mut expected_after = vec!["MD009", "MD012"];
1168 expected_after.sort_unstable();
1169 let mut actual_after = after.clone();
1170 actual_after.sort_unstable();
1171 assert_eq!(
1172 actual_after, expected_after,
1173 "only MD013's own dependents may follow it; order was {names:?}"
1174 );
1175 }
1176
1177 #[test]
1178 fn link_style_rewrite_runs_before_reflow_regardless_of_input_order() {
1179 let coordinator = FixCoordinator::new();
1182 for input in [vec!["MD013", "MD054"], vec!["MD054", "MD013"]] {
1183 let rules: Vec<Box<dyn Rule>> = input
1184 .iter()
1185 .map(|name| crate::rules::create_rule_by_name(name, &Config::default()).unwrap())
1186 .collect();
1187 let names: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1188 assert_eq!(names, vec!["MD054", "MD013"], "input order {input:?}");
1189 }
1190 }
1191
1192 #[test]
1193 fn optimal_order_is_the_same_on_every_call() {
1194 let rules = crate::rules::all_rules(&Config::default());
1200 let coordinator = FixCoordinator::new();
1201 let first: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1202 for _ in 0..20 {
1203 let again: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1204 assert_eq!(again, first);
1205 }
1206 }
1207}