Skip to main content

rumdl_lib/
fix_coordinator.rs

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
8/// Maximum number of fix iterations before stopping (same as Ruff)
9const MAX_ITERATIONS: usize = 100;
10
11/// Rules whose fix lays text out on lines rather than changing what the text says.
12///
13/// Reflow picks wrap points from the width of the inline content in front of it,
14/// and its default mode only revisits a line that is over the limit. A rewrite that
15/// shortens inline content AFTER reflow (a link turned into a shortcut reference, a
16/// bare URL wrapped in angle brackets, spaces removed inside emphasis) therefore
17/// leaves every affected line short for good, and a second run cannot repair it
18/// because a short line is never a finding. Ordering layout after every content
19/// rewrite is the invariant; the sort applies it to the whole rule set, so a rewrite
20/// does not have to be listed here to be covered (listing MD054 by hand is exactly
21/// what was missing when it slipped through).
22const LAYOUT_RULES: &[&str] = &["MD013"];
23
24/// Result of applying fixes iteratively
25///
26/// This struct provides named fields instead of a tuple to prevent
27/// confusion about the meaning of each value.
28#[derive(Debug, Clone)]
29pub struct FixResult {
30    /// Total number of rules that successfully applied fixes
31    pub rules_fixed: usize,
32    /// Number of fix iterations performed
33    pub iterations: usize,
34    /// Number of LintContext instances created during fixing
35    pub context_creations: usize,
36    /// Names of rules that applied fixes
37    pub fixed_rule_names: HashSet<String>,
38    /// Whether the fix process converged (content stabilized)
39    pub converged: bool,
40    /// Rules identified as participants in an oscillation cycle.
41    /// Populated only when `converged == false` and a cycle was detected.
42    /// Empty when the fix loop hit `max_iterations` without cycling.
43    pub conflicting_rules: Vec<String>,
44    /// Ordered rule sequence observed in the cycle.
45    /// If non-empty, this can be rendered as a loop by appending the first rule
46    /// at the end (e.g. `MD044 -> MD063 -> MD044`).
47    pub conflict_cycle: Vec<String>,
48}
49
50/// Calculate hash of content for convergence detection
51fn hash_content(content: &str) -> u64 {
52    let mut hasher = DefaultHasher::new();
53    content.hash(&mut hasher);
54    hasher.finish()
55}
56
57/// Coordinates rule fixing to minimize the number of passes needed
58pub struct FixCoordinator {
59    /// Rules that should run before others (rule -> rules that depend on it)
60    dependencies: HashMap<&'static str, Vec<&'static str>>,
61}
62
63impl Default for FixCoordinator {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl FixCoordinator {
70    pub fn new() -> Self {
71        let mut dependencies = HashMap::new();
72
73        // CRITICAL DEPENDENCIES:
74        // These dependencies prevent cascading issues that require multiple passes
75
76        // MD064 (multiple consecutive spaces) MUST run before:
77        // - MD010 (tabs->spaces) - MD010 replaces tabs with multiple spaces (e.g., 4),
78        //   which MD064 would incorrectly collapse back to 1 space if it ran after
79        dependencies.insert("MD064", vec!["MD010"]);
80
81        // MD010 (tabs->spaces) MUST run before:
82        // - MD007 (list indentation) - because tabs affect indent calculation
83        // - MD005 (list indent consistency) - same reason
84        dependencies.insert("MD010", vec!["MD007", "MD005"]);
85
86        // MD013 (line length) MUST run before:
87        // - MD009 (trailing spaces) - line wrapping might add trailing spaces that need cleanup
88        // - MD012 (multiple blanks) - reflowing can affect blank lines
89        // Note: MD013 now trims trailing whitespace during reflow to prevent mid-line spaces
90        dependencies.insert("MD013", vec!["MD009", "MD012"]);
91
92        // MD004 (list style) should run before:
93        // - MD007 (list indentation) - changing markers affects indentation
94        dependencies.insert("MD004", vec!["MD007"]);
95
96        // MD022/MD023 (heading spacing) should run before:
97        // - MD012 (multiple blanks) - heading fixes can affect blank lines
98        dependencies.insert("MD022", vec!["MD012"]);
99        dependencies.insert("MD023", vec!["MD012"]);
100
101        // MD070 (nested fence collision) MUST run before:
102        // - MD040 (code language) - MD070 changes block structure, making orphan fences into content
103        // - MD031 (blanks around fences) - same reason
104        dependencies.insert("MD070", vec!["MD040", "MD031"]);
105
106        // MD005/MD077 (list indent and continuation indent) MUST run before:
107        // - MD032 (blanks around lists) - MD005/MD077 fix nesting structure that MD032 relies on
108        //   to correctly identify list block boundaries; running MD032 first on under-indented
109        //   content causes spurious blank-line insertions inside list items
110        dependencies.insert("MD005", vec!["MD032"]);
111        dependencies.insert("MD077", vec!["MD032"]);
112
113        Self { dependencies }
114    }
115
116    /// The given rules plus everything that transitively depends on them.
117    fn downstream_of(&self, roots: &[&'static str]) -> HashSet<&'static str> {
118        let mut closure: HashSet<&'static str> = HashSet::new();
119        let mut pending: Vec<&'static str> = roots.to_vec();
120        while let Some(name) = pending.pop() {
121            if !closure.insert(name) {
122                continue;
123            }
124            if let Some(dependents) = self.dependencies.get(name) {
125                pending.extend(dependents.iter().copied());
126            }
127        }
128        closure
129    }
130
131    /// Get the optimal order for running rules based on dependencies
132    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
133        // Build a map of rule names to rules for quick lookup
134        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
135        // Input position of each rule. Prerequisites are visited in this order so
136        // that rules the dependency table leaves unordered keep their input order,
137        // and so the result is the same on every run: the sets below are hashed,
138        // and iterating them directly would let the hasher's per-process seed pick
139        // the order of the whole rule set.
140        let position: HashMap<&str, usize> = rules.iter().enumerate().map(|(i, r)| (r.name(), i)).collect();
141
142        // Build reverse dependencies (rule -> rules it depends on)
143        let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
144        for (prereq, dependents) in &self.dependencies {
145            for dependent in dependents {
146                reverse_deps.entry(dependent).or_default().insert(prereq);
147            }
148        }
149
150        // A layout rule runs after every rule that is not downstream of it. Rules
151        // downstream of a layout rule (its dependents, transitively) are excluded so
152        // the declared edges MD013 -> MD009/MD012 stay acyclic.
153        let downstream_of_layout = self.downstream_of(LAYOUT_RULES);
154        for &layout_rule in LAYOUT_RULES {
155            let prereqs = reverse_deps.entry(layout_rule).or_default();
156            prereqs.extend(
157                rule_map
158                    .keys()
159                    .copied()
160                    .filter(|name| *name != layout_rule && !downstream_of_layout.contains(name)),
161            );
162        }
163
164        // Perform topological sort
165        let mut sorted = Vec::new();
166        let mut visited: HashSet<&str> = HashSet::new();
167        let mut visiting: HashSet<&str> = HashSet::new();
168
169        fn visit<'a, 'b>(
170            rule_name: &'b str,
171            rule_map: &HashMap<&str, &'a dyn Rule>,
172            position: &HashMap<&str, usize>,
173            reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
174            visited: &mut HashSet<&'b str>,
175            visiting: &mut HashSet<&'b str>,
176            sorted: &mut Vec<&'a dyn Rule>,
177        ) where
178            'a: 'b,
179        {
180            if visited.contains(rule_name) {
181                return;
182            }
183
184            if visiting.contains(rule_name) {
185                // Cycle detected, but we'll just skip it
186                return;
187            }
188
189            visiting.insert(rule_name);
190
191            // Visit dependencies first, in input order
192            if let Some(deps) = reverse_deps.get(rule_name) {
193                let mut deps: Vec<&'b str> = deps.iter().copied().filter(|dep| rule_map.contains_key(dep)).collect();
194                deps.sort_by_key(|dep| position[dep]);
195                for dep in deps {
196                    visit(dep, rule_map, position, reverse_deps, visited, visiting, sorted);
197                }
198            }
199
200            visiting.remove(rule_name);
201            visited.insert(rule_name);
202
203            // Add this rule to sorted list
204            if let Some(&rule) = rule_map.get(rule_name) {
205                sorted.push(rule);
206            }
207        }
208
209        // Visit all rules
210        for rule in rules {
211            visit(
212                rule.name(),
213                &rule_map,
214                &position,
215                &reverse_deps,
216                &mut visited,
217                &mut visiting,
218                &mut sorted,
219            );
220        }
221
222        // Add any rules not in dependency graph
223        for rule in rules {
224            if !sorted.iter().any(|r| r.name() == rule.name()) {
225                sorted.push(rule.as_ref());
226            }
227        }
228
229        sorted
230    }
231
232    /// Apply fixes iteratively until no more fixes are needed or max iterations reached.
233    ///
234    /// This implements a Ruff-inspired fix loop that re-checks ALL rules after each fix
235    /// to detect cascading issues (e.g., MD046 creating code blocks that MD040 needs to fix).
236    ///
237    /// The `file_path` parameter is used to determine per-file flavor overrides. If provided,
238    /// the flavor for creating LintContext will be resolved using `config.get_flavor_for_file()`.
239    pub fn apply_fixes_iterative(
240        &self,
241        rules: &[Box<dyn Rule>],
242        all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
243        content: &mut String,
244        config: &Config,
245        max_iterations: usize,
246        file_path: Option<&std::path::Path>,
247    ) -> Result<FixResult, String> {
248        self.apply_fixes_iterative_with_paths(
249            rules,
250            all_warnings,
251            content,
252            config,
253            max_iterations,
254            crate::DocumentPaths::same(file_path),
255        )
256    }
257
258    /// Apply fixes with separate paths for configuration matching and rule filesystem access.
259    ///
260    /// Native file adapters pass the same path for both. Virtual adapters can use a
261    /// logical `config_path` for per-file flavor and ignores while leaving
262    /// `source_file` unset so filesystem-dependent rules stay disabled.
263    pub fn apply_fixes_iterative_with_paths(
264        &self,
265        rules: &[Box<dyn Rule>],
266        _all_warnings: &[LintWarning],
267        content: &mut String,
268        config: &Config,
269        max_iterations: usize,
270        paths: crate::DocumentPaths<'_>,
271    ) -> Result<FixResult, String> {
272        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
273        let max_iterations = max_iterations.min(MAX_ITERATIONS);
274
275        // Get optimal rule order based on dependencies
276        let ordered_rules = self.get_optimal_order(rules);
277
278        let mut total_fixed = 0;
279        let mut total_ctx_creations = 0;
280        let mut iterations = 0;
281
282        // History tracks (content_hash, rule_that_produced_this_state).
283        // The initial entry has an empty rule name (no rule produced the initial content).
284        let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
285
286        // Track which rules actually applied fixes
287        let mut fixed_rule_names: HashSet<&str> = HashSet::new();
288
289        // Config rule lists are guaranteed canonical by `Config::canonicalize_rule_lists`,
290        // so a plain string set matches `Rule::name()` directly.
291        let unfixable_rules: HashSet<String> = config.global.unfixable.iter().cloned().collect();
292        let fixable_rules: HashSet<String> = config.global.fixable.iter().cloned().collect();
293        let has_fixable_allowlist = !fixable_rules.is_empty();
294
295        // Per-file-ignores are config-driven, per-file rule exclusions. The
296        // coordinator is the single engine every fix path funnels through, so
297        // resolving them here guarantees `fmt`/fix never rewrites a rule the file
298        // has excluded - no caller can reintroduce issue #707 by forgetting to
299        // pre-filter. Empty when no path is available (e.g. WASM without a path).
300        let ignored_for_file: HashSet<String> = paths
301            .config_path
302            .map(|p| config.get_ignored_rules_for_file(p))
303            .unwrap_or_default();
304
305        // Ruff-style fix loop: keep applying fixes until content stabilizes
306        while iterations < max_iterations {
307            iterations += 1;
308
309            // Create fresh context for this iteration
310            // Use per-file flavor if file_path is provided, otherwise fall back to global flavor
311            let flavor = paths
312                .config_path
313                .map_or_else(|| config.markdown_flavor(), |path| config.get_flavor_for_file(path));
314            let ctx = LintContext::new(content, flavor, paths.source_file.map(std::path::Path::to_path_buf));
315            total_ctx_creations += 1;
316
317            // Inline `rumdl-configure-file` value overrides: when the document carries
318            // inline rule-config overrides, recreate the affected rules from the merged
319            // config so fixes honor them, matching the lint/diagnostics path. Without
320            // this, fix() would run with the base config and could rewrite content the
321            // configured rule considers valid.
322            let recreated_rules: HashMap<String, Box<dyn Rule>> = {
323                let inline_overrides = ctx.inline_config().get_all_rule_configs();
324                if inline_overrides.is_empty() {
325                    HashMap::new()
326                } else {
327                    let merged = config.merge_with_inline_config(ctx.inline_config());
328                    inline_overrides
329                        .keys()
330                        .filter_map(|name| {
331                            crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
332                        })
333                        .collect()
334                }
335            };
336
337            let mut any_fix_applied = false;
338            // The rule that applied a fix this iteration (used for cycle reporting).
339            let mut this_iter_rule: &str = "";
340
341            // Check and fix each rule in dependency order
342            for rule in &ordered_rules {
343                // Skip disabled rules
344                if unfixable_rules.contains(rule.name()) {
345                    continue;
346                }
347                if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
348                    continue;
349                }
350                // Skip rules excluded for this file via [per-file-ignores].
351                if ignored_for_file.contains(rule.name()) {
352                    continue;
353                }
354
355                // Use the inline-config-recreated instance when present so checks and
356                // fixes reflect inline `rumdl-configure-file` overrides; otherwise the
357                // base rule. Rule identity (name) is unchanged either way.
358                let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
359
360                // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
361                if effective_rule.should_skip(&ctx) {
362                    continue;
363                }
364
365                // Check if this rule has any current warnings
366                let Ok(warnings) = effective_rule.check(&ctx) else {
367                    continue;
368                };
369
370                if warnings.is_empty() {
371                    continue;
372                }
373
374                // Filter warnings through inline config to respect disable comments
375                let inline_config = ctx.inline_config();
376                let filtered_warnings =
377                    crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
378
379                if filtered_warnings.is_empty() {
380                    continue;
381                }
382
383                // Decide whether to dispatch to rule.fix(). Two paths qualify:
384                //   1. Any non-disabled warning carries an inline Fix (the
385                //      common case — most rules attach per-warning edits).
386                //   2. The rule advertises a fix capability via Rule::fix_capability().
387                //      This is for rules whose fix() rewrites at the document
388                //      level rather than producing per-warning edits (e.g.
389                //      MD046 fence-style normalization).
390                // A rule is skipped only when it has no inline fix AND advertises
391                // no fix capability (Unfixable). Unfixable rules attach no inline
392                // fixes, so in practice they are never dispatched to fix().
393                let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
394                let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
395                if !has_inline_fix && !rule_advertises_fix {
396                    continue;
397                }
398
399                // Apply fix
400                match effective_rule.fix(&ctx) {
401                    Ok(fixed_content) => {
402                        if fixed_content != *content {
403                            *content = fixed_content;
404                            total_fixed += 1;
405                            any_fix_applied = true;
406                            this_iter_rule = rule.name();
407                            fixed_rule_names.insert(rule.name());
408
409                            // Break to re-check all rules with the new content
410                            // This is the key difference from the old approach:
411                            // we always restart from the beginning after a fix
412                            break;
413                        }
414                    }
415                    Err(_) => {
416                        // Error applying fix, continue to next rule
417                        continue;
418                    }
419                }
420            }
421
422            let current_hash = hash_content(content);
423
424            // Check whether this content state has been seen before.
425            if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
426                if cycle_start == history.len() - 1 {
427                    // Content matches the last recorded state: nothing changed this iteration.
428                    return Ok(FixResult {
429                        rules_fixed: total_fixed,
430                        iterations,
431                        context_creations: total_ctx_creations,
432                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
433                        converged: true,
434                        conflicting_rules: Vec::new(),
435                        conflict_cycle: Vec::new(),
436                    });
437                } else {
438                    // Content matches an older state: oscillation cycle detected.
439                    // Collect the rules that participate in the cycle.
440                    let conflict_cycle: Vec<String> = history[cycle_start + 1..]
441                        .iter()
442                        .map(|(_, r)| r.to_string())
443                        .chain(std::iter::once(this_iter_rule.to_string()))
444                        .filter(|r| !r.is_empty())
445                        .collect();
446                    let conflicting_rules: Vec<String> = history[cycle_start + 1..]
447                        .iter()
448                        .map(|(_, r)| *r)
449                        .chain(std::iter::once(this_iter_rule))
450                        .filter(|r| !r.is_empty())
451                        .collect::<HashSet<&str>>()
452                        .into_iter()
453                        .map(std::string::ToString::to_string)
454                        .collect();
455                    return Ok(FixResult {
456                        rules_fixed: total_fixed,
457                        iterations,
458                        context_creations: total_ctx_creations,
459                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
460                        converged: false,
461                        conflicting_rules,
462                        conflict_cycle,
463                    });
464                }
465            }
466
467            // New state - record it.
468            history.push((current_hash, this_iter_rule));
469
470            // If no fix was applied this iteration, content is stable.
471            if !any_fix_applied {
472                return Ok(FixResult {
473                    rules_fixed: total_fixed,
474                    iterations,
475                    context_creations: total_ctx_creations,
476                    fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
477                    converged: true,
478                    conflicting_rules: Vec::new(),
479                    conflict_cycle: Vec::new(),
480                });
481            }
482        }
483
484        // Hit max iterations without detecting a cycle.
485        Ok(FixResult {
486            rules_fixed: total_fixed,
487            iterations,
488            context_creations: total_ctx_creations,
489            fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
490            converged: false,
491            conflicting_rules: Vec::new(),
492            conflict_cycle: Vec::new(),
493        })
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
501    use std::sync::atomic::{AtomicUsize, Ordering};
502
503    /// Mock rule that checks content and applies fixes based on a condition
504    #[derive(Clone)]
505    struct ConditionalFixRule {
506        name: &'static str,
507        /// Function to check if content has issues
508        check_fn: fn(&str) -> bool,
509        /// Function to fix content
510        fix_fn: fn(&str) -> String,
511    }
512
513    impl Rule for ConditionalFixRule {
514        fn name(&self) -> &'static str {
515            self.name
516        }
517
518        fn check(&self, ctx: &LintContext) -> LintResult {
519            if (self.check_fn)(ctx.content) {
520                Ok(vec![LintWarning {
521                    line: 1,
522                    column: 1,
523                    end_line: 1,
524                    end_column: 1,
525                    message: format!("{} issue found", self.name),
526                    rule_name: Some(self.name.to_string()),
527                    severity: Severity::Error,
528                    fix: Some(Fix::new(0..0, String::new())),
529                }])
530            } else {
531                Ok(vec![])
532            }
533        }
534
535        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
536            Ok((self.fix_fn)(ctx.content))
537        }
538
539        fn description(&self) -> &'static str {
540            "Conditional fix rule for testing"
541        }
542
543        fn category(&self) -> RuleCategory {
544            RuleCategory::Whitespace
545        }
546
547        fn as_any(&self) -> &dyn std::any::Any {
548            self
549        }
550    }
551
552    // Simple mock rule for basic tests
553    #[derive(Clone)]
554    struct MockRule {
555        name: &'static str,
556        warnings: Vec<LintWarning>,
557        fix_content: String,
558    }
559
560    impl Rule for MockRule {
561        fn name(&self) -> &'static str {
562            self.name
563        }
564
565        fn check(&self, _ctx: &LintContext) -> LintResult {
566            Ok(self.warnings.clone())
567        }
568
569        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
570            Ok(self.fix_content.clone())
571        }
572
573        fn description(&self) -> &'static str {
574            "Mock rule for testing"
575        }
576
577        fn category(&self) -> RuleCategory {
578            RuleCategory::Whitespace
579        }
580
581        fn as_any(&self) -> &dyn std::any::Any {
582            self
583        }
584    }
585
586    #[test]
587    fn test_dependency_ordering() {
588        let coordinator = FixCoordinator::new();
589
590        let rules: Vec<Box<dyn Rule>> = vec![
591            Box::new(MockRule {
592                name: "MD009",
593                warnings: vec![],
594                fix_content: "".to_string(),
595            }),
596            Box::new(MockRule {
597                name: "MD013",
598                warnings: vec![],
599                fix_content: "".to_string(),
600            }),
601            Box::new(MockRule {
602                name: "MD010",
603                warnings: vec![],
604                fix_content: "".to_string(),
605            }),
606            Box::new(MockRule {
607                name: "MD007",
608                warnings: vec![],
609                fix_content: "".to_string(),
610            }),
611        ];
612
613        let ordered = coordinator.get_optimal_order(&rules);
614        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
615
616        // MD010 should come before MD007 (dependency)
617        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
618        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
619        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
620
621        // MD013 should come before MD009 (dependency)
622        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
623        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
624        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
625    }
626
627    #[test]
628    fn test_single_rule_fix() {
629        let coordinator = FixCoordinator::new();
630
631        // Rule that removes "BAD" from content
632        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
633            name: "RemoveBad",
634            check_fn: |content| content.contains("BAD"),
635            fix_fn: |content| content.replace("BAD", "GOOD"),
636        })];
637
638        let mut content = "This is BAD content".to_string();
639        let config = Config::default();
640
641        let result = coordinator
642            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
643            .unwrap();
644
645        assert_eq!(content, "This is GOOD content");
646        assert_eq!(result.rules_fixed, 1);
647        assert!(result.converged);
648    }
649
650    #[test]
651    fn test_cascading_fixes() {
652        // Simulates MD046 -> MD040 cascade:
653        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
654        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
655        let coordinator = FixCoordinator::new();
656
657        let rules: Vec<Box<dyn Rule>> = vec![
658            Box::new(ConditionalFixRule {
659                name: "Rule1_IndentToFence",
660                check_fn: |content| content.contains("INDENT"),
661                fix_fn: |content| content.replace("INDENT", "FENCE"),
662            }),
663            Box::new(ConditionalFixRule {
664                name: "Rule2_FenceToLang",
665                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
666                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
667            }),
668        ];
669
670        let mut content = "Code: INDENT".to_string();
671        let config = Config::default();
672
673        let result = coordinator
674            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
675            .unwrap();
676
677        // Should reach final state in one run (internally multiple iterations)
678        assert_eq!(content, "Code: FENCE_LANG");
679        assert_eq!(result.rules_fixed, 2);
680        assert!(result.converged);
681        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
682    }
683
684    #[test]
685    fn test_indirect_cascade() {
686        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
687        // Rule1: adds "BLANK" (like MD022 adding blank line)
688        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
689        // Rule3: converts "FENCE" to "FENCE_LANG"
690        let coordinator = FixCoordinator::new();
691
692        let rules: Vec<Box<dyn Rule>> = vec![
693            Box::new(ConditionalFixRule {
694                name: "Rule1_AddBlank",
695                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
696                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
697            }),
698            Box::new(ConditionalFixRule {
699                name: "Rule2_CodeToFence",
700                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
701                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
702                fix_fn: |content| content.replace("CODE", "FENCE"),
703            }),
704            Box::new(ConditionalFixRule {
705                name: "Rule3_AddLang",
706                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
707                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
708            }),
709        ];
710
711        let mut content = "HEADING CODE".to_string();
712        let config = Config::default();
713
714        let result = coordinator
715            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
716            .unwrap();
717
718        // Key assertion: all fixes applied in single run
719        assert_eq!(content, "HEADING BLANK FENCE_LANG");
720        assert_eq!(result.rules_fixed, 3);
721        assert!(result.converged);
722    }
723
724    #[test]
725    fn test_unfixable_rules_skipped() {
726        let coordinator = FixCoordinator::new();
727
728        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
729            name: "MD001",
730            check_fn: |content| content.contains("BAD"),
731            fix_fn: |content| content.replace("BAD", "GOOD"),
732        })];
733
734        let mut content = "BAD content".to_string();
735        let mut config = Config::default();
736        config.global.unfixable = vec!["MD001".to_string()];
737
738        let result = coordinator
739            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
740            .unwrap();
741
742        assert_eq!(content, "BAD content"); // Should not be changed
743        assert_eq!(result.rules_fixed, 0);
744        assert!(result.converged);
745    }
746
747    #[test]
748    fn test_fixable_allowlist() {
749        let coordinator = FixCoordinator::new();
750
751        let rules: Vec<Box<dyn Rule>> = vec![
752            Box::new(ConditionalFixRule {
753                name: "MD001",
754                check_fn: |content| content.contains('A'),
755                fix_fn: |content| content.replace('A', "X"),
756            }),
757            Box::new(ConditionalFixRule {
758                name: "MD002",
759                check_fn: |content| content.contains('B'),
760                fix_fn: |content| content.replace('B', "Y"),
761            }),
762        ];
763
764        let mut content = "AB".to_string();
765        let mut config = Config::default();
766        config.global.fixable = vec!["MD001".to_string()];
767
768        let result = coordinator
769            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
770            .unwrap();
771
772        assert_eq!(content, "XB"); // Only A->X, B unchanged
773        assert_eq!(result.rules_fixed, 1);
774    }
775
776    /// Aliases in `unfixable` (e.g. `"heading-increment"`) must reach
777    /// `apply_fixes_iterative` already canonicalised — the runtime invariant
778    /// enforced by `Config::canonicalize_rule_lists` at every mutation
779    /// boundary (`From<SourcedConfig> for Config`, LSP `apply_lsp_settings_*`,
780    /// WASM `to_config_with_warnings`). The fix coordinator therefore matches
781    /// against `Rule::name()` with plain string equality.
782    #[test]
783    fn test_unfixable_rules_resolved_from_alias() {
784        let coordinator = FixCoordinator::new();
785
786        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
787            name: "MD001",
788            check_fn: |content| content.contains("BAD"),
789            fix_fn: |content| content.replace("BAD", "GOOD"),
790        })];
791
792        let mut content = "BAD content".to_string();
793        let mut config = Config::default();
794        // Caller writes the alias…
795        config.global.unfixable = vec!["heading-increment".to_string()];
796        // …and the boundary canonicalises it to "MD001" before lint/fix sees it.
797        config.canonicalize_rule_lists();
798
799        let result = coordinator
800            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
801            .unwrap();
802
803        assert_eq!(content, "BAD content");
804        assert_eq!(result.rules_fixed, 0);
805        assert!(result.converged);
806    }
807
808    /// Counterpart to `test_unfixable_rules_resolved_from_alias` for the
809    /// fixable allowlist. Same invariant: callers may write aliases, but the
810    /// boundary canonicalises before the fix coordinator sees the config.
811    #[test]
812    fn test_fixable_allowlist_resolved_from_alias() {
813        let coordinator = FixCoordinator::new();
814
815        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
816            name: "MD001",
817            check_fn: |content| content.contains("BAD"),
818            fix_fn: |content| content.replace("BAD", "GOOD"),
819        })];
820
821        let mut content = "BAD content".to_string();
822        let mut config = Config::default();
823        config.global.fixable = vec!["heading-increment".to_string()];
824        config.canonicalize_rule_lists();
825
826        let result = coordinator
827            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
828            .unwrap();
829
830        assert_eq!(content, "GOOD content");
831        assert_eq!(result.rules_fixed, 1);
832    }
833
834    #[test]
835    fn test_max_iterations_limit() {
836        let coordinator = FixCoordinator::new();
837
838        // Rule that always changes content (pathological case)
839        static COUNTER: AtomicUsize = AtomicUsize::new(0);
840
841        #[derive(Clone)]
842        struct AlwaysChangeRule;
843        impl Rule for AlwaysChangeRule {
844            fn name(&self) -> &'static str {
845                "AlwaysChange"
846            }
847            fn check(&self, _: &LintContext) -> LintResult {
848                Ok(vec![LintWarning {
849                    line: 1,
850                    column: 1,
851                    end_line: 1,
852                    end_column: 1,
853                    message: "Always".to_string(),
854                    rule_name: Some("AlwaysChange".to_string()),
855                    severity: Severity::Error,
856                    fix: Some(Fix::new(0..0, String::new())),
857                }])
858            }
859            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
860                COUNTER.fetch_add(1, Ordering::SeqCst);
861                Ok(format!("{}x", ctx.content))
862            }
863            fn description(&self) -> &'static str {
864                "Always changes"
865            }
866            fn category(&self) -> RuleCategory {
867                RuleCategory::Whitespace
868            }
869            fn as_any(&self) -> &dyn std::any::Any {
870                self
871            }
872        }
873
874        COUNTER.store(0, Ordering::SeqCst);
875        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
876
877        let mut content = "test".to_string();
878        let config = Config::default();
879
880        let result = coordinator
881            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
882            .unwrap();
883
884        // Should stop at max iterations
885        assert_eq!(result.iterations, 5);
886        assert!(!result.converged);
887        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
888    }
889
890    #[test]
891    fn test_empty_rules() {
892        let coordinator = FixCoordinator::new();
893        let rules: Vec<Box<dyn Rule>> = vec![];
894
895        let mut content = "unchanged".to_string();
896        let config = Config::default();
897
898        let result = coordinator
899            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
900            .unwrap();
901
902        assert_eq!(result.rules_fixed, 0);
903        assert_eq!(result.iterations, 1);
904        assert!(result.converged);
905        assert_eq!(content, "unchanged");
906    }
907
908    #[test]
909    fn test_no_warnings_no_changes() {
910        let coordinator = FixCoordinator::new();
911
912        // Rule that finds no issues
913        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
914            name: "NoIssues",
915            check_fn: |_| false, // Never finds issues
916            fix_fn: |content| content.to_string(),
917        })];
918
919        let mut content = "clean content".to_string();
920        let config = Config::default();
921
922        let result = coordinator
923            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
924            .unwrap();
925
926        assert_eq!(content, "clean content");
927        assert_eq!(result.rules_fixed, 0);
928        assert!(result.converged);
929    }
930
931    #[test]
932    fn test_oscillation_detection() {
933        // Two rules that fight each other: Rule A changes "foo" → "bar", Rule B changes "bar" → "foo".
934        // The fix loop should detect this as an oscillation cycle and stop early with
935        // conflicting_rules populated rather than running all 100 iterations.
936        let coordinator = FixCoordinator::new();
937
938        let rules: Vec<Box<dyn Rule>> = vec![
939            Box::new(ConditionalFixRule {
940                name: "RuleA",
941                check_fn: |content| content.contains("foo"),
942                fix_fn: |content| content.replace("foo", "bar"),
943            }),
944            Box::new(ConditionalFixRule {
945                name: "RuleB",
946                check_fn: |content| content.contains("bar"),
947                fix_fn: |content| content.replace("bar", "foo"),
948            }),
949        ];
950
951        let mut content = "foo".to_string();
952        let config = Config::default();
953
954        let result = coordinator
955            .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
956            .unwrap();
957
958        // Should detect the cycle quickly, not burn through all 100 iterations.
959        assert!(!result.converged, "Should not converge in an oscillating pair");
960        assert!(
961            result.iterations < 10,
962            "Cycle detection should stop well before max_iterations (got {})",
963            result.iterations
964        );
965
966        // Both conflicting rules should be identified.
967        let mut conflicting = result.conflicting_rules.clone();
968        conflicting.sort();
969        assert_eq!(
970            conflicting,
971            vec!["RuleA".to_string(), "RuleB".to_string()],
972            "Both oscillating rules must be reported"
973        );
974        assert_eq!(
975            result.conflict_cycle,
976            vec!["RuleA".to_string(), "RuleB".to_string()],
977            "Cycle should preserve the observed application order"
978        );
979    }
980
981    #[test]
982    fn test_cyclic_dependencies_handled() {
983        let mut coordinator = FixCoordinator::new();
984
985        // Create a cycle: A -> B -> C -> A
986        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
987        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
988        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
989
990        let rules: Vec<Box<dyn Rule>> = vec![
991            Box::new(MockRule {
992                name: "RuleA",
993                warnings: vec![],
994                fix_content: "".to_string(),
995            }),
996            Box::new(MockRule {
997                name: "RuleB",
998                warnings: vec![],
999                fix_content: "".to_string(),
1000            }),
1001            Box::new(MockRule {
1002                name: "RuleC",
1003                warnings: vec![],
1004                fix_content: "".to_string(),
1005            }),
1006        ];
1007
1008        // Should not panic or infinite loop
1009        let ordered = coordinator.get_optimal_order(&rules);
1010
1011        // Should return all rules despite cycle
1012        assert_eq!(ordered.len(), 3);
1013    }
1014
1015    #[test]
1016    fn test_fix_is_idempotent() {
1017        // This is the key test for issue #271
1018        let coordinator = FixCoordinator::new();
1019
1020        let rules: Vec<Box<dyn Rule>> = vec![
1021            Box::new(ConditionalFixRule {
1022                name: "Rule1",
1023                check_fn: |content| content.contains('A'),
1024                fix_fn: |content| content.replace('A', "B"),
1025            }),
1026            Box::new(ConditionalFixRule {
1027                name: "Rule2",
1028                check_fn: |content| content.contains('B') && !content.contains('C'),
1029                fix_fn: |content| content.replace('B', "BC"),
1030            }),
1031        ];
1032
1033        let config = Config::default();
1034
1035        // First run
1036        let mut content1 = "A".to_string();
1037        let result1 = coordinator
1038            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
1039            .unwrap();
1040
1041        // Second run on same final content
1042        let mut content2 = content1.clone();
1043        let result2 = coordinator
1044            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
1045            .unwrap();
1046
1047        // Should be identical (idempotent)
1048        assert_eq!(content1, content2);
1049        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
1050        assert!(result1.converged);
1051        assert!(result2.converged);
1052    }
1053
1054    #[test]
1055    fn test_apply_fixes_collapses_double_space_without_inline_override() {
1056        // Control: MD064 actually rewrites this content, so the override test below
1057        // is not vacuous - without an inline override the double space is collapsed.
1058        let mut content = String::from("`<svg>`.  Fortunately\n");
1059        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1060        FixCoordinator::new()
1061            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1062            .unwrap();
1063        assert_eq!(
1064            content, "`<svg>`. Fortunately\n",
1065            "MD064 collapses the sentence double space when not overridden"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
1071        // The coordinator is the single engine every fix path funnels through. Handed
1072        // the UNFILTERED rule set plus a file path, it must still skip any rule the
1073        // path excludes via [per-file-ignores] - so no caller can reintroduce #707 by
1074        // forgetting to pre-filter.
1075        let coordinator = FixCoordinator::new();
1076        let rules: Vec<Box<dyn Rule>> = vec![
1077            Box::new(ConditionalFixRule {
1078                name: "MD004",
1079                check_fn: |c| c.contains('*'),
1080                fix_fn: |c| c.replace('*', "-"),
1081            }),
1082            Box::new(ConditionalFixRule {
1083                name: "MD032",
1084                check_fn: |c| c.contains("PARENT"),
1085                fix_fn: |c| c.replace("PARENT", "parent"),
1086            }),
1087        ];
1088
1089        let mut config = Config::default();
1090        config
1091            .per_file_ignores
1092            .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1093        config.canonicalize_rule_lists();
1094
1095        // Path matches the ignore glob: MD004 must be skipped, MD032 must still apply.
1096        let mut content = "* PARENT".to_string();
1097        let result = coordinator
1098            .apply_fixes_iterative(
1099                &rules,
1100                &[],
1101                &mut content,
1102                &config,
1103                10,
1104                Some(std::path::Path::new("slides/deck.md")),
1105            )
1106            .unwrap();
1107        assert_eq!(
1108            content, "* parent",
1109            "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1110        );
1111        assert!(result.fixed_rule_names.contains("MD032"));
1112        assert!(!result.fixed_rule_names.contains("MD004"));
1113
1114        // Control: a path NOT matching the glob applies both rules, proving the skip
1115        // above is driven by per-file-ignores and not something else.
1116        let mut other = "* PARENT".to_string();
1117        coordinator
1118            .apply_fixes_iterative(
1119                &rules,
1120                &[],
1121                &mut other,
1122                &config,
1123                10,
1124                Some(std::path::Path::new("docs/other.md")),
1125            )
1126            .unwrap();
1127        assert_eq!(
1128            other, "- parent",
1129            "both rules apply when the path is not per-file-ignored"
1130        );
1131    }
1132
1133    #[test]
1134    fn test_apply_fixes_honors_inline_configure_file_overrides() {
1135        // A document that relaxes a rule via an inline `rumdl-configure-file` override
1136        // must survive the fix coordinator unchanged: fixes have to honor inline value
1137        // overrides the same way lint/diagnostics do, otherwise "fix" rewrites content
1138        // the configured rule considers valid.
1139        let mut content = String::from(
1140            "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`.  Fortunately\n",
1141        );
1142        let original = content.clone();
1143        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1144        FixCoordinator::new()
1145            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1146            .unwrap();
1147        assert_eq!(
1148            content, original,
1149            "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1150        );
1151    }
1152
1153    #[test]
1154    fn layout_rules_run_after_every_content_rewrite() {
1155        // Reflow decides wrap points from the inline content it sees, and its default
1156        // mode never revisits a line that is under the limit. Every rule that is not
1157        // downstream of MD013 must therefore be ordered before it, over the FULL rule
1158        // set, so a new inline rewrite (like MD054 in issue #819) is covered without
1159        // anyone remembering to list it.
1160        let rules = crate::rules::all_rules(&Config::default());
1161        let ordered = FixCoordinator::new().get_optimal_order(&rules);
1162        let names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
1163        assert_eq!(names.len(), rules.len(), "ordering must keep every rule exactly once");
1164
1165        let md013 = names.iter().position(|&n| n == "MD013").unwrap();
1166        let after: Vec<&str> = names[md013 + 1..].to_vec();
1167        let mut expected_after = vec!["MD009", "MD012"];
1168        expected_after.sort_unstable();
1169        let mut actual_after = after.clone();
1170        actual_after.sort_unstable();
1171        assert_eq!(
1172            actual_after, expected_after,
1173            "only MD013's own dependents may follow it; order was {names:?}"
1174        );
1175    }
1176
1177    #[test]
1178    fn link_style_rewrite_runs_before_reflow_regardless_of_input_order() {
1179        // Regression for issue #819: with MD054 after MD013, reflow wrapped on the
1180        // `[text](url)` width and the shortcut rewrite then left every line short.
1181        let coordinator = FixCoordinator::new();
1182        for input in [vec!["MD013", "MD054"], vec!["MD054", "MD013"]] {
1183            let rules: Vec<Box<dyn Rule>> = input
1184                .iter()
1185                .map(|name| crate::rules::create_rule_by_name(name, &Config::default()).unwrap())
1186                .collect();
1187            let names: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1188            assert_eq!(names, vec!["MD054", "MD013"], "input order {input:?}");
1189        }
1190    }
1191
1192    #[test]
1193    fn optimal_order_is_the_same_on_every_call() {
1194        // The prerequisite sets are hashed, and every HashMap gets its own seed, so
1195        // an ordering that iterated them directly would differ from one call (and
1196        // one process) to the next. That showed up as `rumdl fmt` producing two
1197        // different outputs for the same file. Rules the table leaves unordered
1198        // keep their input order instead.
1199        let rules = crate::rules::all_rules(&Config::default());
1200        let coordinator = FixCoordinator::new();
1201        let first: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1202        for _ in 0..20 {
1203            let again: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
1204            assert_eq!(again, first);
1205        }
1206    }
1207}