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