1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{Fix, FixCapability, LintWarning, Rule};
4use std::borrow::Cow;
5use std::cell::RefCell;
6use std::collections::hash_map::DefaultHasher;
7use std::collections::{HashMap, HashSet};
8use std::hash::{Hash, Hasher};
9use std::sync::LazyLock;
10
11#[cfg(test)]
12mod diagnostic_edits_tests;
13
14mod cleanup_effects;
15
16const MAX_ITERATIONS: usize = 100;
18
19const LAYOUT_RULES: &[&str] = &["MD013"];
31
32pub(crate) type FixCheckResults = HashMap<RuleCheckKey, FixPlan>;
35
36pub(crate) enum FixPlan {
37 Unchanged,
38 Edits(Vec<Fix>),
39 Custom,
40}
41
42impl FixPlan {
43 pub(crate) fn capture(rule: &dyn Rule, ctx: &LintContext<'_>, warnings: &[LintWarning]) -> Self {
44 let mut enabled = warnings
45 .iter()
46 .filter(|warning| crate::utils::fix_utils::warning_is_enabled(warning, ctx.inline_config(), rule.name()))
47 .peekable();
48 if enabled.peek().is_none() {
49 return Self::Unchanged;
50 }
51 if !rule.fixes_from_diagnostics() {
52 return Self::Custom;
53 }
54 let edits: Vec<_> = enabled.filter_map(|warning| warning.fix.clone()).collect();
55 if edits.is_empty() {
56 Self::Unchanged
57 } else {
58 Self::Edits(edits)
59 }
60 }
61}
62
63type RuleCheckKey = (std::any::TypeId, *const ());
66pub(crate) type RawRuleChecks = HashMap<RuleCheckKey, Vec<LintWarning>>;
67
68pub(crate) fn rule_check_key(rule: &dyn Rule) -> RuleCheckKey {
71 (rule.as_any().type_id(), std::ptr::from_ref(rule).cast::<()>())
73}
74type StableAnalysis<'a> = dyn FnMut(&LintContext<'_>, &mut RawRuleChecks) + 'a;
75
76pub fn config_allows_fix(config: &Config, rule_name: &str) -> bool {
82 let global = &config.global;
83 !global.unfixable.iter().any(|name| name == rule_name)
84 && (global.fixable.is_empty() || global.fixable.iter().any(|name| name == rule_name))
85}
86
87#[cfg(not(target_arch = "wasm32"))]
96struct FixLoopProfile {
97 contexts: std::cell::Cell<usize>,
98 handoffs: std::cell::Cell<usize>,
99 parse: std::cell::Cell<std::time::Duration>,
100 rules: std::cell::RefCell<Vec<RuleFixProfile>>,
101}
102
103#[cfg(not(target_arch = "wasm32"))]
104struct RuleFixProfile {
105 name: &'static str,
106 checks: usize,
107 fix_calls: usize,
108 elapsed: std::time::Duration,
109}
110
111#[cfg(not(target_arch = "wasm32"))]
112impl FixLoopProfile {
113 fn from_env() -> Option<Self> {
114 std::env::var_os("RUMDL_PROFILE_RULES").map(|_| Self {
115 contexts: std::cell::Cell::new(0),
116 handoffs: std::cell::Cell::new(0),
117 parse: std::cell::Cell::new(std::time::Duration::ZERO),
118 rules: std::cell::RefCell::new(Vec::new()),
119 })
120 }
121
122 fn record_context(&self, elapsed: std::time::Duration) {
123 self.contexts.set(self.contexts.get() + 1);
124 self.parse.set(self.parse.get() + elapsed);
125 }
126
127 fn with_rule(&self, name: &'static str, update: impl FnOnce(&mut RuleFixProfile)) {
128 let mut rules = self.rules.borrow_mut();
129 let index = rules.iter().position(|rule| rule.name == name).unwrap_or_else(|| {
130 rules.push(RuleFixProfile {
131 name,
132 checks: 0,
133 fix_calls: 0,
134 elapsed: std::time::Duration::ZERO,
135 });
136 rules.len() - 1
137 });
138 update(&mut rules[index]);
139 }
140
141 fn time<'a>(&'a self, name: &'static str) -> RuleFixTimer<'a> {
144 self.with_rule(name, |rule| rule.checks += 1);
145 RuleFixTimer {
146 profile: self,
147 name,
148 start: std::time::Instant::now(),
149 }
150 }
151
152 fn fix_attempted(&self, name: &'static str) {
153 self.with_rule(name, |rule| rule.fix_calls += 1);
154 }
155}
156
157#[cfg(not(target_arch = "wasm32"))]
158impl Drop for FixLoopProfile {
159 fn drop(&mut self) {
160 eprintln!(
161 "[FIX] {:6} {:?} ({} contexts)",
162 "parse",
163 self.parse.get(),
164 self.contexts.get()
165 );
166
167 eprintln!("[FIX] shared parser ({} handoffs)", self.handoffs.get());
168 for rule in self.rules.borrow().iter() {
169 eprintln!(
170 "[FIX] {:6} {:?} ({} checks, {} fix calls)",
171 rule.name, rule.elapsed, rule.checks, rule.fix_calls
172 );
173 }
174 }
175}
176
177#[cfg(not(target_arch = "wasm32"))]
178struct RuleFixTimer<'a> {
179 profile: &'a FixLoopProfile,
180 name: &'static str,
181 start: std::time::Instant,
182}
183
184#[cfg(not(target_arch = "wasm32"))]
185impl Drop for RuleFixTimer<'_> {
186 fn drop(&mut self) {
187 let elapsed = self.start.elapsed();
188 self.profile.with_rule(self.name, |rule| rule.elapsed += elapsed);
189 }
190}
191
192#[derive(Debug, Clone)]
197pub struct FixResult {
198 pub rules_fixed: usize,
200 pub iterations: usize,
202 pub context_creations: usize,
204 pub fixed_rule_names: HashSet<String>,
206 pub converged: bool,
208 pub conflicting_rules: Vec<String>,
212 pub conflict_cycle: Vec<String>,
216}
217
218fn hash_content(content: &str) -> u64 {
220 let mut hasher = DefaultHasher::new();
221 content.hash(&mut hasher);
222 hasher.finish()
223}
224
225pub struct FixCoordinator {
227 dependencies: Cow<'static, RuleDependencies>,
229 cleanup_effects: bool,
230}
231
232type RuleDependencies = HashMap<&'static str, Vec<&'static str>>;
233
234static DEFAULT_DEPENDENCIES: LazyLock<RuleDependencies> = LazyLock::new(FixCoordinator::build_dependencies);
235
236struct CachedRuleOrder {
237 names: Vec<&'static str>,
238 positions: Vec<usize>,
239}
240
241thread_local! {
242 static RULE_ORDER: RefCell<Option<CachedRuleOrder>> = const { RefCell::new(None) };
245}
246
247impl Default for FixCoordinator {
248 fn default() -> Self {
249 Self::new()
250 }
251}
252
253impl FixCoordinator {
254 pub fn new() -> Self {
255 Self {
256 dependencies: Cow::Borrowed(&DEFAULT_DEPENDENCIES),
257 cleanup_effects: false,
258 }
259 }
260
261 pub fn for_formatter() -> Self {
263 Self {
264 cleanup_effects: true,
265 ..Self::new()
266 }
267 }
268
269 fn build_dependencies() -> RuleDependencies {
270 let mut dependencies = HashMap::new();
271
272 dependencies.insert("MD064", vec!["MD010"]);
279
280 dependencies.insert("MD010", vec!["MD007", "MD005"]);
284
285 dependencies.insert("MD013", vec!["MD009", "MD012"]);
290
291 dependencies.insert("MD004", vec!["MD007"]);
294
295 dependencies.insert("MD022", vec!["MD012"]);
298 dependencies.insert("MD023", vec!["MD012"]);
299
300 dependencies.insert("MD070", vec!["MD040", "MD031"]);
304
305 dependencies.insert("MD005", vec!["MD032"]);
310 dependencies.insert("MD077", vec!["MD032"]);
311
312 dependencies
313 }
314
315 fn downstream_of(&self, roots: &[&'static str]) -> HashSet<&'static str> {
317 let mut closure: HashSet<&'static str> = HashSet::new();
318 let mut pending: Vec<&'static str> = roots.to_vec();
319 while let Some(name) = pending.pop() {
320 if !closure.insert(name) {
321 continue;
322 }
323 if let Some(dependents) = self.dependencies.get(name) {
324 pending.extend(dependents.iter().copied());
325 }
326 }
327 closure
328 }
329
330 pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
332 let shared_graph = matches!(&self.dependencies, Cow::Borrowed(graph) if std::ptr::eq(*graph, LazyLock::force(&DEFAULT_DEPENDENCIES)));
335 if shared_graph
336 && let Some(ordered) = RULE_ORDER.with_borrow(|cache| {
337 let cache = cache.as_ref()?;
338 (cache.names.len() == rules.len()
339 && cache.names.iter().zip(rules).all(|(name, rule)| *name == rule.name()))
340 .then(|| cache.positions.iter().map(|&index| rules[index].as_ref()).collect())
341 })
342 {
343 return ordered;
344 }
345 let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
347 let position: HashMap<&str, usize> = rules.iter().enumerate().map(|(i, r)| (r.name(), i)).collect();
353
354 let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
356 for (prereq, dependents) in self.dependencies.iter() {
357 for dependent in dependents {
358 reverse_deps.entry(dependent).or_default().insert(prereq);
359 }
360 }
361
362 let downstream_of_layout = self.downstream_of(LAYOUT_RULES);
366 for &layout_rule in LAYOUT_RULES {
367 let prereqs = reverse_deps.entry(layout_rule).or_default();
368 prereqs.extend(
369 rule_map
370 .keys()
371 .copied()
372 .filter(|name| *name != layout_rule && !downstream_of_layout.contains(name)),
373 );
374 }
375
376 let mut sorted = Vec::new();
378 let mut visited: HashSet<&str> = HashSet::new();
379 let mut visiting: HashSet<&str> = HashSet::new();
380
381 fn visit<'a, 'b>(
382 rule_name: &'b str,
383 rule_map: &HashMap<&str, &'a dyn Rule>,
384 position: &HashMap<&str, usize>,
385 reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
386 visited: &mut HashSet<&'b str>,
387 visiting: &mut HashSet<&'b str>,
388 sorted: &mut Vec<&'a dyn Rule>,
389 ) where
390 'a: 'b,
391 {
392 if visited.contains(rule_name) {
393 return;
394 }
395
396 if visiting.contains(rule_name) {
397 return;
399 }
400
401 visiting.insert(rule_name);
402
403 if let Some(deps) = reverse_deps.get(rule_name) {
405 let mut deps: Vec<&'b str> = deps.iter().copied().filter(|dep| rule_map.contains_key(dep)).collect();
406 deps.sort_by_key(|dep| position[dep]);
407 for dep in deps {
408 visit(dep, rule_map, position, reverse_deps, visited, visiting, sorted);
409 }
410 }
411
412 visiting.remove(rule_name);
413 visited.insert(rule_name);
414
415 if let Some(&rule) = rule_map.get(rule_name) {
417 sorted.push(rule);
418 }
419 }
420
421 for rule in rules {
423 visit(
424 rule.name(),
425 &rule_map,
426 &position,
427 &reverse_deps,
428 &mut visited,
429 &mut visiting,
430 &mut sorted,
431 );
432 }
433
434 for rule in rules {
436 if !sorted.iter().any(|r| r.name() == rule.name()) {
437 sorted.push(rule.as_ref());
438 }
439 }
440
441 if shared_graph {
442 RULE_ORDER.with_borrow_mut(|cache| {
443 *cache = Some(CachedRuleOrder {
444 names: rules.iter().map(|rule| rule.name()).collect(),
445 positions: sorted.iter().map(|rule| position[rule.name()]).collect(),
446 });
447 });
448 }
449 sorted
450 }
451
452 pub fn apply_fixes_iterative(
463 &self,
464 rules: &[Box<dyn Rule>],
465 all_warnings: &[LintWarning], content: &mut String,
467 config: &Config,
468 max_iterations: usize,
469 file_path: Option<&std::path::Path>,
470 ) -> Result<FixResult, String> {
471 self.apply_fixes_iterative_with_paths(
472 rules,
473 all_warnings,
474 content,
475 config,
476 max_iterations,
477 crate::DocumentPaths::same(file_path),
478 )
479 }
480
481 pub fn apply_fixes_iterative_with_paths(
487 &self,
488 rules: &[Box<dyn Rule>],
489 _all_warnings: &[LintWarning],
490 content: &mut String,
491 config: &Config,
492 max_iterations: usize,
493 paths: crate::DocumentPaths<'_>,
494 ) -> Result<FixResult, String> {
495 let had_bom = crate::utils::bom::has_bom(content);
497 if had_bom {
498 content.remove(0);
499 }
500 let result = self.apply_fixes_with_analysis(rules, content, config, max_iterations, paths, None, None);
501 if had_bom {
502 content.insert(0, crate::utils::bom::BOM);
503 }
504 Ok(result)
505 }
506
507 pub fn apply_fixes_to_fragment(
511 &self,
512 rules: &[Box<dyn Rule>],
513 content: &mut String,
514 config: &Config,
515 max_iterations: usize,
516 ) -> FixResult {
517 self.apply_fixes_with_analysis(
518 rules,
519 content,
520 config,
521 max_iterations,
522 crate::DocumentPaths::same(None),
523 None,
524 None,
525 )
526 }
527
528 #[allow(clippy::too_many_arguments)]
532 pub(crate) fn apply_fixes_with_analysis(
533 &self,
534 rules: &[Box<dyn Rule>],
535 content: &mut String,
536 config: &Config,
537 max_iterations: usize,
538 paths: crate::DocumentPaths<'_>,
539 initial: Option<(&LintContext<'_>, &FixCheckResults)>,
540 mut on_stable: Option<&mut StableAnalysis<'_>>,
541 ) -> FixResult {
542 let initial = initial.filter(|(ctx, _)| ctx.content == content);
544 let conflict = initial.map_or_else(
545 || crate::merge_conflict::detect_for_analysis(content, rules, config, paths.config_path),
546 |(ctx, _)| crate::merge_conflict::detect_for_context(ctx, rules, config, paths.config_path),
547 );
548 if conflict.is_some() {
549 return FixResult {
550 rules_fixed: 0,
551 iterations: 0,
552 context_creations: 0,
553 fixed_rule_names: HashSet::new(),
554 converged: true,
555 conflicting_rules: Vec::new(),
556 conflict_cycle: Vec::new(),
557 };
558 }
559
560 let max_iterations = max_iterations.min(MAX_ITERATIONS);
562
563 let ordered_rules = self.get_optimal_order(rules);
565
566 let mut total_fixed = 0;
567 let mut total_ctx_creations = 0;
568 let mut iterations = 0;
569
570 let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
573
574 let mut fixed_rule_names: HashSet<&str> = HashSet::new();
576
577 let ignored_for_file: HashSet<String> = paths
583 .config_path
584 .map(|p| config.get_ignored_rules_for_file(p))
585 .unwrap_or_default();
586
587 let proof_policies: Vec<_> = ordered_rules
588 .iter()
589 .map(|rule| crate::format_proof::NativeFormatPolicy::validated(*rule))
590 .collect();
591
592 let mut resume_at = 0;
596
597 let mut parse_handoff = None;
598
599 #[cfg(not(target_arch = "wasm32"))]
600 let profile = FixLoopProfile::from_env();
601
602 while iterations < max_iterations {
604 iterations += 1;
605
606 let flavor = paths
609 .config_path
610 .map_or_else(|| config.markdown_flavor(), |path| config.get_flavor_for_file(path));
611
612 let mut proof = if initial.is_none() && on_stable.is_none() {
613 if parse_handoff.is_some() {
614 crate::format_proof::FormatProof::analyze_handoff(content, flavor, &mut parse_handoff)
615 } else {
616 crate::format_proof::FormatProof::analyze(content, flavor)
617 }
618 } else {
619 crate::format_proof::FormatProof::default()
620 };
621
622 let proof_skips: Vec<_> = proof_policies
623 .iter()
624 .map(|policy| policy.is_some_and(|policy| policy.proven(&proof)))
625 .collect();
626
627 let has_pending = ordered_rules.iter().enumerate().any(|(index, rule)| {
628 config_allows_fix(config, rule.name())
629 && !ignored_for_file.contains(rule.name())
630 && rule.fix_capability() != FixCapability::Unfixable
631 && !proof_skips[index]
632 });
633 if !has_pending
634 && proof.eligible()
635 && initial.is_none()
636 && on_stable.is_none()
637 && !crate::inline_config::has_inline_config_markers(content)
638 {
639 return FixResult {
640 rules_fixed: total_fixed,
641 iterations,
642 context_creations: total_ctx_creations,
643 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
644 converged: true,
645 conflicting_rules: Vec::new(),
646 conflict_cycle: Vec::new(),
647 };
648 }
649 #[cfg(not(target_arch = "wasm32"))]
650 let parse_start = profile.as_ref().map(|_| std::time::Instant::now());
651 let reused = initial.filter(|_| iterations == 1);
652 let mut fresh_ctx = None;
653 let ctx = if let Some((ctx, _)) = reused {
654 ctx
655 } else {
656 #[cfg(not(target_arch = "wasm32"))]
657 if parse_handoff.is_some()
658 && let Some(profile) = profile.as_ref()
659 {
660 profile.handoffs.set(profile.handoffs.get() + 1);
661 }
662 let ctx = fresh_ctx.insert(crate::time_section!("fix: build context", {
663 LintContext::new_with_proof(
664 content,
665 flavor,
666 paths.source_file.map(std::path::Path::to_path_buf),
667 proof.take_parsed(),
668 parse_handoff.take(),
669 )
670 }));
671 #[cfg(not(target_arch = "wasm32"))]
672 if let (Some(profile), Some(start)) = (profile.as_ref(), parse_start) {
673 profile.record_context(start.elapsed());
674 }
675 total_ctx_creations += 1;
676 ctx
677 };
678
679 let recreated_rules: HashMap<String, Box<dyn Rule>> = {
685 let inline_overrides = ctx.inline_config().get_all_rule_configs();
686 if inline_overrides.is_empty() {
687 HashMap::new()
688 } else {
689 let merged = config.merge_with_inline_config(ctx.inline_config());
690 inline_overrides
691 .keys()
692 .filter_map(|name| {
693 crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
694 })
695 .collect()
696 }
697 };
698
699 let mut current_checks = RawRuleChecks::new();
700 let mut next_content = None;
701 let mut any_fix_applied = false;
702 let mut this_iter_rule: &str = "";
704
705 let configuration_only =
709 initial.is_none() && on_stable.is_none() && ctx.inline_config().get_all_rule_configs().is_empty();
710
711 let cleanup_enabled =
712 self.cleanup_effects && initial.is_none() && on_stable.is_none() && cleanup_effects::eligible(ctx);
713
714 let mut cleanup = cleanup_effects::CleanupBatch::default();
715
716 let scan = ordered_rules
720 .iter()
721 .enumerate()
722 .skip(resume_at)
723 .chain(ordered_rules.iter().enumerate().take(resume_at));
724 for (index, rule) in scan {
725 if !config_allows_fix(config, rule.name()) {
726 continue;
727 }
728 if ignored_for_file.contains(rule.name()) {
730 continue;
731 }
732 let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
736
737 if effective_rule.fix_capability() == FixCapability::Unfixable {
742 continue;
743 }
744
745 if configuration_only
746 && crate::format_proof::NativeFormatPolicy::validated(effective_rule)
747 .is_some_and(crate::format_proof::NativeFormatPolicy::configuration_only)
748 {
749 continue;
750 }
751 if !cleanup.is_empty() && !cleanup.can_check(index, effective_rule, ctx) {
756 break;
757 }
758
759 if proof_skips[index] && !recreated_rules.contains_key(rule.name()) {
760 continue;
761 }
762 if effective_rule.should_skip(ctx) {
763 continue;
764 }
765
766 #[cfg(not(target_arch = "wasm32"))]
767 let _rule_timer = profile.as_ref().map(|profile| profile.time(rule.name()));
768
769 let fresh_plan;
772 let mut cleanup_edits = None;
773 let mut prepared_native_fix = None;
774 let mut checked_warnings = None;
775 let plan = match reused.and_then(|(_, checks)| checks.get(&rule_check_key(*rule))) {
776 Some(plan) => plan,
777 None => {
778 let Ok(warnings) = crate::time_section!("fix: check rules", {
779 effective_rule.check_with_prepared_fix(ctx).map(|(warnings, prepared)| {
780 prepared_native_fix = prepared.map(Ok);
781 warnings
782 })
783 }) else {
784 if !cleanup.is_empty() {
785 break;
786 }
787 continue;
788 };
789 fresh_plan = FixPlan::capture(effective_rule, ctx, &warnings);
790
791 if cleanup_enabled {
792 cleanup_edits = cleanup_effects::capture_with_native(
793 effective_rule,
794 ctx,
795 &warnings,
796 &mut prepared_native_fix,
797 );
798 }
799 if on_stable.is_some() {
800 current_checks.insert(rule_check_key(*rule), warnings);
801 } else {
802 checked_warnings = Some(warnings);
803 }
804 &fresh_plan
805 }
806 };
807 if matches!(plan, FixPlan::Unchanged) {
808 continue;
809 }
810
811 if cleanup_enabled {
812 if let Some(edits) = cleanup_edits
813 && cleanup.try_push(ctx.content, index, effective_rule.name(), edits, &history)
814 {
815 #[cfg(not(target_arch = "wasm32"))]
816 if let Some(profile) = profile.as_ref() {
817 profile.fix_attempted(rule.name());
818 }
819 if cleanup.terminal() || cleanup.len() > max_iterations - iterations {
820 break;
821 }
822 continue;
823 }
824 if !cleanup.is_empty() {
825 break;
826 }
827 }
828
829 #[cfg(not(target_arch = "wasm32"))]
831 if let Some(profile) = profile.as_ref() {
832 profile.fix_attempted(rule.name());
833 }
834 let fixed = crate::time_section!("fix: apply rule fixes", {
835 match plan {
836 FixPlan::Edits(edits) => crate::utils::fix_utils::apply_edits(ctx.content, edits.iter())
837 .map(Cow::into_owned)
838 .map_err(crate::rule::LintError::FixFailed),
839 FixPlan::Custom => prepared_native_fix.unwrap_or_else(|| {
840 checked_warnings.as_deref().map_or_else(
841 || effective_rule.fix(ctx),
842 |warnings| effective_rule.fix_with_diagnostics(ctx, warnings),
843 )
844 }),
845 FixPlan::Unchanged => unreachable!(),
846 }
847 });
848 match fixed {
849 Ok(fixed_content) => {
850 if fixed_content != *content {
851 parse_handoff = ctx.take_parse_handoff(&fixed_content);
852 next_content = Some(fixed_content);
853 total_fixed += 1;
854 any_fix_applied = true;
855 this_iter_rule = rule.name();
856 fixed_rule_names.insert(rule.name());
857 resume_at = if index + 1 == ordered_rules.len() { 0 } else { index + 1 };
860 break;
861 }
862 }
863 Err(_) => {
864 continue;
866 }
867 }
868 }
869
870 if let Some(batch) = cleanup.finish() {
871 let count = batch.stages.len();
872 for &(hash, name, _) in batch.stages.iter().take(count - 1) {
873 history.push((hash, name));
874 }
875 for &(_, name, _) in &batch.stages {
876 fixed_rule_names.insert(name);
877 }
878 let &(_, name, index) = batch.stages.last().unwrap();
879 this_iter_rule = name;
880 resume_at = if index + 1 == ordered_rules.len() { 0 } else { index + 1 };
881 total_fixed += count;
882 iterations += count - 1;
883 any_fix_applied = true;
884
885 parse_handoff = ctx.take_parse_handoff(&batch.content);
889 next_content = Some(batch.content);
890 }
891
892 if !any_fix_applied && let Some(analyze) = &mut on_stable {
893 analyze(ctx, &mut current_checks);
894 }
895
896 if let Some(fixed) = next_content {
897 *content = fixed;
898 }
899 let current_hash = hash_content(content);
900
901 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
903 if cycle_start == history.len() - 1 {
904 return FixResult {
906 rules_fixed: total_fixed,
907 iterations,
908 context_creations: total_ctx_creations,
909 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
910 converged: true,
911 conflicting_rules: Vec::new(),
912 conflict_cycle: Vec::new(),
913 };
914 } else {
915 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
918 .iter()
919 .map(|(_, r)| r.to_string())
920 .chain(std::iter::once(this_iter_rule.to_string()))
921 .filter(|r| !r.is_empty())
922 .collect();
923 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
924 .iter()
925 .map(|(_, r)| *r)
926 .chain(std::iter::once(this_iter_rule))
927 .filter(|r| !r.is_empty())
928 .collect::<HashSet<&str>>()
929 .into_iter()
930 .map(std::string::ToString::to_string)
931 .collect();
932 return FixResult {
933 rules_fixed: total_fixed,
934 iterations,
935 context_creations: total_ctx_creations,
936 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
937 converged: false,
938 conflicting_rules,
939 conflict_cycle,
940 };
941 }
942 }
943
944 history.push((current_hash, this_iter_rule));
946
947 if !any_fix_applied {
949 return FixResult {
950 rules_fixed: total_fixed,
951 iterations,
952 context_creations: total_ctx_creations,
953 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
954 converged: true,
955 conflicting_rules: Vec::new(),
956 conflict_cycle: Vec::new(),
957 };
958 }
959 }
960
961 FixResult {
963 rules_fixed: total_fixed,
964 iterations,
965 context_creations: total_ctx_creations,
966 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
967 converged: false,
968 conflicting_rules: Vec::new(),
969 conflict_cycle: Vec::new(),
970 }
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 #[test]
977 fn conflict_guard_discards_analysis_from_another_content_version() {
978 let config = super::Config::default();
979 let rules = crate::rules::all_rules(&config);
980 let old = super::LintContext::new("# Safe\n", config.markdown_flavor(), None);
981 let checks = super::FixCheckResults::new();
982 let original = "<<<<<<< HEAD\ntext \n";
983 let mut content = original.to_string();
984 let result = super::FixCoordinator::new().apply_fixes_with_analysis(
985 &rules,
986 &mut content,
987 &config,
988 100,
989 crate::DocumentPaths::same(None),
990 Some((&old, &checks)),
991 None,
992 );
993 assert_eq!(content, original);
994 assert_eq!(result.iterations, 0);
995 assert_eq!(result.rules_fixed, 0);
996 }
997
998 use super::*;
999 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
1000 use std::sync::atomic::{AtomicUsize, Ordering};
1001
1002 #[test]
1003 fn forwarding_a_policy_does_not_certify_a_custom_rule() {
1004 #[derive(Clone)]
1005 struct Wrapper(crate::rules::MD013LineLength);
1006 impl Rule for Wrapper {
1007 fn name(&self) -> &'static str {
1008 "MD013"
1009 }
1010 fn description(&self) -> &'static str {
1011 "Custom rewrite"
1012 }
1013 fn as_any(&self) -> &dyn std::any::Any {
1014 self
1015 }
1016 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1017 self.0.native_format_policy()
1018 }
1019 fn check(&self, ctx: &LintContext) -> LintResult {
1020 Ok((!ctx.content.contains("custom"))
1021 .then(|| LintWarning {
1022 rule_name: Some(self.name().into()),
1023 message: self.description().into(),
1024 line: 1,
1025 column: 1,
1026 end_line: 1,
1027 end_column: 2,
1028 severity: Severity::Warning,
1029 fix: None,
1030 })
1031 .into_iter()
1032 .collect())
1033 }
1034 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1035 Ok(if ctx.content.contains("custom") {
1036 ctx.content.into()
1037 } else {
1038 format!("custom {}", ctx.content)
1039 })
1040 }
1041 }
1042 let rule = Wrapper(crate::rules::MD013LineLength::default());
1043 assert!(rule.native_format_policy().is_some());
1044 assert!(crate::format_proof::NativeFormatPolicy::validated(&rule).is_none());
1045 let rules: Vec<Box<dyn Rule>> = vec![Box::new(rule)];
1046 let config = Config::default();
1047 let (text, result) = crate::document_run::DocumentRun::new("plain\n", &rules, &config)
1048 .fix_for_formatter(10)
1049 .unwrap();
1050 assert_eq!(text, "custom plain\n");
1051 assert!(result.converged);
1052 }
1053
1054 #[test]
1055 fn configured_noops_keep_flavor_diagnostics_and_inline_fixes() {
1056 #[derive(Clone)]
1057 struct CountNative {
1058 rule: Box<dyn Rule>,
1059 checks: std::sync::Arc<AtomicUsize>,
1060 }
1061 impl Rule for CountNative {
1062 fn name(&self) -> &'static str {
1063 self.rule.name()
1064 }
1065 fn description(&self) -> &'static str {
1066 self.rule.description()
1067 }
1068 fn check(&self, ctx: &LintContext) -> LintResult {
1069 self.checks.fetch_add(1, Ordering::Relaxed);
1070 self.rule.check(ctx)
1071 }
1072 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1073 self.rule.fix(ctx)
1074 }
1075 fn should_skip(&self, ctx: &LintContext) -> bool {
1076 self.rule.should_skip(ctx)
1077 }
1078 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1079 self.rule
1080 .native_format_policy()
1081 .map(crate::format_proof::NativeFormatPolicy::for_test_owner::<Self>)
1082 }
1083 fn as_any(&self) -> &dyn std::any::Any {
1084 self
1085 }
1086 }
1087 for flavor in crate::format_proof::ALL_FLAVORS {
1088 let mut config = Config::default();
1089 config.global.flavor = flavor;
1090 let counters = [
1091 std::sync::Arc::new(AtomicUsize::new(0)),
1092 std::sync::Arc::new(AtomicUsize::new(0)),
1093 ];
1094 let rules: Vec<Box<dyn Rule>> = ["MD013", "MD036"]
1095 .into_iter()
1096 .zip(&counters)
1097 .map(|(name, checks)| {
1098 Box::new(CountNative {
1099 rule: crate::rules::create_rule_by_name(name, &config).unwrap(),
1100 checks: checks.clone(),
1101 }) as Box<dyn Rule>
1102 })
1103 .collect();
1104 let original = format!("*Heading*\n\n{}\n", "ordinary prose ".repeat(10));
1105 let mut content = original.clone();
1106 let coordinator = FixCoordinator::new();
1107 let result = coordinator.apply_fixes_with_analysis(
1108 &rules,
1109 &mut content,
1110 &config,
1111 10,
1112 crate::DocumentPaths::same(None),
1113 None,
1114 None,
1115 );
1116 assert!(result.converged);
1117 assert_eq!(content, original, "{flavor:?}");
1118 assert!(
1119 counters.iter().all(|count| count.load(Ordering::Relaxed) == 0),
1120 "{flavor:?}"
1121 );
1122
1123 let mut warning_count = 0;
1124 let mut stable = |_: &LintContext, checks: &mut RawRuleChecks| {
1125 warning_count = checks.values().map(Vec::len).sum::<usize>();
1126 };
1127 coordinator.apply_fixes_with_analysis(
1128 &rules,
1129 &mut content,
1130 &config,
1131 10,
1132 crate::DocumentPaths::same(None),
1133 None,
1134 Some(&mut stable),
1135 );
1136 assert!(warning_count >= 2, "{flavor:?}: diagnostic consumer lost warnings");
1137 assert!(
1138 counters.iter().all(|count| count.load(Ordering::Relaxed) == 1),
1139 "{flavor:?}"
1140 );
1141
1142 for (name, original) in [
1143 (
1144 "MD036",
1145 "<!-- rumdl-configure-file {\"MD036\":{\"fix\":true}} -->\n\n*Heading*\n",
1146 ),
1147 (
1148 "MD013",
1149 "<!-- rumdl-configure-file {\"MD013\":{\"reflow\":true,\"line_length\":12}} -->\n\nalpha beta gamma delta epsilon zeta\n",
1150 ),
1151 ] {
1152 let ctx = LintContext::new(original, flavor, None);
1153 let merged = config.merge_with_inline_config(ctx.inline_config());
1154 let native = crate::rules::create_rule_by_name(name, &merged).unwrap();
1155 let expected = native.fix(&ctx).unwrap();
1156 assert_ne!(
1157 expected, original,
1158 "{flavor:?} {name}: fixture must exercise a native edit"
1159 );
1160 let rules = vec![crate::rules::create_rule_by_name(name, &config).unwrap()];
1161 let mut content = original.to_owned();
1162 coordinator.apply_fixes_with_analysis(
1163 &rules,
1164 &mut content,
1165 &config,
1166 10,
1167 crate::DocumentPaths::same(None),
1168 None,
1169 None,
1170 );
1171 assert_eq!(
1172 content, expected,
1173 "{flavor:?} {name}: inline override lost its native fix"
1174 );
1175 }
1176 }
1177 }
1178
1179 #[derive(Clone)]
1180 struct WarningOnlyProbe;
1181
1182 impl Rule for WarningOnlyProbe {
1183 fn name(&self) -> &'static str {
1184 "TEST_WARNING_ONLY"
1185 }
1186 fn description(&self) -> &'static str {
1187 "A diagnostic that cannot change content"
1188 }
1189 fn fix_capability(&self) -> FixCapability {
1190 FixCapability::Unfixable
1191 }
1192 fn check(&self, _ctx: &LintContext) -> LintResult {
1193 panic!("warning-only rules must not be rechecked in the fix loop")
1194 }
1195 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
1196 panic!("warning-only rules must not be fixed")
1197 }
1198 fn as_any(&self) -> &dyn std::any::Any {
1199 self
1200 }
1201 }
1202
1203 #[test]
1204 fn fix_loop_skips_warning_only_rules_without_losing_other_fixes() {
1205 let rules: Vec<Box<dyn Rule>> = vec![
1206 Box::new(WarningOnlyProbe),
1207 Box::new(ConditionalFixRule {
1208 name: "TEST_FIX",
1209 check_fn: |s| s.contains("bad"),
1210 fix_fn: |s| s.replace("bad", "good"),
1211 }),
1212 ];
1213 let mut content = "bad\n".to_string();
1214 let result = FixCoordinator::new()
1215 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1216 .unwrap();
1217 assert!(result.converged);
1218 assert_eq!(content, "good\n");
1219 }
1220
1221 #[derive(Clone)]
1223 struct ConditionalFixRule {
1224 name: &'static str,
1225 check_fn: fn(&str) -> bool,
1227 fix_fn: fn(&str) -> String,
1229 }
1230
1231 impl Rule for ConditionalFixRule {
1232 fn name(&self) -> &'static str {
1233 self.name
1234 }
1235
1236 fn check(&self, ctx: &LintContext) -> LintResult {
1237 if (self.check_fn)(ctx.content) {
1238 Ok(vec![LintWarning {
1239 line: 1,
1240 column: 1,
1241 end_line: 1,
1242 end_column: 1,
1243 message: format!("{} issue found", self.name),
1244 rule_name: Some(self.name.to_string()),
1245 severity: Severity::Error,
1246 fix: Some(Fix::new(0..0, String::new())),
1247 }])
1248 } else {
1249 Ok(vec![])
1250 }
1251 }
1252
1253 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1254 Ok((self.fix_fn)(ctx.content))
1255 }
1256
1257 fn description(&self) -> &'static str {
1258 "Conditional fix rule for testing"
1259 }
1260
1261 fn category(&self) -> RuleCategory {
1262 RuleCategory::Whitespace
1263 }
1264
1265 fn as_any(&self) -> &dyn std::any::Any {
1266 self
1267 }
1268 }
1269
1270 #[derive(Clone)]
1272 struct MockRule {
1273 name: &'static str,
1274 warnings: Vec<LintWarning>,
1275 fix_content: String,
1276 }
1277
1278 impl Rule for MockRule {
1279 fn name(&self) -> &'static str {
1280 self.name
1281 }
1282
1283 fn check(&self, _ctx: &LintContext) -> LintResult {
1284 Ok(self.warnings.clone())
1285 }
1286
1287 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
1288 Ok(self.fix_content.clone())
1289 }
1290
1291 fn description(&self) -> &'static str {
1292 "Mock rule for testing"
1293 }
1294
1295 fn category(&self) -> RuleCategory {
1296 RuleCategory::Whitespace
1297 }
1298
1299 fn as_any(&self) -> &dyn std::any::Any {
1300 self
1301 }
1302 }
1303
1304 #[test]
1305 fn test_dependency_ordering() {
1306 let coordinator = FixCoordinator::new();
1307
1308 let rules: Vec<Box<dyn Rule>> = vec![
1309 Box::new(MockRule {
1310 name: "MD009",
1311 warnings: vec![],
1312 fix_content: "".to_string(),
1313 }),
1314 Box::new(MockRule {
1315 name: "MD013",
1316 warnings: vec![],
1317 fix_content: "".to_string(),
1318 }),
1319 Box::new(MockRule {
1320 name: "MD010",
1321 warnings: vec![],
1322 fix_content: "".to_string(),
1323 }),
1324 Box::new(MockRule {
1325 name: "MD007",
1326 warnings: vec![],
1327 fix_content: "".to_string(),
1328 }),
1329 ];
1330
1331 let ordered = coordinator.get_optimal_order(&rules);
1332 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
1333
1334 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
1336 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
1337 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
1338
1339 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
1341 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
1342 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
1343 }
1344
1345 #[test]
1346 fn test_single_rule_fix() {
1347 let coordinator = FixCoordinator::new();
1348
1349 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1351 name: "RemoveBad",
1352 check_fn: |content| content.contains("BAD"),
1353 fix_fn: |content| content.replace("BAD", "GOOD"),
1354 })];
1355
1356 let mut content = "This is BAD content".to_string();
1357 let config = Config::default();
1358
1359 let result = coordinator
1360 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1361 .unwrap();
1362
1363 assert_eq!(content, "This is GOOD content");
1364 assert_eq!(result.rules_fixed, 1);
1365 assert!(result.converged);
1366 }
1367
1368 #[test]
1369 fn test_cascading_fixes() {
1370 let coordinator = FixCoordinator::new();
1374
1375 let rules: Vec<Box<dyn Rule>> = vec![
1376 Box::new(ConditionalFixRule {
1377 name: "Rule1_IndentToFence",
1378 check_fn: |content| content.contains("INDENT"),
1379 fix_fn: |content| content.replace("INDENT", "FENCE"),
1380 }),
1381 Box::new(ConditionalFixRule {
1382 name: "Rule2_FenceToLang",
1383 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
1384 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
1385 }),
1386 ];
1387
1388 let mut content = "Code: INDENT".to_string();
1389 let config = Config::default();
1390
1391 let result = coordinator
1392 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
1393 .unwrap();
1394
1395 assert_eq!(content, "Code: FENCE_LANG");
1397 assert_eq!(result.rules_fixed, 2);
1398 assert!(result.converged);
1399 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
1400 }
1401
1402 #[test]
1403 fn a_rule_that_never_fires_is_checked_once_per_pass_not_once_per_fix() {
1404 static QUIET_CHECKS: AtomicUsize = AtomicUsize::new(0);
1405 let coordinator = FixCoordinator::new();
1406
1407 let mut rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1409 name: "Quiet",
1410 check_fn: |_| {
1411 QUIET_CHECKS.fetch_add(1, Ordering::SeqCst);
1412 false
1413 },
1414 fix_fn: str::to_string,
1415 })];
1416 let fixers = [
1417 ConditionalFixRule {
1418 name: "FixA",
1419 check_fn: |c| c.contains('a'),
1420 fix_fn: |c| c.replace('a', "A"),
1421 },
1422 ConditionalFixRule {
1423 name: "FixB",
1424 check_fn: |c| c.contains('b'),
1425 fix_fn: |c| c.replace('b', "B"),
1426 },
1427 ConditionalFixRule {
1428 name: "FixC",
1429 check_fn: |c| c.contains('c'),
1430 fix_fn: |c| c.replace('c', "C"),
1431 },
1432 ConditionalFixRule {
1433 name: "FixD",
1434 check_fn: |c| c.contains('d'),
1435 fix_fn: |c| c.replace('d', "D"),
1436 },
1437 ConditionalFixRule {
1438 name: "FixE",
1439 check_fn: |c| c.contains('e'),
1440 fix_fn: |c| c.replace('e', "E"),
1441 },
1442 ];
1443 rules.extend(fixers.map(|rule| Box::new(rule) as Box<dyn Rule>));
1444
1445 let mut content = "a b c d e".to_string();
1446 let result = coordinator
1447 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 100, None)
1448 .unwrap();
1449
1450 assert_eq!(content, "A B C D E");
1451 assert!(result.converged);
1452 assert_eq!(result.rules_fixed, 5);
1453 assert_eq!(QUIET_CHECKS.load(Ordering::SeqCst), 2);
1456 }
1457
1458 #[test]
1459 fn a_fix_that_creates_work_for_an_earlier_rule_is_picked_up_on_the_wrap() {
1460 let coordinator = FixCoordinator::new();
1461
1462 let rules: Vec<Box<dyn Rule>> = vec![
1466 Box::new(ConditionalFixRule {
1467 name: "Early_YToZ",
1468 check_fn: |content| content.contains('y'),
1469 fix_fn: |content| content.replace('y', "z"),
1470 }),
1471 Box::new(ConditionalFixRule {
1472 name: "Late_XToY",
1473 check_fn: |content| content.contains('x'),
1474 fix_fn: |content| content.replace('x', "y"),
1475 }),
1476 Box::new(ConditionalFixRule {
1477 name: "Tail_Quiet",
1478 check_fn: |_| false,
1479 fix_fn: str::to_string,
1480 }),
1481 ];
1482
1483 let mut content = "x".to_string();
1484 let result = coordinator
1485 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 3, None)
1486 .unwrap();
1487
1488 assert_eq!(content, "z");
1489 assert!(result.converged);
1490 assert_eq!(result.rules_fixed, 2);
1491 assert_eq!(result.context_creations, 3);
1492 }
1493
1494 #[test]
1495 fn a_clean_wrap_reuses_the_context_and_does_not_repeat_the_tail() {
1496 static TAIL_CHECKS: AtomicUsize = AtomicUsize::new(0);
1497 TAIL_CHECKS.store(0, Ordering::SeqCst);
1498 let rules: Vec<Box<dyn Rule>> = vec![
1499 Box::new(ConditionalFixRule {
1500 name: "Early_Quiet",
1501 check_fn: |_| false,
1502 fix_fn: str::to_string,
1503 }),
1504 Box::new(ConditionalFixRule {
1505 name: "Middle_Fix",
1506 check_fn: |content| content.contains('x'),
1507 fix_fn: |content| content.replace('x', "y"),
1508 }),
1509 Box::new(ConditionalFixRule {
1510 name: "Tail_Quiet",
1511 check_fn: |_| {
1512 TAIL_CHECKS.fetch_add(1, Ordering::SeqCst);
1513 false
1514 },
1515 fix_fn: str::to_string,
1516 }),
1517 ];
1518 let mut content = "x".to_string();
1519 let result = FixCoordinator::new()
1520 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 2, None)
1521 .unwrap();
1522
1523 assert_eq!(content, "y");
1524 assert!(result.converged);
1525 assert_eq!(result.rules_fixed, 1);
1526 assert_eq!(result.context_creations, 2);
1527 assert_eq!(TAIL_CHECKS.load(Ordering::SeqCst), 1);
1528 }
1529
1530 #[test]
1531 fn test_indirect_cascade() {
1532 let coordinator = FixCoordinator::new();
1537
1538 let rules: Vec<Box<dyn Rule>> = vec![
1539 Box::new(ConditionalFixRule {
1540 name: "Rule1_AddBlank",
1541 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
1542 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
1543 }),
1544 Box::new(ConditionalFixRule {
1545 name: "Rule2_CodeToFence",
1546 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
1548 fix_fn: |content| content.replace("CODE", "FENCE"),
1549 }),
1550 Box::new(ConditionalFixRule {
1551 name: "Rule3_AddLang",
1552 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
1553 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
1554 }),
1555 ];
1556
1557 let mut content = "HEADING CODE".to_string();
1558 let config = Config::default();
1559
1560 let result = coordinator
1561 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
1562 .unwrap();
1563
1564 assert_eq!(content, "HEADING BLANK FENCE_LANG");
1566 assert_eq!(result.rules_fixed, 3);
1567 assert!(result.converged);
1568 }
1569
1570 #[test]
1571 fn test_unfixable_rules_skipped() {
1572 let coordinator = FixCoordinator::new();
1573
1574 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1575 name: "MD001",
1576 check_fn: |content| content.contains("BAD"),
1577 fix_fn: |content| content.replace("BAD", "GOOD"),
1578 })];
1579
1580 let mut content = "BAD content".to_string();
1581 let mut config = Config::default();
1582 config.global.unfixable = vec!["MD001".to_string()];
1583
1584 let result = coordinator
1585 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1586 .unwrap();
1587
1588 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
1590 assert!(result.converged);
1591 }
1592
1593 #[test]
1594 fn test_fixable_allowlist() {
1595 let coordinator = FixCoordinator::new();
1596
1597 let rules: Vec<Box<dyn Rule>> = vec![
1598 Box::new(ConditionalFixRule {
1599 name: "MD001",
1600 check_fn: |content| content.contains('A'),
1601 fix_fn: |content| content.replace('A', "X"),
1602 }),
1603 Box::new(ConditionalFixRule {
1604 name: "MD002",
1605 check_fn: |content| content.contains('B'),
1606 fix_fn: |content| content.replace('B', "Y"),
1607 }),
1608 ];
1609
1610 let mut content = "AB".to_string();
1611 let mut config = Config::default();
1612 config.global.fixable = vec!["MD001".to_string()];
1613
1614 let result = coordinator
1615 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1616 .unwrap();
1617
1618 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
1620 }
1621
1622 #[test]
1629 fn test_unfixable_rules_resolved_from_alias() {
1630 let coordinator = FixCoordinator::new();
1631
1632 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1633 name: "MD001",
1634 check_fn: |content| content.contains("BAD"),
1635 fix_fn: |content| content.replace("BAD", "GOOD"),
1636 })];
1637
1638 let mut content = "BAD content".to_string();
1639 let mut config = Config::default();
1640 config.global.unfixable = vec!["heading-increment".to_string()];
1642 config.canonicalize_rule_lists();
1644
1645 let result = coordinator
1646 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1647 .unwrap();
1648
1649 assert_eq!(content, "BAD content");
1650 assert_eq!(result.rules_fixed, 0);
1651 assert!(result.converged);
1652 }
1653
1654 #[test]
1658 fn test_fixable_allowlist_resolved_from_alias() {
1659 let coordinator = FixCoordinator::new();
1660
1661 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1662 name: "MD001",
1663 check_fn: |content| content.contains("BAD"),
1664 fix_fn: |content| content.replace("BAD", "GOOD"),
1665 })];
1666
1667 let mut content = "BAD content".to_string();
1668 let mut config = Config::default();
1669 config.global.fixable = vec!["heading-increment".to_string()];
1670 config.canonicalize_rule_lists();
1671
1672 let result = coordinator
1673 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1674 .unwrap();
1675
1676 assert_eq!(content, "GOOD content");
1677 assert_eq!(result.rules_fixed, 1);
1678 }
1679
1680 #[test]
1681 fn test_max_iterations_limit() {
1682 let coordinator = FixCoordinator::new();
1683
1684 static COUNTER: AtomicUsize = AtomicUsize::new(0);
1686
1687 #[derive(Clone)]
1688 struct AlwaysChangeRule;
1689 impl Rule for AlwaysChangeRule {
1690 fn name(&self) -> &'static str {
1691 "AlwaysChange"
1692 }
1693 fn check(&self, _: &LintContext) -> LintResult {
1694 Ok(vec![LintWarning {
1695 line: 1,
1696 column: 1,
1697 end_line: 1,
1698 end_column: 1,
1699 message: "Always".to_string(),
1700 rule_name: Some("AlwaysChange".to_string()),
1701 severity: Severity::Error,
1702 fix: Some(Fix::new(0..0, String::new())),
1703 }])
1704 }
1705 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1706 COUNTER.fetch_add(1, Ordering::SeqCst);
1707 Ok(format!("{}x", ctx.content))
1708 }
1709 fn description(&self) -> &'static str {
1710 "Always changes"
1711 }
1712 fn category(&self) -> RuleCategory {
1713 RuleCategory::Whitespace
1714 }
1715 fn as_any(&self) -> &dyn std::any::Any {
1716 self
1717 }
1718 }
1719
1720 COUNTER.store(0, Ordering::SeqCst);
1721 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
1722
1723 let mut content = "test".to_string();
1724 let config = Config::default();
1725
1726 let result = coordinator
1727 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1728 .unwrap();
1729
1730 assert_eq!(result.iterations, 5);
1732 assert!(!result.converged);
1733 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
1734 }
1735
1736 #[test]
1737 fn test_empty_rules() {
1738 let coordinator = FixCoordinator::new();
1739 let rules: Vec<Box<dyn Rule>> = vec![];
1740
1741 let mut content = "unchanged".to_string();
1742 let config = Config::default();
1743
1744 let result = coordinator
1745 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1746 .unwrap();
1747
1748 assert_eq!(result.rules_fixed, 0);
1749 assert_eq!(result.iterations, 1);
1750 assert!(result.converged);
1751 assert_eq!(content, "unchanged");
1752 }
1753
1754 #[test]
1755 fn test_no_warnings_no_changes() {
1756 let coordinator = FixCoordinator::new();
1757
1758 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1760 name: "NoIssues",
1761 check_fn: |_| false, fix_fn: |content| content.to_string(),
1763 })];
1764
1765 let mut content = "clean content".to_string();
1766 let config = Config::default();
1767
1768 let result = coordinator
1769 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1770 .unwrap();
1771
1772 assert_eq!(content, "clean content");
1773 assert_eq!(result.rules_fixed, 0);
1774 assert!(result.converged);
1775 }
1776
1777 #[test]
1778 fn test_oscillation_detection() {
1779 let coordinator = FixCoordinator::new();
1783
1784 let rules: Vec<Box<dyn Rule>> = vec![
1785 Box::new(ConditionalFixRule {
1786 name: "RuleA",
1787 check_fn: |content| content.contains("foo"),
1788 fix_fn: |content| content.replace("foo", "bar"),
1789 }),
1790 Box::new(ConditionalFixRule {
1791 name: "RuleB",
1792 check_fn: |content| content.contains("bar"),
1793 fix_fn: |content| content.replace("bar", "foo"),
1794 }),
1795 ];
1796
1797 let mut content = "foo".to_string();
1798 let config = Config::default();
1799
1800 let result = coordinator
1801 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
1802 .unwrap();
1803
1804 assert!(!result.converged, "Should not converge in an oscillating pair");
1806 assert!(
1807 result.iterations < 10,
1808 "Cycle detection should stop well before max_iterations (got {})",
1809 result.iterations
1810 );
1811
1812 let mut conflicting = result.conflicting_rules.clone();
1814 conflicting.sort();
1815 assert_eq!(
1816 conflicting,
1817 vec!["RuleA".to_string(), "RuleB".to_string()],
1818 "Both oscillating rules must be reported"
1819 );
1820 assert_eq!(
1821 result.conflict_cycle,
1822 vec!["RuleA".to_string(), "RuleB".to_string()],
1823 "Cycle should preserve the observed application order"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_cyclic_dependencies_handled() {
1829 let mut coordinator = FixCoordinator::new();
1830
1831 coordinator.dependencies.to_mut().insert("RuleA", vec!["RuleB"]);
1833 coordinator.dependencies.to_mut().insert("RuleB", vec!["RuleC"]);
1834 coordinator.dependencies.to_mut().insert("RuleC", vec!["RuleA"]);
1835
1836 let rules: Vec<Box<dyn Rule>> = vec![
1837 Box::new(MockRule {
1838 name: "RuleA",
1839 warnings: vec![],
1840 fix_content: "".to_string(),
1841 }),
1842 Box::new(MockRule {
1843 name: "RuleB",
1844 warnings: vec![],
1845 fix_content: "".to_string(),
1846 }),
1847 Box::new(MockRule {
1848 name: "RuleC",
1849 warnings: vec![],
1850 fix_content: "".to_string(),
1851 }),
1852 ];
1853
1854 let ordered = coordinator.get_optimal_order(&rules);
1856
1857 assert_eq!(ordered.len(), 3);
1859 }
1860
1861 #[test]
1862 fn test_fix_is_idempotent() {
1863 let coordinator = FixCoordinator::new();
1865
1866 let rules: Vec<Box<dyn Rule>> = vec![
1867 Box::new(ConditionalFixRule {
1868 name: "Rule1",
1869 check_fn: |content| content.contains('A'),
1870 fix_fn: |content| content.replace('A', "B"),
1871 }),
1872 Box::new(ConditionalFixRule {
1873 name: "Rule2",
1874 check_fn: |content| content.contains('B') && !content.contains('C'),
1875 fix_fn: |content| content.replace('B', "BC"),
1876 }),
1877 ];
1878
1879 let config = Config::default();
1880
1881 let mut content1 = "A".to_string();
1883 let result1 = coordinator
1884 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
1885 .unwrap();
1886
1887 let mut content2 = content1.clone();
1889 let result2 = coordinator
1890 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
1891 .unwrap();
1892
1893 assert_eq!(content1, content2);
1895 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
1896 assert!(result1.converged);
1897 assert!(result2.converged);
1898 }
1899
1900 #[test]
1901 fn test_apply_fixes_collapses_double_space_without_inline_override() {
1902 let mut content = String::from("`<svg>`. Fortunately\n");
1905 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1906 FixCoordinator::new()
1907 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1908 .unwrap();
1909 assert_eq!(
1910 content, "`<svg>`. Fortunately\n",
1911 "MD064 collapses the sentence double space when not overridden"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
1917 let coordinator = FixCoordinator::new();
1922 let rules: Vec<Box<dyn Rule>> = vec![
1923 Box::new(ConditionalFixRule {
1924 name: "MD004",
1925 check_fn: |c| c.contains('*'),
1926 fix_fn: |c| c.replace('*', "-"),
1927 }),
1928 Box::new(ConditionalFixRule {
1929 name: "MD032",
1930 check_fn: |c| c.contains("PARENT"),
1931 fix_fn: |c| c.replace("PARENT", "parent"),
1932 }),
1933 ];
1934
1935 let mut config = Config::default();
1936 config
1937 .per_file_ignores
1938 .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1939 config.canonicalize_rule_lists();
1940
1941 let mut content = "* PARENT".to_string();
1943 let result = coordinator
1944 .apply_fixes_iterative(
1945 &rules,
1946 &[],
1947 &mut content,
1948 &config,
1949 10,
1950 Some(std::path::Path::new("slides/deck.md")),
1951 )
1952 .unwrap();
1953 assert_eq!(
1954 content, "* parent",
1955 "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1956 );
1957 assert!(result.fixed_rule_names.contains("MD032"));
1958 assert!(!result.fixed_rule_names.contains("MD004"));
1959
1960 let mut other = "* PARENT".to_string();
1963 coordinator
1964 .apply_fixes_iterative(
1965 &rules,
1966 &[],
1967 &mut other,
1968 &config,
1969 10,
1970 Some(std::path::Path::new("docs/other.md")),
1971 )
1972 .unwrap();
1973 assert_eq!(
1974 other, "- parent",
1975 "both rules apply when the path is not per-file-ignored"
1976 );
1977 }
1978
1979 #[test]
1980 fn test_apply_fixes_honors_inline_configure_file_overrides() {
1981 let mut content = String::from(
1986 "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`. Fortunately\n",
1987 );
1988 let original = content.clone();
1989 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1990 FixCoordinator::new()
1991 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1992 .unwrap();
1993 assert_eq!(
1994 content, original,
1995 "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1996 );
1997 }
1998
1999 #[test]
2000 fn layout_rules_run_after_every_content_rewrite() {
2001 let rules = crate::rules::all_rules(&Config::default());
2007 let ordered = FixCoordinator::new().get_optimal_order(&rules);
2008 let names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
2009 assert_eq!(names.len(), rules.len(), "ordering must keep every rule exactly once");
2010
2011 let md013 = names.iter().position(|&n| n == "MD013").unwrap();
2012 let after: Vec<&str> = names[md013 + 1..].to_vec();
2013 let mut expected_after = vec!["MD009", "MD012"];
2014 expected_after.sort_unstable();
2015 let mut actual_after = after.clone();
2016 actual_after.sort_unstable();
2017 assert_eq!(
2018 actual_after, expected_after,
2019 "only MD013's own dependents may follow it; order was {names:?}"
2020 );
2021 }
2022
2023 #[test]
2024 fn link_style_rewrite_runs_before_reflow_regardless_of_input_order() {
2025 let coordinator = FixCoordinator::new();
2028 for input in [vec!["MD013", "MD054"], vec!["MD054", "MD013"]] {
2029 let rules: Vec<Box<dyn Rule>> = input
2030 .iter()
2031 .map(|name| crate::rules::create_rule_by_name(name, &Config::default()).unwrap())
2032 .collect();
2033 let names: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
2034 assert_eq!(names, vec!["MD054", "MD013"], "input order {input:?}");
2035 }
2036 }
2037
2038 #[test]
2039 fn optimal_order_is_the_same_on_every_call() {
2040 let rules = crate::rules::all_rules(&Config::default());
2046 let coordinator = FixCoordinator::new();
2047 let first: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
2048 for _ in 0..20 {
2049 let again: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
2050 assert_eq!(again, first);
2051 }
2052 }
2053
2054 #[test]
2055 fn cached_order_respects_the_selection_and_current_rule_instances() {
2056 let config = Config::default();
2057 let original = crate::rules::all_rules(&config);
2058 let coordinator = FixCoordinator::new();
2059 coordinator.get_optimal_order(&original);
2060
2061 let mut uncached = FixCoordinator::new();
2064 uncached.dependencies.to_mut();
2065 for selection in [
2066 original.iter().map(|rule| rule.name()).collect::<Vec<_>>(),
2067 vec!["MD013", "MD054", "MD009"],
2068 vec!["MD013", "MD054", "MD009"],
2069 vec!["MD009", "MD054", "MD013"],
2070 vec![],
2071 vec!["MD013"],
2072 ] {
2073 let current: Vec<Box<dyn Rule>> = selection
2074 .iter()
2075 .map(|name| crate::rules::create_rule_by_name(name, &config).unwrap())
2076 .collect();
2077 let ordered = coordinator.get_optimal_order(¤t);
2078 let expected = uncached.get_optimal_order(¤t);
2079 assert_eq!(
2080 ordered.iter().map(|rule| rule.name()).collect::<Vec<_>>(),
2081 expected.iter().map(|rule| rule.name()).collect::<Vec<_>>()
2082 );
2083 for rule in ordered {
2084 assert!(current.iter().any(|current| std::ptr::eq(rule, current.as_ref())));
2085 }
2086 }
2087 }
2088}