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
24pub fn config_allows_fix(config: &Config, rule_name: &str) -> bool {
30 let global = &config.global;
31 !global.unfixable.iter().any(|name| name == rule_name)
32 && (global.fixable.is_empty() || global.fixable.iter().any(|name| name == rule_name))
33}
34
35#[derive(Debug, Clone)]
40pub struct FixResult {
41 pub rules_fixed: usize,
43 pub iterations: usize,
45 pub context_creations: usize,
47 pub fixed_rule_names: HashSet<String>,
49 pub converged: bool,
51 pub conflicting_rules: Vec<String>,
55 pub conflict_cycle: Vec<String>,
59}
60
61fn hash_content(content: &str) -> u64 {
63 let mut hasher = DefaultHasher::new();
64 content.hash(&mut hasher);
65 hasher.finish()
66}
67
68pub struct FixCoordinator {
70 dependencies: HashMap<&'static str, Vec<&'static str>>,
72}
73
74impl Default for FixCoordinator {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80impl FixCoordinator {
81 pub fn new() -> Self {
82 let mut dependencies = HashMap::new();
83
84 dependencies.insert("MD064", vec!["MD010"]);
91
92 dependencies.insert("MD010", vec!["MD007", "MD005"]);
96
97 dependencies.insert("MD013", vec!["MD009", "MD012"]);
102
103 dependencies.insert("MD004", vec!["MD007"]);
106
107 dependencies.insert("MD022", vec!["MD012"]);
110 dependencies.insert("MD023", vec!["MD012"]);
111
112 dependencies.insert("MD070", vec!["MD040", "MD031"]);
116
117 dependencies.insert("MD005", vec!["MD032"]);
122 dependencies.insert("MD077", vec!["MD032"]);
123
124 Self { dependencies }
125 }
126
127 fn downstream_of(&self, roots: &[&'static str]) -> HashSet<&'static str> {
129 let mut closure: HashSet<&'static str> = HashSet::new();
130 let mut pending: Vec<&'static str> = roots.to_vec();
131 while let Some(name) = pending.pop() {
132 if !closure.insert(name) {
133 continue;
134 }
135 if let Some(dependents) = self.dependencies.get(name) {
136 pending.extend(dependents.iter().copied());
137 }
138 }
139 closure
140 }
141
142 pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
144 let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
146 let position: HashMap<&str, usize> = rules.iter().enumerate().map(|(i, r)| (r.name(), i)).collect();
152
153 let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
155 for (prereq, dependents) in &self.dependencies {
156 for dependent in dependents {
157 reverse_deps.entry(dependent).or_default().insert(prereq);
158 }
159 }
160
161 let downstream_of_layout = self.downstream_of(LAYOUT_RULES);
165 for &layout_rule in LAYOUT_RULES {
166 let prereqs = reverse_deps.entry(layout_rule).or_default();
167 prereqs.extend(
168 rule_map
169 .keys()
170 .copied()
171 .filter(|name| *name != layout_rule && !downstream_of_layout.contains(name)),
172 );
173 }
174
175 let mut sorted = Vec::new();
177 let mut visited: HashSet<&str> = HashSet::new();
178 let mut visiting: HashSet<&str> = HashSet::new();
179
180 fn visit<'a, 'b>(
181 rule_name: &'b str,
182 rule_map: &HashMap<&str, &'a dyn Rule>,
183 position: &HashMap<&str, usize>,
184 reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
185 visited: &mut HashSet<&'b str>,
186 visiting: &mut HashSet<&'b str>,
187 sorted: &mut Vec<&'a dyn Rule>,
188 ) where
189 'a: 'b,
190 {
191 if visited.contains(rule_name) {
192 return;
193 }
194
195 if visiting.contains(rule_name) {
196 return;
198 }
199
200 visiting.insert(rule_name);
201
202 if let Some(deps) = reverse_deps.get(rule_name) {
204 let mut deps: Vec<&'b str> = deps.iter().copied().filter(|dep| rule_map.contains_key(dep)).collect();
205 deps.sort_by_key(|dep| position[dep]);
206 for dep in deps {
207 visit(dep, rule_map, position, reverse_deps, visited, visiting, sorted);
208 }
209 }
210
211 visiting.remove(rule_name);
212 visited.insert(rule_name);
213
214 if let Some(&rule) = rule_map.get(rule_name) {
216 sorted.push(rule);
217 }
218 }
219
220 for rule in rules {
222 visit(
223 rule.name(),
224 &rule_map,
225 &position,
226 &reverse_deps,
227 &mut visited,
228 &mut visiting,
229 &mut sorted,
230 );
231 }
232
233 for rule in rules {
235 if !sorted.iter().any(|r| r.name() == rule.name()) {
236 sorted.push(rule.as_ref());
237 }
238 }
239
240 sorted
241 }
242
243 pub fn apply_fixes_iterative(
251 &self,
252 rules: &[Box<dyn Rule>],
253 all_warnings: &[LintWarning], content: &mut String,
255 config: &Config,
256 max_iterations: usize,
257 file_path: Option<&std::path::Path>,
258 ) -> Result<FixResult, String> {
259 self.apply_fixes_iterative_with_paths(
260 rules,
261 all_warnings,
262 content,
263 config,
264 max_iterations,
265 crate::DocumentPaths::same(file_path),
266 )
267 }
268
269 pub fn apply_fixes_iterative_with_paths(
275 &self,
276 rules: &[Box<dyn Rule>],
277 _all_warnings: &[LintWarning],
278 content: &mut String,
279 config: &Config,
280 max_iterations: usize,
281 paths: crate::DocumentPaths<'_>,
282 ) -> Result<FixResult, String> {
283 if crate::merge_conflict::detect_for_rules(content, rules, config, paths.config_path).is_some() {
284 return Ok(FixResult {
285 rules_fixed: 0,
286 iterations: 0,
287 context_creations: 0,
288 fixed_rule_names: HashSet::new(),
289 converged: true,
290 conflicting_rules: Vec::new(),
291 conflict_cycle: Vec::new(),
292 });
293 }
294
295 let max_iterations = max_iterations.min(MAX_ITERATIONS);
297
298 let ordered_rules = self.get_optimal_order(rules);
300
301 let mut total_fixed = 0;
302 let mut total_ctx_creations = 0;
303 let mut iterations = 0;
304
305 let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
308
309 let mut fixed_rule_names: HashSet<&str> = HashSet::new();
311
312 let ignored_for_file: HashSet<String> = paths
318 .config_path
319 .map(|p| config.get_ignored_rules_for_file(p))
320 .unwrap_or_default();
321
322 while iterations < max_iterations {
324 iterations += 1;
325
326 let flavor = paths
329 .config_path
330 .map_or_else(|| config.markdown_flavor(), |path| config.get_flavor_for_file(path));
331 let ctx = LintContext::new(content, flavor, paths.source_file.map(std::path::Path::to_path_buf));
332 total_ctx_creations += 1;
333
334 let recreated_rules: HashMap<String, Box<dyn Rule>> = {
340 let inline_overrides = ctx.inline_config().get_all_rule_configs();
341 if inline_overrides.is_empty() {
342 HashMap::new()
343 } else {
344 let merged = config.merge_with_inline_config(ctx.inline_config());
345 inline_overrides
346 .keys()
347 .filter_map(|name| {
348 crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
349 })
350 .collect()
351 }
352 };
353
354 let mut any_fix_applied = false;
355 let mut this_iter_rule: &str = "";
357
358 for rule in &ordered_rules {
360 if !config_allows_fix(config, rule.name()) {
361 continue;
362 }
363 if ignored_for_file.contains(rule.name()) {
365 continue;
366 }
367
368 let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
372
373 if effective_rule.should_skip(&ctx) {
375 continue;
376 }
377
378 let Ok(warnings) = effective_rule.check(&ctx) else {
380 continue;
381 };
382
383 if warnings.is_empty() {
384 continue;
385 }
386
387 let inline_config = ctx.inline_config();
389 let filtered_warnings =
390 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
391
392 if filtered_warnings.is_empty() {
393 continue;
394 }
395
396 let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
407 let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
408 if !has_inline_fix && !rule_advertises_fix {
409 continue;
410 }
411
412 match effective_rule.fix(&ctx) {
414 Ok(fixed_content) => {
415 if fixed_content != *content {
416 *content = fixed_content;
417 total_fixed += 1;
418 any_fix_applied = true;
419 this_iter_rule = rule.name();
420 fixed_rule_names.insert(rule.name());
421
422 break;
426 }
427 }
428 Err(_) => {
429 continue;
431 }
432 }
433 }
434
435 let current_hash = hash_content(content);
436
437 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
439 if cycle_start == history.len() - 1 {
440 return Ok(FixResult {
442 rules_fixed: total_fixed,
443 iterations,
444 context_creations: total_ctx_creations,
445 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
446 converged: true,
447 conflicting_rules: Vec::new(),
448 conflict_cycle: Vec::new(),
449 });
450 } else {
451 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
454 .iter()
455 .map(|(_, r)| r.to_string())
456 .chain(std::iter::once(this_iter_rule.to_string()))
457 .filter(|r| !r.is_empty())
458 .collect();
459 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
460 .iter()
461 .map(|(_, r)| *r)
462 .chain(std::iter::once(this_iter_rule))
463 .filter(|r| !r.is_empty())
464 .collect::<HashSet<&str>>()
465 .into_iter()
466 .map(std::string::ToString::to_string)
467 .collect();
468 return Ok(FixResult {
469 rules_fixed: total_fixed,
470 iterations,
471 context_creations: total_ctx_creations,
472 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
473 converged: false,
474 conflicting_rules,
475 conflict_cycle,
476 });
477 }
478 }
479
480 history.push((current_hash, this_iter_rule));
482
483 if !any_fix_applied {
485 return 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: true,
491 conflicting_rules: Vec::new(),
492 conflict_cycle: Vec::new(),
493 });
494 }
495 }
496
497 Ok(FixResult {
499 rules_fixed: total_fixed,
500 iterations,
501 context_creations: total_ctx_creations,
502 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
503 converged: false,
504 conflicting_rules: Vec::new(),
505 conflict_cycle: Vec::new(),
506 })
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
514 use std::sync::atomic::{AtomicUsize, Ordering};
515
516 #[derive(Clone)]
518 struct ConditionalFixRule {
519 name: &'static str,
520 check_fn: fn(&str) -> bool,
522 fix_fn: fn(&str) -> String,
524 }
525
526 impl Rule for ConditionalFixRule {
527 fn name(&self) -> &'static str {
528 self.name
529 }
530
531 fn check(&self, ctx: &LintContext) -> LintResult {
532 if (self.check_fn)(ctx.content) {
533 Ok(vec![LintWarning {
534 line: 1,
535 column: 1,
536 end_line: 1,
537 end_column: 1,
538 message: format!("{} issue found", self.name),
539 rule_name: Some(self.name.to_string()),
540 severity: Severity::Error,
541 fix: Some(Fix::new(0..0, String::new())),
542 }])
543 } else {
544 Ok(vec![])
545 }
546 }
547
548 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
549 Ok((self.fix_fn)(ctx.content))
550 }
551
552 fn description(&self) -> &'static str {
553 "Conditional fix rule for testing"
554 }
555
556 fn category(&self) -> RuleCategory {
557 RuleCategory::Whitespace
558 }
559
560 fn as_any(&self) -> &dyn std::any::Any {
561 self
562 }
563 }
564
565 #[derive(Clone)]
567 struct MockRule {
568 name: &'static str,
569 warnings: Vec<LintWarning>,
570 fix_content: String,
571 }
572
573 impl Rule for MockRule {
574 fn name(&self) -> &'static str {
575 self.name
576 }
577
578 fn check(&self, _ctx: &LintContext) -> LintResult {
579 Ok(self.warnings.clone())
580 }
581
582 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
583 Ok(self.fix_content.clone())
584 }
585
586 fn description(&self) -> &'static str {
587 "Mock rule for testing"
588 }
589
590 fn category(&self) -> RuleCategory {
591 RuleCategory::Whitespace
592 }
593
594 fn as_any(&self) -> &dyn std::any::Any {
595 self
596 }
597 }
598
599 #[test]
600 fn test_dependency_ordering() {
601 let coordinator = FixCoordinator::new();
602
603 let rules: Vec<Box<dyn Rule>> = vec![
604 Box::new(MockRule {
605 name: "MD009",
606 warnings: vec![],
607 fix_content: "".to_string(),
608 }),
609 Box::new(MockRule {
610 name: "MD013",
611 warnings: vec![],
612 fix_content: "".to_string(),
613 }),
614 Box::new(MockRule {
615 name: "MD010",
616 warnings: vec![],
617 fix_content: "".to_string(),
618 }),
619 Box::new(MockRule {
620 name: "MD007",
621 warnings: vec![],
622 fix_content: "".to_string(),
623 }),
624 ];
625
626 let ordered = coordinator.get_optimal_order(&rules);
627 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
628
629 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
631 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
632 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
633
634 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
636 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
637 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
638 }
639
640 #[test]
641 fn test_single_rule_fix() {
642 let coordinator = FixCoordinator::new();
643
644 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
646 name: "RemoveBad",
647 check_fn: |content| content.contains("BAD"),
648 fix_fn: |content| content.replace("BAD", "GOOD"),
649 })];
650
651 let mut content = "This is BAD content".to_string();
652 let config = Config::default();
653
654 let result = coordinator
655 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
656 .unwrap();
657
658 assert_eq!(content, "This is GOOD content");
659 assert_eq!(result.rules_fixed, 1);
660 assert!(result.converged);
661 }
662
663 #[test]
664 fn test_cascading_fixes() {
665 let coordinator = FixCoordinator::new();
669
670 let rules: Vec<Box<dyn Rule>> = vec![
671 Box::new(ConditionalFixRule {
672 name: "Rule1_IndentToFence",
673 check_fn: |content| content.contains("INDENT"),
674 fix_fn: |content| content.replace("INDENT", "FENCE"),
675 }),
676 Box::new(ConditionalFixRule {
677 name: "Rule2_FenceToLang",
678 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
679 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
680 }),
681 ];
682
683 let mut content = "Code: INDENT".to_string();
684 let config = Config::default();
685
686 let result = coordinator
687 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
688 .unwrap();
689
690 assert_eq!(content, "Code: FENCE_LANG");
692 assert_eq!(result.rules_fixed, 2);
693 assert!(result.converged);
694 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
695 }
696
697 #[test]
698 fn test_indirect_cascade() {
699 let coordinator = FixCoordinator::new();
704
705 let rules: Vec<Box<dyn Rule>> = vec![
706 Box::new(ConditionalFixRule {
707 name: "Rule1_AddBlank",
708 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
709 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
710 }),
711 Box::new(ConditionalFixRule {
712 name: "Rule2_CodeToFence",
713 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
715 fix_fn: |content| content.replace("CODE", "FENCE"),
716 }),
717 Box::new(ConditionalFixRule {
718 name: "Rule3_AddLang",
719 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
720 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
721 }),
722 ];
723
724 let mut content = "HEADING CODE".to_string();
725 let config = Config::default();
726
727 let result = coordinator
728 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
729 .unwrap();
730
731 assert_eq!(content, "HEADING BLANK FENCE_LANG");
733 assert_eq!(result.rules_fixed, 3);
734 assert!(result.converged);
735 }
736
737 #[test]
738 fn test_unfixable_rules_skipped() {
739 let coordinator = FixCoordinator::new();
740
741 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
742 name: "MD001",
743 check_fn: |content| content.contains("BAD"),
744 fix_fn: |content| content.replace("BAD", "GOOD"),
745 })];
746
747 let mut content = "BAD content".to_string();
748 let mut config = Config::default();
749 config.global.unfixable = vec!["MD001".to_string()];
750
751 let result = coordinator
752 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
753 .unwrap();
754
755 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
757 assert!(result.converged);
758 }
759
760 #[test]
761 fn test_fixable_allowlist() {
762 let coordinator = FixCoordinator::new();
763
764 let rules: Vec<Box<dyn Rule>> = vec![
765 Box::new(ConditionalFixRule {
766 name: "MD001",
767 check_fn: |content| content.contains('A'),
768 fix_fn: |content| content.replace('A', "X"),
769 }),
770 Box::new(ConditionalFixRule {
771 name: "MD002",
772 check_fn: |content| content.contains('B'),
773 fix_fn: |content| content.replace('B', "Y"),
774 }),
775 ];
776
777 let mut content = "AB".to_string();
778 let mut config = Config::default();
779 config.global.fixable = vec!["MD001".to_string()];
780
781 let result = coordinator
782 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
783 .unwrap();
784
785 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
787 }
788
789 #[test]
796 fn test_unfixable_rules_resolved_from_alias() {
797 let coordinator = FixCoordinator::new();
798
799 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
800 name: "MD001",
801 check_fn: |content| content.contains("BAD"),
802 fix_fn: |content| content.replace("BAD", "GOOD"),
803 })];
804
805 let mut content = "BAD content".to_string();
806 let mut config = Config::default();
807 config.global.unfixable = vec!["heading-increment".to_string()];
809 config.canonicalize_rule_lists();
811
812 let result = coordinator
813 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
814 .unwrap();
815
816 assert_eq!(content, "BAD content");
817 assert_eq!(result.rules_fixed, 0);
818 assert!(result.converged);
819 }
820
821 #[test]
825 fn test_fixable_allowlist_resolved_from_alias() {
826 let coordinator = FixCoordinator::new();
827
828 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
829 name: "MD001",
830 check_fn: |content| content.contains("BAD"),
831 fix_fn: |content| content.replace("BAD", "GOOD"),
832 })];
833
834 let mut content = "BAD content".to_string();
835 let mut config = Config::default();
836 config.global.fixable = vec!["heading-increment".to_string()];
837 config.canonicalize_rule_lists();
838
839 let result = coordinator
840 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
841 .unwrap();
842
843 assert_eq!(content, "GOOD content");
844 assert_eq!(result.rules_fixed, 1);
845 }
846
847 #[test]
848 fn test_max_iterations_limit() {
849 let coordinator = FixCoordinator::new();
850
851 static COUNTER: AtomicUsize = AtomicUsize::new(0);
853
854 #[derive(Clone)]
855 struct AlwaysChangeRule;
856 impl Rule for AlwaysChangeRule {
857 fn name(&self) -> &'static str {
858 "AlwaysChange"
859 }
860 fn check(&self, _: &LintContext) -> LintResult {
861 Ok(vec![LintWarning {
862 line: 1,
863 column: 1,
864 end_line: 1,
865 end_column: 1,
866 message: "Always".to_string(),
867 rule_name: Some("AlwaysChange".to_string()),
868 severity: Severity::Error,
869 fix: Some(Fix::new(0..0, String::new())),
870 }])
871 }
872 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
873 COUNTER.fetch_add(1, Ordering::SeqCst);
874 Ok(format!("{}x", ctx.content))
875 }
876 fn description(&self) -> &'static str {
877 "Always changes"
878 }
879 fn category(&self) -> RuleCategory {
880 RuleCategory::Whitespace
881 }
882 fn as_any(&self) -> &dyn std::any::Any {
883 self
884 }
885 }
886
887 COUNTER.store(0, Ordering::SeqCst);
888 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
889
890 let mut content = "test".to_string();
891 let config = Config::default();
892
893 let result = coordinator
894 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
895 .unwrap();
896
897 assert_eq!(result.iterations, 5);
899 assert!(!result.converged);
900 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
901 }
902
903 #[test]
904 fn test_empty_rules() {
905 let coordinator = FixCoordinator::new();
906 let rules: Vec<Box<dyn Rule>> = vec![];
907
908 let mut content = "unchanged".to_string();
909 let config = Config::default();
910
911 let result = coordinator
912 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
913 .unwrap();
914
915 assert_eq!(result.rules_fixed, 0);
916 assert_eq!(result.iterations, 1);
917 assert!(result.converged);
918 assert_eq!(content, "unchanged");
919 }
920
921 #[test]
922 fn test_no_warnings_no_changes() {
923 let coordinator = FixCoordinator::new();
924
925 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
927 name: "NoIssues",
928 check_fn: |_| false, fix_fn: |content| content.to_string(),
930 })];
931
932 let mut content = "clean content".to_string();
933 let config = Config::default();
934
935 let result = coordinator
936 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
937 .unwrap();
938
939 assert_eq!(content, "clean content");
940 assert_eq!(result.rules_fixed, 0);
941 assert!(result.converged);
942 }
943
944 #[test]
945 fn test_oscillation_detection() {
946 let coordinator = FixCoordinator::new();
950
951 let rules: Vec<Box<dyn Rule>> = vec![
952 Box::new(ConditionalFixRule {
953 name: "RuleA",
954 check_fn: |content| content.contains("foo"),
955 fix_fn: |content| content.replace("foo", "bar"),
956 }),
957 Box::new(ConditionalFixRule {
958 name: "RuleB",
959 check_fn: |content| content.contains("bar"),
960 fix_fn: |content| content.replace("bar", "foo"),
961 }),
962 ];
963
964 let mut content = "foo".to_string();
965 let config = Config::default();
966
967 let result = coordinator
968 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
969 .unwrap();
970
971 assert!(!result.converged, "Should not converge in an oscillating pair");
973 assert!(
974 result.iterations < 10,
975 "Cycle detection should stop well before max_iterations (got {})",
976 result.iterations
977 );
978
979 let mut conflicting = result.conflicting_rules.clone();
981 conflicting.sort();
982 assert_eq!(
983 conflicting,
984 vec!["RuleA".to_string(), "RuleB".to_string()],
985 "Both oscillating rules must be reported"
986 );
987 assert_eq!(
988 result.conflict_cycle,
989 vec!["RuleA".to_string(), "RuleB".to_string()],
990 "Cycle should preserve the observed application order"
991 );
992 }
993
994 #[test]
995 fn test_cyclic_dependencies_handled() {
996 let mut coordinator = FixCoordinator::new();
997
998 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
1000 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
1001 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
1002
1003 let rules: Vec<Box<dyn Rule>> = vec![
1004 Box::new(MockRule {
1005 name: "RuleA",
1006 warnings: vec![],
1007 fix_content: "".to_string(),
1008 }),
1009 Box::new(MockRule {
1010 name: "RuleB",
1011 warnings: vec![],
1012 fix_content: "".to_string(),
1013 }),
1014 Box::new(MockRule {
1015 name: "RuleC",
1016 warnings: vec![],
1017 fix_content: "".to_string(),
1018 }),
1019 ];
1020
1021 let ordered = coordinator.get_optimal_order(&rules);
1023
1024 assert_eq!(ordered.len(), 3);
1026 }
1027
1028 #[test]
1029 fn test_fix_is_idempotent() {
1030 let coordinator = FixCoordinator::new();
1032
1033 let rules: Vec<Box<dyn Rule>> = vec![
1034 Box::new(ConditionalFixRule {
1035 name: "Rule1",
1036 check_fn: |content| content.contains('A'),
1037 fix_fn: |content| content.replace('A', "B"),
1038 }),
1039 Box::new(ConditionalFixRule {
1040 name: "Rule2",
1041 check_fn: |content| content.contains('B') && !content.contains('C'),
1042 fix_fn: |content| content.replace('B', "BC"),
1043 }),
1044 ];
1045
1046 let config = Config::default();
1047
1048 let mut content1 = "A".to_string();
1050 let result1 = coordinator
1051 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
1052 .unwrap();
1053
1054 let mut content2 = content1.clone();
1056 let result2 = coordinator
1057 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
1058 .unwrap();
1059
1060 assert_eq!(content1, content2);
1062 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
1063 assert!(result1.converged);
1064 assert!(result2.converged);
1065 }
1066
1067 #[test]
1068 fn test_apply_fixes_collapses_double_space_without_inline_override() {
1069 let mut content = String::from("`<svg>`. Fortunately\n");
1072 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1073 FixCoordinator::new()
1074 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1075 .unwrap();
1076 assert_eq!(
1077 content, "`<svg>`. Fortunately\n",
1078 "MD064 collapses the sentence double space when not overridden"
1079 );
1080 }
1081
1082 #[test]
1083 fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
1084 let coordinator = FixCoordinator::new();
1089 let rules: Vec<Box<dyn Rule>> = vec![
1090 Box::new(ConditionalFixRule {
1091 name: "MD004",
1092 check_fn: |c| c.contains('*'),
1093 fix_fn: |c| c.replace('*', "-"),
1094 }),
1095 Box::new(ConditionalFixRule {
1096 name: "MD032",
1097 check_fn: |c| c.contains("PARENT"),
1098 fix_fn: |c| c.replace("PARENT", "parent"),
1099 }),
1100 ];
1101
1102 let mut config = Config::default();
1103 config
1104 .per_file_ignores
1105 .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1106 config.canonicalize_rule_lists();
1107
1108 let mut content = "* PARENT".to_string();
1110 let result = coordinator
1111 .apply_fixes_iterative(
1112 &rules,
1113 &[],
1114 &mut content,
1115 &config,
1116 10,
1117 Some(std::path::Path::new("slides/deck.md")),
1118 )
1119 .unwrap();
1120 assert_eq!(
1121 content, "* parent",
1122 "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1123 );
1124 assert!(result.fixed_rule_names.contains("MD032"));
1125 assert!(!result.fixed_rule_names.contains("MD004"));
1126
1127 let mut other = "* PARENT".to_string();
1130 coordinator
1131 .apply_fixes_iterative(
1132 &rules,
1133 &[],
1134 &mut other,
1135 &config,
1136 10,
1137 Some(std::path::Path::new("docs/other.md")),
1138 )
1139 .unwrap();
1140 assert_eq!(
1141 other, "- parent",
1142 "both rules apply when the path is not per-file-ignored"
1143 );
1144 }
1145
1146 #[test]
1147 fn test_apply_fixes_honors_inline_configure_file_overrides() {
1148 let mut content = String::from(
1153 "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`. Fortunately\n",
1154 );
1155 let original = content.clone();
1156 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1157 FixCoordinator::new()
1158 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1159 .unwrap();
1160 assert_eq!(
1161 content, original,
1162 "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1163 );
1164 }
1165
1166 #[test]
1167 fn layout_rules_run_after_every_content_rewrite() {
1168 let rules = crate::rules::all_rules(&Config::default());
1174 let ordered = FixCoordinator::new().get_optimal_order(&rules);
1175 let names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
1176 assert_eq!(names.len(), rules.len(), "ordering must keep every rule exactly once");
1177
1178 let md013 = names.iter().position(|&n| n == "MD013").unwrap();
1179 let after: Vec<&str> = names[md013 + 1..].to_vec();
1180 let mut expected_after = vec!["MD009", "MD012"];
1181 expected_after.sort_unstable();
1182 let mut actual_after = after.clone();
1183 actual_after.sort_unstable();
1184 assert_eq!(
1185 actual_after, expected_after,
1186 "only MD013's own dependents may follow it; order was {names:?}"
1187 );
1188 }
1189
1190 #[test]
1191 fn link_style_rewrite_runs_before_reflow_regardless_of_input_order() {
1192 let coordinator = FixCoordinator::new();
1195 for input in [vec!["MD013", "MD054"], vec!["MD054", "MD013"]] {
1196 let rules: Vec<Box<dyn Rule>> = input
1197 .iter()
1198 .map(|name| crate::rules::create_rule_by_name(name, &Config::default()).unwrap())
1199 .collect();
1200 let names: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1201 assert_eq!(names, vec!["MD054", "MD013"], "input order {input:?}");
1202 }
1203 }
1204
1205 #[test]
1206 fn optimal_order_is_the_same_on_every_call() {
1207 let rules = crate::rules::all_rules(&Config::default());
1213 let coordinator = FixCoordinator::new();
1214 let first: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1215 for _ in 0..20 {
1216 let again: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1217 assert_eq!(again, first);
1218 }
1219 }
1220}