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#[cfg(not(target_arch = "wasm32"))]
44struct FixLoopProfile {
45 contexts: std::cell::Cell<usize>,
46 parse: std::cell::Cell<std::time::Duration>,
47 rules: std::cell::RefCell<Vec<RuleFixProfile>>,
48}
49
50#[cfg(not(target_arch = "wasm32"))]
51struct RuleFixProfile {
52 name: &'static str,
53 checks: usize,
54 fix_calls: usize,
55 elapsed: std::time::Duration,
56}
57
58#[cfg(not(target_arch = "wasm32"))]
59impl FixLoopProfile {
60 fn from_env() -> Option<Self> {
61 std::env::var_os("RUMDL_PROFILE_RULES").map(|_| Self {
62 contexts: std::cell::Cell::new(0),
63 parse: std::cell::Cell::new(std::time::Duration::ZERO),
64 rules: std::cell::RefCell::new(Vec::new()),
65 })
66 }
67
68 fn record_context(&self, elapsed: std::time::Duration) {
69 self.contexts.set(self.contexts.get() + 1);
70 self.parse.set(self.parse.get() + elapsed);
71 }
72
73 fn with_rule(&self, name: &'static str, update: impl FnOnce(&mut RuleFixProfile)) {
74 let mut rules = self.rules.borrow_mut();
75 let index = rules.iter().position(|rule| rule.name == name).unwrap_or_else(|| {
76 rules.push(RuleFixProfile {
77 name,
78 checks: 0,
79 fix_calls: 0,
80 elapsed: std::time::Duration::ZERO,
81 });
82 rules.len() - 1
83 });
84 update(&mut rules[index]);
85 }
86
87 fn time<'a>(&'a self, name: &'static str) -> RuleFixTimer<'a> {
90 self.with_rule(name, |rule| rule.checks += 1);
91 RuleFixTimer {
92 profile: self,
93 name,
94 start: std::time::Instant::now(),
95 }
96 }
97
98 fn fix_attempted(&self, name: &'static str) {
99 self.with_rule(name, |rule| rule.fix_calls += 1);
100 }
101}
102
103#[cfg(not(target_arch = "wasm32"))]
104impl Drop for FixLoopProfile {
105 fn drop(&mut self) {
106 eprintln!(
107 "[FIX] {:6} {:?} ({} contexts)",
108 "parse",
109 self.parse.get(),
110 self.contexts.get()
111 );
112 for rule in self.rules.borrow().iter() {
113 eprintln!(
114 "[FIX] {:6} {:?} ({} checks, {} fix calls)",
115 rule.name, rule.elapsed, rule.checks, rule.fix_calls
116 );
117 }
118 }
119}
120
121#[cfg(not(target_arch = "wasm32"))]
122struct RuleFixTimer<'a> {
123 profile: &'a FixLoopProfile,
124 name: &'static str,
125 start: std::time::Instant,
126}
127
128#[cfg(not(target_arch = "wasm32"))]
129impl Drop for RuleFixTimer<'_> {
130 fn drop(&mut self) {
131 let elapsed = self.start.elapsed();
132 self.profile.with_rule(self.name, |rule| rule.elapsed += elapsed);
133 }
134}
135
136#[derive(Debug, Clone)]
141pub struct FixResult {
142 pub rules_fixed: usize,
144 pub iterations: usize,
146 pub context_creations: usize,
148 pub fixed_rule_names: HashSet<String>,
150 pub converged: bool,
152 pub conflicting_rules: Vec<String>,
156 pub conflict_cycle: Vec<String>,
160}
161
162fn hash_content(content: &str) -> u64 {
164 let mut hasher = DefaultHasher::new();
165 content.hash(&mut hasher);
166 hasher.finish()
167}
168
169pub struct FixCoordinator {
171 dependencies: HashMap<&'static str, Vec<&'static str>>,
173}
174
175impl Default for FixCoordinator {
176 fn default() -> Self {
177 Self::new()
178 }
179}
180
181impl FixCoordinator {
182 pub fn new() -> Self {
183 let mut dependencies = HashMap::new();
184
185 dependencies.insert("MD064", vec!["MD010"]);
192
193 dependencies.insert("MD010", vec!["MD007", "MD005"]);
197
198 dependencies.insert("MD013", vec!["MD009", "MD012"]);
203
204 dependencies.insert("MD004", vec!["MD007"]);
207
208 dependencies.insert("MD022", vec!["MD012"]);
211 dependencies.insert("MD023", vec!["MD012"]);
212
213 dependencies.insert("MD070", vec!["MD040", "MD031"]);
217
218 dependencies.insert("MD005", vec!["MD032"]);
223 dependencies.insert("MD077", vec!["MD032"]);
224
225 Self { dependencies }
226 }
227
228 fn downstream_of(&self, roots: &[&'static str]) -> HashSet<&'static str> {
230 let mut closure: HashSet<&'static str> = HashSet::new();
231 let mut pending: Vec<&'static str> = roots.to_vec();
232 while let Some(name) = pending.pop() {
233 if !closure.insert(name) {
234 continue;
235 }
236 if let Some(dependents) = self.dependencies.get(name) {
237 pending.extend(dependents.iter().copied());
238 }
239 }
240 closure
241 }
242
243 pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
245 let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
247 let position: HashMap<&str, usize> = rules.iter().enumerate().map(|(i, r)| (r.name(), i)).collect();
253
254 let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
256 for (prereq, dependents) in &self.dependencies {
257 for dependent in dependents {
258 reverse_deps.entry(dependent).or_default().insert(prereq);
259 }
260 }
261
262 let downstream_of_layout = self.downstream_of(LAYOUT_RULES);
266 for &layout_rule in LAYOUT_RULES {
267 let prereqs = reverse_deps.entry(layout_rule).or_default();
268 prereqs.extend(
269 rule_map
270 .keys()
271 .copied()
272 .filter(|name| *name != layout_rule && !downstream_of_layout.contains(name)),
273 );
274 }
275
276 let mut sorted = Vec::new();
278 let mut visited: HashSet<&str> = HashSet::new();
279 let mut visiting: HashSet<&str> = HashSet::new();
280
281 fn visit<'a, 'b>(
282 rule_name: &'b str,
283 rule_map: &HashMap<&str, &'a dyn Rule>,
284 position: &HashMap<&str, usize>,
285 reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
286 visited: &mut HashSet<&'b str>,
287 visiting: &mut HashSet<&'b str>,
288 sorted: &mut Vec<&'a dyn Rule>,
289 ) where
290 'a: 'b,
291 {
292 if visited.contains(rule_name) {
293 return;
294 }
295
296 if visiting.contains(rule_name) {
297 return;
299 }
300
301 visiting.insert(rule_name);
302
303 if let Some(deps) = reverse_deps.get(rule_name) {
305 let mut deps: Vec<&'b str> = deps.iter().copied().filter(|dep| rule_map.contains_key(dep)).collect();
306 deps.sort_by_key(|dep| position[dep]);
307 for dep in deps {
308 visit(dep, rule_map, position, reverse_deps, visited, visiting, sorted);
309 }
310 }
311
312 visiting.remove(rule_name);
313 visited.insert(rule_name);
314
315 if let Some(&rule) = rule_map.get(rule_name) {
317 sorted.push(rule);
318 }
319 }
320
321 for rule in rules {
323 visit(
324 rule.name(),
325 &rule_map,
326 &position,
327 &reverse_deps,
328 &mut visited,
329 &mut visiting,
330 &mut sorted,
331 );
332 }
333
334 for rule in rules {
336 if !sorted.iter().any(|r| r.name() == rule.name()) {
337 sorted.push(rule.as_ref());
338 }
339 }
340
341 sorted
342 }
343
344 pub fn apply_fixes_iterative(
355 &self,
356 rules: &[Box<dyn Rule>],
357 all_warnings: &[LintWarning], content: &mut String,
359 config: &Config,
360 max_iterations: usize,
361 file_path: Option<&std::path::Path>,
362 ) -> Result<FixResult, String> {
363 self.apply_fixes_iterative_with_paths(
364 rules,
365 all_warnings,
366 content,
367 config,
368 max_iterations,
369 crate::DocumentPaths::same(file_path),
370 )
371 }
372
373 pub fn apply_fixes_iterative_with_paths(
379 &self,
380 rules: &[Box<dyn Rule>],
381 _all_warnings: &[LintWarning],
382 content: &mut String,
383 config: &Config,
384 max_iterations: usize,
385 paths: crate::DocumentPaths<'_>,
386 ) -> Result<FixResult, String> {
387 if crate::merge_conflict::detect_for_rules(content, rules, config, paths.config_path).is_some() {
388 return Ok(FixResult {
389 rules_fixed: 0,
390 iterations: 0,
391 context_creations: 0,
392 fixed_rule_names: HashSet::new(),
393 converged: true,
394 conflicting_rules: Vec::new(),
395 conflict_cycle: Vec::new(),
396 });
397 }
398
399 let max_iterations = max_iterations.min(MAX_ITERATIONS);
401
402 let ordered_rules = self.get_optimal_order(rules);
404
405 let mut total_fixed = 0;
406 let mut total_ctx_creations = 0;
407 let mut iterations = 0;
408
409 let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
412
413 let mut fixed_rule_names: HashSet<&str> = HashSet::new();
415
416 let ignored_for_file: HashSet<String> = paths
422 .config_path
423 .map(|p| config.get_ignored_rules_for_file(p))
424 .unwrap_or_default();
425
426 let mut resume_at = 0;
430
431 #[cfg(not(target_arch = "wasm32"))]
432 let profile = FixLoopProfile::from_env();
433
434 while iterations < max_iterations {
436 iterations += 1;
437
438 let flavor = paths
441 .config_path
442 .map_or_else(|| config.markdown_flavor(), |path| config.get_flavor_for_file(path));
443 #[cfg(not(target_arch = "wasm32"))]
444 let parse_start = profile.as_ref().map(|_| std::time::Instant::now());
445 let ctx = crate::time_section!("fix: build context", {
446 LintContext::new(content, flavor, paths.source_file.map(std::path::Path::to_path_buf))
447 });
448 #[cfg(not(target_arch = "wasm32"))]
449 if let (Some(profile), Some(start)) = (profile.as_ref(), parse_start) {
450 profile.record_context(start.elapsed());
451 }
452 total_ctx_creations += 1;
453
454 let recreated_rules: HashMap<String, Box<dyn Rule>> = {
460 let inline_overrides = ctx.inline_config().get_all_rule_configs();
461 if inline_overrides.is_empty() {
462 HashMap::new()
463 } else {
464 let merged = config.merge_with_inline_config(ctx.inline_config());
465 inline_overrides
466 .keys()
467 .filter_map(|name| {
468 crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
469 })
470 .collect()
471 }
472 };
473
474 let mut any_fix_applied = false;
475 let mut this_iter_rule: &str = "";
477
478 let started_at = resume_at;
480 for (index, rule) in ordered_rules.iter().enumerate().skip(started_at) {
481 if !config_allows_fix(config, rule.name()) {
482 continue;
483 }
484 if ignored_for_file.contains(rule.name()) {
486 continue;
487 }
488
489 let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
493
494 if effective_rule.should_skip(&ctx) {
496 continue;
497 }
498
499 #[cfg(not(target_arch = "wasm32"))]
500 let _rule_timer = profile.as_ref().map(|profile| profile.time(rule.name()));
501
502 let Ok(warnings) = crate::time_section!("fix: check rules", { effective_rule.check(&ctx) }) else {
504 continue;
505 };
506
507 if warnings.is_empty() {
508 continue;
509 }
510
511 let inline_config = ctx.inline_config();
513 let filtered_warnings =
514 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
515
516 if filtered_warnings.is_empty() {
517 continue;
518 }
519
520 let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
531 let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
532 if !has_inline_fix && !rule_advertises_fix {
533 continue;
534 }
535
536 #[cfg(not(target_arch = "wasm32"))]
538 if let Some(profile) = profile.as_ref() {
539 profile.fix_attempted(rule.name());
540 }
541 match crate::time_section!("fix: apply rule fixes", { effective_rule.fix(&ctx) }) {
542 Ok(fixed_content) => {
543 if fixed_content != *content {
544 *content = fixed_content;
545 total_fixed += 1;
546 any_fix_applied = true;
547 this_iter_rule = rule.name();
548 fixed_rule_names.insert(rule.name());
549 resume_at = if index + 1 == ordered_rules.len() { 0 } else { index + 1 };
552 break;
553 }
554 }
555 Err(_) => {
556 continue;
558 }
559 }
560 }
561
562 if !any_fix_applied && started_at > 0 {
565 resume_at = 0;
566 continue;
567 }
568
569 let current_hash = hash_content(content);
570
571 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
573 if cycle_start == history.len() - 1 {
574 return Ok(FixResult {
576 rules_fixed: total_fixed,
577 iterations,
578 context_creations: total_ctx_creations,
579 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
580 converged: true,
581 conflicting_rules: Vec::new(),
582 conflict_cycle: Vec::new(),
583 });
584 } else {
585 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
588 .iter()
589 .map(|(_, r)| r.to_string())
590 .chain(std::iter::once(this_iter_rule.to_string()))
591 .filter(|r| !r.is_empty())
592 .collect();
593 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
594 .iter()
595 .map(|(_, r)| *r)
596 .chain(std::iter::once(this_iter_rule))
597 .filter(|r| !r.is_empty())
598 .collect::<HashSet<&str>>()
599 .into_iter()
600 .map(std::string::ToString::to_string)
601 .collect();
602 return Ok(FixResult {
603 rules_fixed: total_fixed,
604 iterations,
605 context_creations: total_ctx_creations,
606 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
607 converged: false,
608 conflicting_rules,
609 conflict_cycle,
610 });
611 }
612 }
613
614 history.push((current_hash, this_iter_rule));
616
617 if !any_fix_applied {
619 return Ok(FixResult {
620 rules_fixed: total_fixed,
621 iterations,
622 context_creations: total_ctx_creations,
623 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
624 converged: true,
625 conflicting_rules: Vec::new(),
626 conflict_cycle: Vec::new(),
627 });
628 }
629 }
630
631 Ok(FixResult {
633 rules_fixed: total_fixed,
634 iterations,
635 context_creations: total_ctx_creations,
636 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
637 converged: false,
638 conflicting_rules: Vec::new(),
639 conflict_cycle: Vec::new(),
640 })
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
648 use std::sync::atomic::{AtomicUsize, Ordering};
649
650 #[derive(Clone)]
652 struct ConditionalFixRule {
653 name: &'static str,
654 check_fn: fn(&str) -> bool,
656 fix_fn: fn(&str) -> String,
658 }
659
660 impl Rule for ConditionalFixRule {
661 fn name(&self) -> &'static str {
662 self.name
663 }
664
665 fn check(&self, ctx: &LintContext) -> LintResult {
666 if (self.check_fn)(ctx.content) {
667 Ok(vec![LintWarning {
668 line: 1,
669 column: 1,
670 end_line: 1,
671 end_column: 1,
672 message: format!("{} issue found", self.name),
673 rule_name: Some(self.name.to_string()),
674 severity: Severity::Error,
675 fix: Some(Fix::new(0..0, String::new())),
676 }])
677 } else {
678 Ok(vec![])
679 }
680 }
681
682 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
683 Ok((self.fix_fn)(ctx.content))
684 }
685
686 fn description(&self) -> &'static str {
687 "Conditional fix rule for testing"
688 }
689
690 fn category(&self) -> RuleCategory {
691 RuleCategory::Whitespace
692 }
693
694 fn as_any(&self) -> &dyn std::any::Any {
695 self
696 }
697 }
698
699 #[derive(Clone)]
701 struct MockRule {
702 name: &'static str,
703 warnings: Vec<LintWarning>,
704 fix_content: String,
705 }
706
707 impl Rule for MockRule {
708 fn name(&self) -> &'static str {
709 self.name
710 }
711
712 fn check(&self, _ctx: &LintContext) -> LintResult {
713 Ok(self.warnings.clone())
714 }
715
716 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
717 Ok(self.fix_content.clone())
718 }
719
720 fn description(&self) -> &'static str {
721 "Mock rule for testing"
722 }
723
724 fn category(&self) -> RuleCategory {
725 RuleCategory::Whitespace
726 }
727
728 fn as_any(&self) -> &dyn std::any::Any {
729 self
730 }
731 }
732
733 #[test]
734 fn test_dependency_ordering() {
735 let coordinator = FixCoordinator::new();
736
737 let rules: Vec<Box<dyn Rule>> = vec![
738 Box::new(MockRule {
739 name: "MD009",
740 warnings: vec![],
741 fix_content: "".to_string(),
742 }),
743 Box::new(MockRule {
744 name: "MD013",
745 warnings: vec![],
746 fix_content: "".to_string(),
747 }),
748 Box::new(MockRule {
749 name: "MD010",
750 warnings: vec![],
751 fix_content: "".to_string(),
752 }),
753 Box::new(MockRule {
754 name: "MD007",
755 warnings: vec![],
756 fix_content: "".to_string(),
757 }),
758 ];
759
760 let ordered = coordinator.get_optimal_order(&rules);
761 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
762
763 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
765 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
766 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
767
768 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
770 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
771 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
772 }
773
774 #[test]
775 fn test_single_rule_fix() {
776 let coordinator = FixCoordinator::new();
777
778 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
780 name: "RemoveBad",
781 check_fn: |content| content.contains("BAD"),
782 fix_fn: |content| content.replace("BAD", "GOOD"),
783 })];
784
785 let mut content = "This is BAD content".to_string();
786 let config = Config::default();
787
788 let result = coordinator
789 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
790 .unwrap();
791
792 assert_eq!(content, "This is GOOD content");
793 assert_eq!(result.rules_fixed, 1);
794 assert!(result.converged);
795 }
796
797 #[test]
798 fn test_cascading_fixes() {
799 let coordinator = FixCoordinator::new();
803
804 let rules: Vec<Box<dyn Rule>> = vec![
805 Box::new(ConditionalFixRule {
806 name: "Rule1_IndentToFence",
807 check_fn: |content| content.contains("INDENT"),
808 fix_fn: |content| content.replace("INDENT", "FENCE"),
809 }),
810 Box::new(ConditionalFixRule {
811 name: "Rule2_FenceToLang",
812 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
813 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
814 }),
815 ];
816
817 let mut content = "Code: INDENT".to_string();
818 let config = Config::default();
819
820 let result = coordinator
821 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
822 .unwrap();
823
824 assert_eq!(content, "Code: FENCE_LANG");
826 assert_eq!(result.rules_fixed, 2);
827 assert!(result.converged);
828 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
829 }
830
831 #[test]
832 fn a_rule_that_never_fires_is_checked_once_per_pass_not_once_per_fix() {
833 static QUIET_CHECKS: AtomicUsize = AtomicUsize::new(0);
834 let coordinator = FixCoordinator::new();
835
836 let mut rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
838 name: "Quiet",
839 check_fn: |_| {
840 QUIET_CHECKS.fetch_add(1, Ordering::SeqCst);
841 false
842 },
843 fix_fn: str::to_string,
844 })];
845 let fixers = [
846 ConditionalFixRule {
847 name: "FixA",
848 check_fn: |c| c.contains('a'),
849 fix_fn: |c| c.replace('a', "A"),
850 },
851 ConditionalFixRule {
852 name: "FixB",
853 check_fn: |c| c.contains('b'),
854 fix_fn: |c| c.replace('b', "B"),
855 },
856 ConditionalFixRule {
857 name: "FixC",
858 check_fn: |c| c.contains('c'),
859 fix_fn: |c| c.replace('c', "C"),
860 },
861 ConditionalFixRule {
862 name: "FixD",
863 check_fn: |c| c.contains('d'),
864 fix_fn: |c| c.replace('d', "D"),
865 },
866 ConditionalFixRule {
867 name: "FixE",
868 check_fn: |c| c.contains('e'),
869 fix_fn: |c| c.replace('e', "E"),
870 },
871 ];
872 rules.extend(fixers.map(|rule| Box::new(rule) as Box<dyn Rule>));
873
874 let mut content = "a b c d e".to_string();
875 let result = coordinator
876 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 100, None)
877 .unwrap();
878
879 assert_eq!(content, "A B C D E");
880 assert!(result.converged);
881 assert_eq!(result.rules_fixed, 5);
882 assert_eq!(QUIET_CHECKS.load(Ordering::SeqCst), 2);
885 }
886
887 #[test]
888 fn a_fix_that_creates_work_for_an_earlier_rule_is_picked_up_on_the_wrap() {
889 let coordinator = FixCoordinator::new();
890
891 let rules: Vec<Box<dyn Rule>> = vec![
895 Box::new(ConditionalFixRule {
896 name: "Early_YToZ",
897 check_fn: |content| content.contains('y'),
898 fix_fn: |content| content.replace('y', "z"),
899 }),
900 Box::new(ConditionalFixRule {
901 name: "Late_XToY",
902 check_fn: |content| content.contains('x'),
903 fix_fn: |content| content.replace('x', "y"),
904 }),
905 Box::new(ConditionalFixRule {
906 name: "Tail_Quiet",
907 check_fn: |_| false,
908 fix_fn: str::to_string,
909 }),
910 ];
911
912 let mut content = "x".to_string();
913 let result = coordinator
914 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 100, None)
915 .unwrap();
916
917 assert_eq!(content, "z");
918 assert!(result.converged);
919 assert_eq!(result.rules_fixed, 2);
920 }
921
922 #[test]
923 fn test_indirect_cascade() {
924 let coordinator = FixCoordinator::new();
929
930 let rules: Vec<Box<dyn Rule>> = vec![
931 Box::new(ConditionalFixRule {
932 name: "Rule1_AddBlank",
933 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
934 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
935 }),
936 Box::new(ConditionalFixRule {
937 name: "Rule2_CodeToFence",
938 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
940 fix_fn: |content| content.replace("CODE", "FENCE"),
941 }),
942 Box::new(ConditionalFixRule {
943 name: "Rule3_AddLang",
944 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
945 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
946 }),
947 ];
948
949 let mut content = "HEADING CODE".to_string();
950 let config = Config::default();
951
952 let result = coordinator
953 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
954 .unwrap();
955
956 assert_eq!(content, "HEADING BLANK FENCE_LANG");
958 assert_eq!(result.rules_fixed, 3);
959 assert!(result.converged);
960 }
961
962 #[test]
963 fn test_unfixable_rules_skipped() {
964 let coordinator = FixCoordinator::new();
965
966 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
967 name: "MD001",
968 check_fn: |content| content.contains("BAD"),
969 fix_fn: |content| content.replace("BAD", "GOOD"),
970 })];
971
972 let mut content = "BAD content".to_string();
973 let mut config = Config::default();
974 config.global.unfixable = vec!["MD001".to_string()];
975
976 let result = coordinator
977 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
978 .unwrap();
979
980 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
982 assert!(result.converged);
983 }
984
985 #[test]
986 fn test_fixable_allowlist() {
987 let coordinator = FixCoordinator::new();
988
989 let rules: Vec<Box<dyn Rule>> = vec![
990 Box::new(ConditionalFixRule {
991 name: "MD001",
992 check_fn: |content| content.contains('A'),
993 fix_fn: |content| content.replace('A', "X"),
994 }),
995 Box::new(ConditionalFixRule {
996 name: "MD002",
997 check_fn: |content| content.contains('B'),
998 fix_fn: |content| content.replace('B', "Y"),
999 }),
1000 ];
1001
1002 let mut content = "AB".to_string();
1003 let mut config = Config::default();
1004 config.global.fixable = vec!["MD001".to_string()];
1005
1006 let result = coordinator
1007 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1008 .unwrap();
1009
1010 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
1012 }
1013
1014 #[test]
1021 fn test_unfixable_rules_resolved_from_alias() {
1022 let coordinator = FixCoordinator::new();
1023
1024 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1025 name: "MD001",
1026 check_fn: |content| content.contains("BAD"),
1027 fix_fn: |content| content.replace("BAD", "GOOD"),
1028 })];
1029
1030 let mut content = "BAD content".to_string();
1031 let mut config = Config::default();
1032 config.global.unfixable = vec!["heading-increment".to_string()];
1034 config.canonicalize_rule_lists();
1036
1037 let result = coordinator
1038 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1039 .unwrap();
1040
1041 assert_eq!(content, "BAD content");
1042 assert_eq!(result.rules_fixed, 0);
1043 assert!(result.converged);
1044 }
1045
1046 #[test]
1050 fn test_fixable_allowlist_resolved_from_alias() {
1051 let coordinator = FixCoordinator::new();
1052
1053 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1054 name: "MD001",
1055 check_fn: |content| content.contains("BAD"),
1056 fix_fn: |content| content.replace("BAD", "GOOD"),
1057 })];
1058
1059 let mut content = "BAD content".to_string();
1060 let mut config = Config::default();
1061 config.global.fixable = vec!["heading-increment".to_string()];
1062 config.canonicalize_rule_lists();
1063
1064 let result = coordinator
1065 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1066 .unwrap();
1067
1068 assert_eq!(content, "GOOD content");
1069 assert_eq!(result.rules_fixed, 1);
1070 }
1071
1072 #[test]
1073 fn test_max_iterations_limit() {
1074 let coordinator = FixCoordinator::new();
1075
1076 static COUNTER: AtomicUsize = AtomicUsize::new(0);
1078
1079 #[derive(Clone)]
1080 struct AlwaysChangeRule;
1081 impl Rule for AlwaysChangeRule {
1082 fn name(&self) -> &'static str {
1083 "AlwaysChange"
1084 }
1085 fn check(&self, _: &LintContext) -> LintResult {
1086 Ok(vec![LintWarning {
1087 line: 1,
1088 column: 1,
1089 end_line: 1,
1090 end_column: 1,
1091 message: "Always".to_string(),
1092 rule_name: Some("AlwaysChange".to_string()),
1093 severity: Severity::Error,
1094 fix: Some(Fix::new(0..0, String::new())),
1095 }])
1096 }
1097 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1098 COUNTER.fetch_add(1, Ordering::SeqCst);
1099 Ok(format!("{}x", ctx.content))
1100 }
1101 fn description(&self) -> &'static str {
1102 "Always changes"
1103 }
1104 fn category(&self) -> RuleCategory {
1105 RuleCategory::Whitespace
1106 }
1107 fn as_any(&self) -> &dyn std::any::Any {
1108 self
1109 }
1110 }
1111
1112 COUNTER.store(0, Ordering::SeqCst);
1113 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
1114
1115 let mut content = "test".to_string();
1116 let config = Config::default();
1117
1118 let result = coordinator
1119 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1120 .unwrap();
1121
1122 assert_eq!(result.iterations, 5);
1124 assert!(!result.converged);
1125 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
1126 }
1127
1128 #[test]
1129 fn test_empty_rules() {
1130 let coordinator = FixCoordinator::new();
1131 let rules: Vec<Box<dyn Rule>> = vec![];
1132
1133 let mut content = "unchanged".to_string();
1134 let config = Config::default();
1135
1136 let result = coordinator
1137 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1138 .unwrap();
1139
1140 assert_eq!(result.rules_fixed, 0);
1141 assert_eq!(result.iterations, 1);
1142 assert!(result.converged);
1143 assert_eq!(content, "unchanged");
1144 }
1145
1146 #[test]
1147 fn test_no_warnings_no_changes() {
1148 let coordinator = FixCoordinator::new();
1149
1150 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1152 name: "NoIssues",
1153 check_fn: |_| false, fix_fn: |content| content.to_string(),
1155 })];
1156
1157 let mut content = "clean content".to_string();
1158 let config = Config::default();
1159
1160 let result = coordinator
1161 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1162 .unwrap();
1163
1164 assert_eq!(content, "clean content");
1165 assert_eq!(result.rules_fixed, 0);
1166 assert!(result.converged);
1167 }
1168
1169 #[test]
1170 fn test_oscillation_detection() {
1171 let coordinator = FixCoordinator::new();
1175
1176 let rules: Vec<Box<dyn Rule>> = vec![
1177 Box::new(ConditionalFixRule {
1178 name: "RuleA",
1179 check_fn: |content| content.contains("foo"),
1180 fix_fn: |content| content.replace("foo", "bar"),
1181 }),
1182 Box::new(ConditionalFixRule {
1183 name: "RuleB",
1184 check_fn: |content| content.contains("bar"),
1185 fix_fn: |content| content.replace("bar", "foo"),
1186 }),
1187 ];
1188
1189 let mut content = "foo".to_string();
1190 let config = Config::default();
1191
1192 let result = coordinator
1193 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
1194 .unwrap();
1195
1196 assert!(!result.converged, "Should not converge in an oscillating pair");
1198 assert!(
1199 result.iterations < 10,
1200 "Cycle detection should stop well before max_iterations (got {})",
1201 result.iterations
1202 );
1203
1204 let mut conflicting = result.conflicting_rules.clone();
1206 conflicting.sort();
1207 assert_eq!(
1208 conflicting,
1209 vec!["RuleA".to_string(), "RuleB".to_string()],
1210 "Both oscillating rules must be reported"
1211 );
1212 assert_eq!(
1213 result.conflict_cycle,
1214 vec!["RuleA".to_string(), "RuleB".to_string()],
1215 "Cycle should preserve the observed application order"
1216 );
1217 }
1218
1219 #[test]
1220 fn test_cyclic_dependencies_handled() {
1221 let mut coordinator = FixCoordinator::new();
1222
1223 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
1225 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
1226 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
1227
1228 let rules: Vec<Box<dyn Rule>> = vec![
1229 Box::new(MockRule {
1230 name: "RuleA",
1231 warnings: vec![],
1232 fix_content: "".to_string(),
1233 }),
1234 Box::new(MockRule {
1235 name: "RuleB",
1236 warnings: vec![],
1237 fix_content: "".to_string(),
1238 }),
1239 Box::new(MockRule {
1240 name: "RuleC",
1241 warnings: vec![],
1242 fix_content: "".to_string(),
1243 }),
1244 ];
1245
1246 let ordered = coordinator.get_optimal_order(&rules);
1248
1249 assert_eq!(ordered.len(), 3);
1251 }
1252
1253 #[test]
1254 fn test_fix_is_idempotent() {
1255 let coordinator = FixCoordinator::new();
1257
1258 let rules: Vec<Box<dyn Rule>> = vec![
1259 Box::new(ConditionalFixRule {
1260 name: "Rule1",
1261 check_fn: |content| content.contains('A'),
1262 fix_fn: |content| content.replace('A', "B"),
1263 }),
1264 Box::new(ConditionalFixRule {
1265 name: "Rule2",
1266 check_fn: |content| content.contains('B') && !content.contains('C'),
1267 fix_fn: |content| content.replace('B', "BC"),
1268 }),
1269 ];
1270
1271 let config = Config::default();
1272
1273 let mut content1 = "A".to_string();
1275 let result1 = coordinator
1276 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
1277 .unwrap();
1278
1279 let mut content2 = content1.clone();
1281 let result2 = coordinator
1282 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
1283 .unwrap();
1284
1285 assert_eq!(content1, content2);
1287 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
1288 assert!(result1.converged);
1289 assert!(result2.converged);
1290 }
1291
1292 #[test]
1293 fn test_apply_fixes_collapses_double_space_without_inline_override() {
1294 let mut content = String::from("`<svg>`. Fortunately\n");
1297 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1298 FixCoordinator::new()
1299 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1300 .unwrap();
1301 assert_eq!(
1302 content, "`<svg>`. Fortunately\n",
1303 "MD064 collapses the sentence double space when not overridden"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
1309 let coordinator = FixCoordinator::new();
1314 let rules: Vec<Box<dyn Rule>> = vec![
1315 Box::new(ConditionalFixRule {
1316 name: "MD004",
1317 check_fn: |c| c.contains('*'),
1318 fix_fn: |c| c.replace('*', "-"),
1319 }),
1320 Box::new(ConditionalFixRule {
1321 name: "MD032",
1322 check_fn: |c| c.contains("PARENT"),
1323 fix_fn: |c| c.replace("PARENT", "parent"),
1324 }),
1325 ];
1326
1327 let mut config = Config::default();
1328 config
1329 .per_file_ignores
1330 .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1331 config.canonicalize_rule_lists();
1332
1333 let mut content = "* PARENT".to_string();
1335 let result = coordinator
1336 .apply_fixes_iterative(
1337 &rules,
1338 &[],
1339 &mut content,
1340 &config,
1341 10,
1342 Some(std::path::Path::new("slides/deck.md")),
1343 )
1344 .unwrap();
1345 assert_eq!(
1346 content, "* parent",
1347 "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1348 );
1349 assert!(result.fixed_rule_names.contains("MD032"));
1350 assert!(!result.fixed_rule_names.contains("MD004"));
1351
1352 let mut other = "* PARENT".to_string();
1355 coordinator
1356 .apply_fixes_iterative(
1357 &rules,
1358 &[],
1359 &mut other,
1360 &config,
1361 10,
1362 Some(std::path::Path::new("docs/other.md")),
1363 )
1364 .unwrap();
1365 assert_eq!(
1366 other, "- parent",
1367 "both rules apply when the path is not per-file-ignored"
1368 );
1369 }
1370
1371 #[test]
1372 fn test_apply_fixes_honors_inline_configure_file_overrides() {
1373 let mut content = String::from(
1378 "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`. Fortunately\n",
1379 );
1380 let original = content.clone();
1381 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1382 FixCoordinator::new()
1383 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1384 .unwrap();
1385 assert_eq!(
1386 content, original,
1387 "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1388 );
1389 }
1390
1391 #[test]
1392 fn layout_rules_run_after_every_content_rewrite() {
1393 let rules = crate::rules::all_rules(&Config::default());
1399 let ordered = FixCoordinator::new().get_optimal_order(&rules);
1400 let names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
1401 assert_eq!(names.len(), rules.len(), "ordering must keep every rule exactly once");
1402
1403 let md013 = names.iter().position(|&n| n == "MD013").unwrap();
1404 let after: Vec<&str> = names[md013 + 1..].to_vec();
1405 let mut expected_after = vec!["MD009", "MD012"];
1406 expected_after.sort_unstable();
1407 let mut actual_after = after.clone();
1408 actual_after.sort_unstable();
1409 assert_eq!(
1410 actual_after, expected_after,
1411 "only MD013's own dependents may follow it; order was {names:?}"
1412 );
1413 }
1414
1415 #[test]
1416 fn link_style_rewrite_runs_before_reflow_regardless_of_input_order() {
1417 let coordinator = FixCoordinator::new();
1420 for input in [vec!["MD013", "MD054"], vec!["MD054", "MD013"]] {
1421 let rules: Vec<Box<dyn Rule>> = input
1422 .iter()
1423 .map(|name| crate::rules::create_rule_by_name(name, &Config::default()).unwrap())
1424 .collect();
1425 let names: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1426 assert_eq!(names, vec!["MD054", "MD013"], "input order {input:?}");
1427 }
1428 }
1429
1430 #[test]
1431 fn optimal_order_is_the_same_on_every_call() {
1432 let rules = crate::rules::all_rules(&Config::default());
1438 let coordinator = FixCoordinator::new();
1439 let first: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1440 for _ in 0..20 {
1441 let again: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1442 assert_eq!(again, first);
1443 }
1444 }
1445}