Skip to main content

rumdl_lib/
fix_coordinator.rs

1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{Fix, FixCapability, LintWarning, Rule};
4use std::borrow::Cow;
5use std::cell::RefCell;
6use std::collections::hash_map::DefaultHasher;
7use std::collections::{HashMap, HashSet};
8use std::hash::{Hash, Hasher};
9use std::sync::LazyLock;
10
11#[cfg(test)]
12mod diagnostic_edits_tests;
13
14mod cleanup_effects;
15
16/// Maximum number of fix iterations before stopping (same as Ruff)
17const MAX_ITERATIONS: usize = 100;
18
19/// Rules whose fix lays text out on lines rather than changing what the text says.
20///
21/// Reflow picks wrap points from the width of the inline content in front of it,
22/// and its default mode only revisits a line that is over the limit. A rewrite that
23/// shortens inline content AFTER reflow (a link turned into a shortcut reference, a
24/// bare URL wrapped in angle brackets, spaces removed inside emphasis) therefore
25/// leaves every affected line short for good, and a second run cannot repair it
26/// because a short line is never a finding. Ordering layout after every content
27/// rewrite is the invariant; the sort applies it to the whole rule set, so a rewrite
28/// does not have to be listed here to be covered (listing MD054 by hand is exactly
29/// what was missing when it slipped through).
30const LAYOUT_RULES: &[&str] = &["MD013"];
31
32/// Fix plans tied to one immutable source revision and configured rule instance.
33/// Rules absent from this map must still run in the fix pass.
34pub(crate) type FixCheckResults = HashMap<RuleCheckKey, FixPlan>;
35
36pub(crate) enum FixPlan {
37    Unchanged,
38    Edits(Vec<Fix>),
39    Custom,
40}
41
42impl FixPlan {
43    pub(crate) fn capture(rule: &dyn Rule, ctx: &LintContext<'_>, warnings: &[LintWarning]) -> Self {
44        let mut enabled = warnings
45            .iter()
46            .filter(|warning| crate::utils::fix_utils::warning_is_enabled(warning, ctx.inline_config(), rule.name()))
47            .peekable();
48        if enabled.peek().is_none() {
49            return Self::Unchanged;
50        }
51        if !rule.fixes_from_diagnostics() {
52            return Self::Custom;
53        }
54        let edits: Vec<_> = enabled.filter_map(|warning| warning.fix.clone()).collect();
55        if edits.is_empty() {
56            Self::Unchanged
57        } else {
58            Self::Edits(edits)
59        }
60    }
61}
62
63/// Raw findings checked on one immutable document version. Never carried across
64/// a rewrite; report filtering belongs to the lint driver, after these checks.
65type RuleCheckKey = (std::any::TypeId, *const ());
66pub(crate) type RawRuleChecks = HashMap<RuleCheckKey, Vec<LintWarning>>;
67
68/// Identify the selected configured instance, including when callers supply two
69/// instances with the same name. Keys live only for this invocation.
70pub(crate) fn rule_check_key(rule: &dyn Rule) -> RuleCheckKey {
71    // Zero-sized rules may share a data address; their types distinguish them.
72    (rule.as_any().type_id(), std::ptr::from_ref(rule).cast::<()>())
73}
74type StableAnalysis<'a> = dyn FnMut(&LintContext<'_>, &mut RawRuleChecks) + 'a;
75
76/// Whether configuration lets `rule_name` apply fixes: `unfixable` does not
77/// list it, and `fixable`, when set, does.
78///
79/// Config rule lists are canonical (`Config::canonicalize_rule_lists`), so a
80/// name compares exactly with `Rule::name()`.
81pub fn config_allows_fix(config: &Config, rule_name: &str) -> bool {
82    let global = &config.global;
83    !global.unfixable.iter().any(|name| name == rule_name)
84        && (global.fixable.is_empty() || global.fixable.iter().any(|name| name == rule_name))
85}
86
87/// Where the fix loop spends its time, printed as `[FIX]` lines when
88/// `RUMDL_PROFILE_RULES` is set, beside the `[RULE]` lines of the lint pass.
89///
90/// The fix loop re-parses the document and re-checks rules after every fix, so a
91/// rule's cost there is its lint cost times the number of times it runs. The
92/// report says how many contexts were built and, per rule, how often it was
93/// checked, how often diagnostic edits or its custom `fix()` were attempted
94/// (whether or not anything changed), and the time both took together.
95#[cfg(not(target_arch = "wasm32"))]
96struct FixLoopProfile {
97    contexts: std::cell::Cell<usize>,
98    handoffs: std::cell::Cell<usize>,
99    parse: std::cell::Cell<std::time::Duration>,
100    rules: std::cell::RefCell<Vec<RuleFixProfile>>,
101}
102
103#[cfg(not(target_arch = "wasm32"))]
104struct RuleFixProfile {
105    name: &'static str,
106    checks: usize,
107    fix_calls: usize,
108    elapsed: std::time::Duration,
109}
110
111#[cfg(not(target_arch = "wasm32"))]
112impl FixLoopProfile {
113    fn from_env() -> Option<Self> {
114        std::env::var_os("RUMDL_PROFILE_RULES").map(|_| Self {
115            contexts: std::cell::Cell::new(0),
116            handoffs: std::cell::Cell::new(0),
117            parse: std::cell::Cell::new(std::time::Duration::ZERO),
118            rules: std::cell::RefCell::new(Vec::new()),
119        })
120    }
121
122    fn record_context(&self, elapsed: std::time::Duration) {
123        self.contexts.set(self.contexts.get() + 1);
124        self.parse.set(self.parse.get() + elapsed);
125    }
126
127    fn with_rule(&self, name: &'static str, update: impl FnOnce(&mut RuleFixProfile)) {
128        let mut rules = self.rules.borrow_mut();
129        let index = rules.iter().position(|rule| rule.name == name).unwrap_or_else(|| {
130            rules.push(RuleFixProfile {
131                name,
132                checks: 0,
133                fix_calls: 0,
134                elapsed: std::time::Duration::ZERO,
135            });
136            rules.len() - 1
137        });
138        update(&mut rules[index]);
139    }
140
141    /// Count one check of `name` and time it, and any fix that follows, until
142    /// the returned guard drops.
143    fn time<'a>(&'a self, name: &'static str) -> RuleFixTimer<'a> {
144        self.with_rule(name, |rule| rule.checks += 1);
145        RuleFixTimer {
146            profile: self,
147            name,
148            start: std::time::Instant::now(),
149        }
150    }
151
152    fn fix_attempted(&self, name: &'static str) {
153        self.with_rule(name, |rule| rule.fix_calls += 1);
154    }
155}
156
157#[cfg(not(target_arch = "wasm32"))]
158impl Drop for FixLoopProfile {
159    fn drop(&mut self) {
160        eprintln!(
161            "[FIX]  {:6} {:?} ({} contexts)",
162            "parse",
163            self.parse.get(),
164            self.contexts.get()
165        );
166
167        eprintln!("[FIX]  shared parser ({} handoffs)", self.handoffs.get());
168        for rule in self.rules.borrow().iter() {
169            eprintln!(
170                "[FIX]  {:6} {:?} ({} checks, {} fix calls)",
171                rule.name, rule.elapsed, rule.checks, rule.fix_calls
172            );
173        }
174    }
175}
176
177#[cfg(not(target_arch = "wasm32"))]
178struct RuleFixTimer<'a> {
179    profile: &'a FixLoopProfile,
180    name: &'static str,
181    start: std::time::Instant,
182}
183
184#[cfg(not(target_arch = "wasm32"))]
185impl Drop for RuleFixTimer<'_> {
186    fn drop(&mut self) {
187        let elapsed = self.start.elapsed();
188        self.profile.with_rule(self.name, |rule| rule.elapsed += elapsed);
189    }
190}
191
192/// Result of applying fixes iteratively
193///
194/// This struct provides named fields instead of a tuple to prevent
195/// confusion about the meaning of each value.
196#[derive(Debug, Clone)]
197pub struct FixResult {
198    /// Total number of rules that successfully applied fixes
199    pub rules_fixed: usize,
200    /// Number of fix iterations performed
201    pub iterations: usize,
202    /// Number of LintContext instances created during fixing
203    pub context_creations: usize,
204    /// Names of rules that applied fixes
205    pub fixed_rule_names: HashSet<String>,
206    /// Whether the fix process converged (content stabilized)
207    pub converged: bool,
208    /// Rules identified as participants in an oscillation cycle.
209    /// Populated only when `converged == false` and a cycle was detected.
210    /// Empty when the fix loop hit `max_iterations` without cycling.
211    pub conflicting_rules: Vec<String>,
212    /// Ordered rule sequence observed in the cycle.
213    /// If non-empty, this can be rendered as a loop by appending the first rule
214    /// at the end (e.g. `MD044 -> MD063 -> MD044`).
215    pub conflict_cycle: Vec<String>,
216}
217
218/// Calculate hash of content for convergence detection
219fn hash_content(content: &str) -> u64 {
220    let mut hasher = DefaultHasher::new();
221    content.hash(&mut hasher);
222    hasher.finish()
223}
224
225/// Coordinates rule fixing to minimize the number of passes needed
226pub struct FixCoordinator {
227    /// Rules that should run before others (rule -> rules that depend on it)
228    dependencies: Cow<'static, RuleDependencies>,
229    cleanup_effects: bool,
230}
231
232type RuleDependencies = HashMap<&'static str, Vec<&'static str>>;
233
234static DEFAULT_DEPENDENCIES: LazyLock<RuleDependencies> = LazyLock::new(FixCoordinator::build_dependencies);
235
236struct CachedRuleOrder {
237    names: Vec<&'static str>,
238    positions: Vec<usize>,
239}
240
241thread_local! {
242    // Keep one selection per worker. Only indices survive across documents,
243    // never configured rule instances or document facts.
244    static RULE_ORDER: RefCell<Option<CachedRuleOrder>> = const { RefCell::new(None) };
245}
246
247impl Default for FixCoordinator {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253impl FixCoordinator {
254    pub fn new() -> Self {
255        Self {
256            dependencies: Cow::Borrowed(&DEFAULT_DEPENDENCIES),
257            cleanup_effects: false,
258        }
259    }
260
261    /// Opt in only for the formatter, which does not expose per-revision analysis.
262    pub fn for_formatter() -> Self {
263        Self {
264            cleanup_effects: true,
265            ..Self::new()
266        }
267    }
268
269    fn build_dependencies() -> RuleDependencies {
270        let mut dependencies = HashMap::new();
271
272        // CRITICAL DEPENDENCIES:
273        // These dependencies prevent cascading issues that require multiple passes
274
275        // MD064 (multiple consecutive spaces) MUST run before:
276        // - MD010 (tabs->spaces) - MD010 replaces tabs with multiple spaces (e.g., 4),
277        //   which MD064 would incorrectly collapse back to 1 space if it ran after
278        dependencies.insert("MD064", vec!["MD010"]);
279
280        // MD010 (tabs->spaces) MUST run before:
281        // - MD007 (list indentation) - because tabs affect indent calculation
282        // - MD005 (list indent consistency) - same reason
283        dependencies.insert("MD010", vec!["MD007", "MD005"]);
284
285        // MD013 (line length) MUST run before:
286        // - MD009 (trailing spaces) - line wrapping might add trailing spaces that need cleanup
287        // - MD012 (multiple blanks) - reflowing can affect blank lines
288        // Note: MD013 now trims trailing whitespace during reflow to prevent mid-line spaces
289        dependencies.insert("MD013", vec!["MD009", "MD012"]);
290
291        // MD004 (list style) should run before:
292        // - MD007 (list indentation) - changing markers affects indentation
293        dependencies.insert("MD004", vec!["MD007"]);
294
295        // MD022/MD023 (heading spacing) should run before:
296        // - MD012 (multiple blanks) - heading fixes can affect blank lines
297        dependencies.insert("MD022", vec!["MD012"]);
298        dependencies.insert("MD023", vec!["MD012"]);
299
300        // MD070 (nested fence collision) MUST run before:
301        // - MD040 (code language) - MD070 changes block structure, making orphan fences into content
302        // - MD031 (blanks around fences) - same reason
303        dependencies.insert("MD070", vec!["MD040", "MD031"]);
304
305        // MD005/MD077 (list indent and continuation indent) MUST run before:
306        // - MD032 (blanks around lists) - MD005/MD077 fix nesting structure that MD032 relies on
307        //   to correctly identify list block boundaries; running MD032 first on under-indented
308        //   content causes spurious blank-line insertions inside list items
309        dependencies.insert("MD005", vec!["MD032"]);
310        dependencies.insert("MD077", vec!["MD032"]);
311
312        dependencies
313    }
314
315    /// The given rules plus everything that transitively depends on them.
316    fn downstream_of(&self, roots: &[&'static str]) -> HashSet<&'static str> {
317        let mut closure: HashSet<&'static str> = HashSet::new();
318        let mut pending: Vec<&'static str> = roots.to_vec();
319        while let Some(name) = pending.pop() {
320            if !closure.insert(name) {
321                continue;
322            }
323            if let Some(dependents) = self.dependencies.get(name) {
324                pending.extend(dependents.iter().copied());
325            }
326        }
327        closure
328    }
329
330    /// Get the optimal order for running rules based on dependencies
331    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
332        // A private, modified graph is sorted directly, including after it has
333        // been changed. The shared graph's order depends only on input names.
334        let shared_graph = matches!(&self.dependencies, Cow::Borrowed(graph) if std::ptr::eq(*graph, LazyLock::force(&DEFAULT_DEPENDENCIES)));
335        if shared_graph
336            && let Some(ordered) = RULE_ORDER.with_borrow(|cache| {
337                let cache = cache.as_ref()?;
338                (cache.names.len() == rules.len()
339                    && cache.names.iter().zip(rules).all(|(name, rule)| *name == rule.name()))
340                .then(|| cache.positions.iter().map(|&index| rules[index].as_ref()).collect())
341            })
342        {
343            return ordered;
344        }
345        // Build a map of rule names to rules for quick lookup
346        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
347        // Input position of each rule. Prerequisites are visited in this order so
348        // that rules the dependency table leaves unordered keep their input order,
349        // and so the result is the same on every run: the sets below are hashed,
350        // and iterating them directly would let the hasher's per-process seed pick
351        // the order of the whole rule set.
352        let position: HashMap<&str, usize> = rules.iter().enumerate().map(|(i, r)| (r.name(), i)).collect();
353
354        // Build reverse dependencies (rule -> rules it depends on)
355        let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
356        for (prereq, dependents) in self.dependencies.iter() {
357            for dependent in dependents {
358                reverse_deps.entry(dependent).or_default().insert(prereq);
359            }
360        }
361
362        // A layout rule runs after every rule that is not downstream of it. Rules
363        // downstream of a layout rule (its dependents, transitively) are excluded so
364        // the declared edges MD013 -> MD009/MD012 stay acyclic.
365        let downstream_of_layout = self.downstream_of(LAYOUT_RULES);
366        for &layout_rule in LAYOUT_RULES {
367            let prereqs = reverse_deps.entry(layout_rule).or_default();
368            prereqs.extend(
369                rule_map
370                    .keys()
371                    .copied()
372                    .filter(|name| *name != layout_rule && !downstream_of_layout.contains(name)),
373            );
374        }
375
376        // Perform topological sort
377        let mut sorted = Vec::new();
378        let mut visited: HashSet<&str> = HashSet::new();
379        let mut visiting: HashSet<&str> = HashSet::new();
380
381        fn visit<'a, 'b>(
382            rule_name: &'b str,
383            rule_map: &HashMap<&str, &'a dyn Rule>,
384            position: &HashMap<&str, usize>,
385            reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
386            visited: &mut HashSet<&'b str>,
387            visiting: &mut HashSet<&'b str>,
388            sorted: &mut Vec<&'a dyn Rule>,
389        ) where
390            'a: 'b,
391        {
392            if visited.contains(rule_name) {
393                return;
394            }
395
396            if visiting.contains(rule_name) {
397                // Cycle detected, but we'll just skip it
398                return;
399            }
400
401            visiting.insert(rule_name);
402
403            // Visit dependencies first, in input order
404            if let Some(deps) = reverse_deps.get(rule_name) {
405                let mut deps: Vec<&'b str> = deps.iter().copied().filter(|dep| rule_map.contains_key(dep)).collect();
406                deps.sort_by_key(|dep| position[dep]);
407                for dep in deps {
408                    visit(dep, rule_map, position, reverse_deps, visited, visiting, sorted);
409                }
410            }
411
412            visiting.remove(rule_name);
413            visited.insert(rule_name);
414
415            // Add this rule to sorted list
416            if let Some(&rule) = rule_map.get(rule_name) {
417                sorted.push(rule);
418            }
419        }
420
421        // Visit all rules
422        for rule in rules {
423            visit(
424                rule.name(),
425                &rule_map,
426                &position,
427                &reverse_deps,
428                &mut visited,
429                &mut visiting,
430                &mut sorted,
431            );
432        }
433
434        // Add any rules not in dependency graph
435        for rule in rules {
436            if !sorted.iter().any(|r| r.name() == rule.name()) {
437                sorted.push(rule.as_ref());
438            }
439        }
440
441        if shared_graph {
442            RULE_ORDER.with_borrow_mut(|cache| {
443                *cache = Some(CachedRuleOrder {
444                    names: rules.iter().map(|rule| rule.name()).collect(),
445                    positions: sorted.iter().map(|rule| position[rule.name()]).collect(),
446                });
447            });
448        }
449        sorted
450    }
451
452    /// Apply fixes iteratively until no more fixes are needed or max iterations reached.
453    ///
454    /// This implements a Ruff-inspired fix loop that re-parses the document after each
455    /// fix, so a later rule sees what an earlier one produced (e.g., MD046 creating code
456    /// blocks that MD040 needs to fix). After a fix the loop carries on with the next
457    /// rule in dependency order, then wraps round to the rules before it, and stops once
458    /// one full pass over every rule applies nothing.
459    ///
460    /// The `file_path` parameter is used to determine per-file flavor overrides. If provided,
461    /// the flavor for creating LintContext will be resolved using `config.get_flavor_for_file()`.
462    pub fn apply_fixes_iterative(
463        &self,
464        rules: &[Box<dyn Rule>],
465        all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
466        content: &mut String,
467        config: &Config,
468        max_iterations: usize,
469        file_path: Option<&std::path::Path>,
470    ) -> Result<FixResult, String> {
471        self.apply_fixes_iterative_with_paths(
472            rules,
473            all_warnings,
474            content,
475            config,
476            max_iterations,
477            crate::DocumentPaths::same(file_path),
478        )
479    }
480
481    /// Apply fixes with separate paths for configuration matching and rule filesystem access.
482    ///
483    /// Native file adapters pass the same path for both. Virtual adapters can use a
484    /// logical `config_path` for per-file flavor and ignores while leaving
485    /// `source_file` unset so filesystem-dependent rules stay disabled.
486    pub fn apply_fixes_iterative_with_paths(
487        &self,
488        rules: &[Box<dyn Rule>],
489        _all_warnings: &[LintWarning],
490        content: &mut String,
491        config: &Config,
492        max_iterations: usize,
493        paths: crate::DocumentPaths<'_>,
494    ) -> Result<FixResult, String> {
495        // The fix loop works on analysis text; the mark is put back afterwards.
496        let had_bom = crate::utils::bom::has_bom(content);
497        if had_bom {
498            content.remove(0);
499        }
500        let result = self.apply_fixes_with_analysis(rules, content, config, max_iterations, paths, None, None);
501        if had_bom {
502            content.insert(0, crate::utils::bom::BOM);
503        }
504        Ok(result)
505    }
506
507    /// Fix Markdown extracted from a document, such as a fenced block or doc
508    /// comment. A leading U+FEFF belongs to this fragment's content and is never
509    /// interpreted as a file encoding marker. Input uses LF line endings.
510    pub fn apply_fixes_to_fragment(
511        &self,
512        rules: &[Box<dyn Rule>],
513        content: &mut String,
514        config: &Config,
515        max_iterations: usize,
516    ) -> FixResult {
517        self.apply_fixes_with_analysis(
518            rules,
519            content,
520            config,
521            max_iterations,
522            crate::DocumentPaths::same(None),
523            None,
524            None,
525        )
526    }
527
528    /// Reuse checks only while their original source is still current. `content`
529    /// is analysis text: its byte order mark, if any, was removed by the caller.
530    /// The analysis belongs to this invocation, never to a later document version.
531    #[allow(clippy::too_many_arguments)]
532    pub(crate) fn apply_fixes_with_analysis(
533        &self,
534        rules: &[Box<dyn Rule>],
535        content: &mut String,
536        config: &Config,
537        max_iterations: usize,
538        paths: crate::DocumentPaths<'_>,
539        initial: Option<(&LintContext<'_>, &FixCheckResults)>,
540        mut on_stable: Option<&mut StableAnalysis<'_>>,
541    ) -> FixResult {
542        // The caller may supply analysis only for this exact content version.
543        let initial = initial.filter(|(ctx, _)| ctx.content == content);
544        let conflict = initial.map_or_else(
545            || crate::merge_conflict::detect_for_analysis(content, rules, config, paths.config_path),
546            |(ctx, _)| crate::merge_conflict::detect_for_context(ctx, rules, config, paths.config_path),
547        );
548        if conflict.is_some() {
549            return FixResult {
550                rules_fixed: 0,
551                iterations: 0,
552                context_creations: 0,
553                fixed_rule_names: HashSet::new(),
554                converged: true,
555                conflicting_rules: Vec::new(),
556                conflict_cycle: Vec::new(),
557            };
558        }
559
560        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
561        let max_iterations = max_iterations.min(MAX_ITERATIONS);
562
563        // Get optimal rule order based on dependencies
564        let ordered_rules = self.get_optimal_order(rules);
565
566        let mut total_fixed = 0;
567        let mut total_ctx_creations = 0;
568        let mut iterations = 0;
569
570        // History tracks (content_hash, rule_that_produced_this_state).
571        // The initial entry has an empty rule name (no rule produced the initial content).
572        let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
573
574        // Track which rules actually applied fixes
575        let mut fixed_rule_names: HashSet<&str> = HashSet::new();
576
577        // Per-file-ignores are config-driven, per-file rule exclusions. The
578        // coordinator is the single engine every fix path funnels through, so
579        // resolving them here guarantees `fmt`/fix never rewrites a rule the file
580        // has excluded - no caller can reintroduce issue #707 by forgetting to
581        // pre-filter. Empty when no path is available (e.g. WASM without a path).
582        let ignored_for_file: HashSet<String> = paths
583            .config_path
584            .map(|p| config.get_ignored_rules_for_file(p))
585            .unwrap_or_default();
586
587        let proof_policies: Vec<_> = ordered_rules
588            .iter()
589            .map(|rule| crate::format_proof::NativeFormatPolicy::validated(*rule))
590            .collect();
591
592        // Where the next iteration resumes in `ordered_rules`. A scan wraps
593        // around on the same context until every rule has seen this content.
594        // Only a rewrite invalidates the context and its lazily parsed caches.
595        let mut resume_at = 0;
596
597        let mut parse_handoff = None;
598
599        #[cfg(not(target_arch = "wasm32"))]
600        let profile = FixLoopProfile::from_env();
601
602        // Ruff-style fix loop: keep applying fixes until content stabilizes
603        while iterations < max_iterations {
604            iterations += 1;
605
606            // Reuse the initial analysis until a rewrite requires a fresh context.
607            // Use per-file flavor if a config path is provided, otherwise the global flavor.
608            let flavor = paths
609                .config_path
610                .map_or_else(|| config.markdown_flavor(), |path| config.get_flavor_for_file(path));
611
612            let mut proof = if initial.is_none() && on_stable.is_none() {
613                if parse_handoff.is_some() {
614                    crate::format_proof::FormatProof::analyze_handoff(content, flavor, &mut parse_handoff)
615                } else {
616                    crate::format_proof::FormatProof::analyze(content, flavor)
617                }
618            } else {
619                crate::format_proof::FormatProof::default()
620            };
621
622            let proof_skips: Vec<_> = proof_policies
623                .iter()
624                .map(|policy| policy.is_some_and(|policy| policy.proven(&proof)))
625                .collect();
626
627            let has_pending = ordered_rules.iter().enumerate().any(|(index, rule)| {
628                config_allows_fix(config, rule.name())
629                    && !ignored_for_file.contains(rule.name())
630                    && rule.fix_capability() != FixCapability::Unfixable
631                    && !proof_skips[index]
632            });
633            if !has_pending
634                && proof.eligible()
635                && initial.is_none()
636                && on_stable.is_none()
637                && !crate::inline_config::has_inline_config_markers(content)
638            {
639                return FixResult {
640                    rules_fixed: total_fixed,
641                    iterations,
642                    context_creations: total_ctx_creations,
643                    fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
644                    converged: true,
645                    conflicting_rules: Vec::new(),
646                    conflict_cycle: Vec::new(),
647                };
648            }
649            #[cfg(not(target_arch = "wasm32"))]
650            let parse_start = profile.as_ref().map(|_| std::time::Instant::now());
651            let reused = initial.filter(|_| iterations == 1);
652            let mut fresh_ctx = None;
653            let ctx = if let Some((ctx, _)) = reused {
654                ctx
655            } else {
656                #[cfg(not(target_arch = "wasm32"))]
657                if parse_handoff.is_some()
658                    && let Some(profile) = profile.as_ref()
659                {
660                    profile.handoffs.set(profile.handoffs.get() + 1);
661                }
662                let ctx = fresh_ctx.insert(crate::time_section!("fix: build context", {
663                    LintContext::new_with_proof(
664                        content,
665                        flavor,
666                        paths.source_file.map(std::path::Path::to_path_buf),
667                        proof.take_parsed(),
668                        parse_handoff.take(),
669                    )
670                }));
671                #[cfg(not(target_arch = "wasm32"))]
672                if let (Some(profile), Some(start)) = (profile.as_ref(), parse_start) {
673                    profile.record_context(start.elapsed());
674                }
675                total_ctx_creations += 1;
676                ctx
677            };
678
679            // Inline `rumdl-configure-file` value overrides: when the document carries
680            // inline rule-config overrides, recreate the affected rules from the merged
681            // config so fixes honor them, matching the lint/diagnostics path. Without
682            // this, fix() would run with the base config and could rewrite content the
683            // configured rule considers valid.
684            let recreated_rules: HashMap<String, Box<dyn Rule>> = {
685                let inline_overrides = ctx.inline_config().get_all_rule_configs();
686                if inline_overrides.is_empty() {
687                    HashMap::new()
688                } else {
689                    let merged = config.merge_with_inline_config(ctx.inline_config());
690                    inline_overrides
691                        .keys()
692                        .filter_map(|name| {
693                            crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
694                        })
695                        .collect()
696                }
697            };
698
699            let mut current_checks = RawRuleChecks::new();
700            let mut next_content = None;
701            let mut any_fix_applied = false;
702            // The rule that applied a fix this iteration (used for cycle reporting).
703            let mut this_iter_rule: &str = "";
704
705            // A configured no-op needs no syntax proof. Keep every diagnostic
706            // consumer and document-level override on the normal check path.
707
708            let configuration_only =
709                initial.is_none() && on_stable.is_none() && ctx.inline_config().get_all_rule_configs().is_empty();
710
711            let cleanup_enabled =
712                self.cleanup_effects && initial.is_none() && on_stable.is_none() && cleanup_effects::eligible(ctx);
713
714            let mut cleanup = cleanup_effects::CleanupBatch::default();
715
716            // Continue in dependency order, wrapping to the earlier rules on
717            // this same context. If nothing changes, each rule is checked once
718            // and the document is stable; there is no reason to parse it again.
719            let scan = ordered_rules
720                .iter()
721                .enumerate()
722                .skip(resume_at)
723                .chain(ordered_rules.iter().enumerate().take(resume_at));
724            for (index, rule) in scan {
725                if !config_allows_fix(config, rule.name()) {
726                    continue;
727                }
728                // Skip rules excluded for this file via [per-file-ignores].
729                if ignored_for_file.contains(rule.name()) {
730                    continue;
731                }
732                // Use the inline-config-recreated instance when present so checks and
733                // fixes reflect inline `rumdl-configure-file` overrides; otherwise the
734                // base rule. Rule identity (name) is unchanged either way.
735                let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
736
737                // The lint pass reports warning-only rules. Rechecking them
738                // after every rewrite cannot produce a CLI fix and can repeat
739                // expensive work such as filesystem link validation. Read the
740                // effective instance so inline settings can still enable fixes.
741                if effective_rule.fix_capability() == FixCapability::Unfixable {
742                    continue;
743                }
744
745                if configuration_only
746                    && crate::format_proof::NativeFormatPolicy::validated(effective_rule)
747                        .is_some_and(crate::format_proof::NativeFormatPolicy::configuration_only)
748                {
749                    continue;
750                }
751                // Proofs and negative checks belong to the old source too.
752                // Cross a pending edit only for a built-in policy whose inputs
753                // are preserved by every accepted cleanup effect.
754
755                if !cleanup.is_empty() && !cleanup.can_check(index, effective_rule, ctx) {
756                    break;
757                }
758
759                if proof_skips[index] && !recreated_rules.contains_key(rule.name()) {
760                    continue;
761                }
762                if effective_rule.should_skip(ctx) {
763                    continue;
764                }
765
766                #[cfg(not(target_arch = "wasm32"))]
767                let _rule_timer = profile.as_ref().map(|profile| profile.time(rule.name()));
768
769                // A diagnostic edit belongs to this exact source revision. A
770                // rewrite discards every plan, including cached negative checks.
771                let fresh_plan;
772                let mut cleanup_edits = None;
773                let mut prepared_native_fix = None;
774                let mut checked_warnings = None;
775                let plan = match reused.and_then(|(_, checks)| checks.get(&rule_check_key(*rule))) {
776                    Some(plan) => plan,
777                    None => {
778                        let Ok(warnings) = crate::time_section!("fix: check rules", {
779                            effective_rule.check_with_prepared_fix(ctx).map(|(warnings, prepared)| {
780                                prepared_native_fix = prepared.map(Ok);
781                                warnings
782                            })
783                        }) else {
784                            if !cleanup.is_empty() {
785                                break;
786                            }
787                            continue;
788                        };
789                        fresh_plan = FixPlan::capture(effective_rule, ctx, &warnings);
790
791                        if cleanup_enabled {
792                            cleanup_edits = cleanup_effects::capture_with_native(
793                                effective_rule,
794                                ctx,
795                                &warnings,
796                                &mut prepared_native_fix,
797                            );
798                        }
799                        if on_stable.is_some() {
800                            current_checks.insert(rule_check_key(*rule), warnings);
801                        } else {
802                            checked_warnings = Some(warnings);
803                        }
804                        &fresh_plan
805                    }
806                };
807                if matches!(plan, FixPlan::Unchanged) {
808                    continue;
809                }
810
811                if cleanup_enabled {
812                    if let Some(edits) = cleanup_edits
813                        && cleanup.try_push(ctx.content, index, effective_rule.name(), edits, &history)
814                    {
815                        #[cfg(not(target_arch = "wasm32"))]
816                        if let Some(profile) = profile.as_ref() {
817                            profile.fix_attempted(rule.name());
818                        }
819                        if cleanup.terminal() || cleanup.len() > max_iterations - iterations {
820                            break;
821                        }
822                        continue;
823                    }
824                    if !cleanup.is_empty() {
825                        break;
826                    }
827                }
828
829                // Apply fix
830                #[cfg(not(target_arch = "wasm32"))]
831                if let Some(profile) = profile.as_ref() {
832                    profile.fix_attempted(rule.name());
833                }
834                let fixed = crate::time_section!("fix: apply rule fixes", {
835                    match plan {
836                        FixPlan::Edits(edits) => crate::utils::fix_utils::apply_edits(ctx.content, edits.iter())
837                            .map(Cow::into_owned)
838                            .map_err(crate::rule::LintError::FixFailed),
839                        FixPlan::Custom => prepared_native_fix.unwrap_or_else(|| {
840                            checked_warnings.as_deref().map_or_else(
841                                || effective_rule.fix(ctx),
842                                |warnings| effective_rule.fix_with_diagnostics(ctx, warnings),
843                            )
844                        }),
845                        FixPlan::Unchanged => unreachable!(),
846                    }
847                });
848                match fixed {
849                    Ok(fixed_content) => {
850                        if fixed_content != *content {
851                            parse_handoff = ctx.take_parse_handoff(&fixed_content);
852                            next_content = Some(fixed_content);
853                            total_fixed += 1;
854                            any_fix_applied = true;
855                            this_iter_rule = rule.name();
856                            fixed_rule_names.insert(rule.name());
857                            // The next rule sees the fixed content through a fresh
858                            // context; after the last rule the loop wraps round.
859                            resume_at = if index + 1 == ordered_rules.len() { 0 } else { index + 1 };
860                            break;
861                        }
862                    }
863                    Err(_) => {
864                        // Error applying fix, continue to next rule
865                        continue;
866                    }
867                }
868            }
869
870            if let Some(batch) = cleanup.finish() {
871                let count = batch.stages.len();
872                for &(hash, name, _) in batch.stages.iter().take(count - 1) {
873                    history.push((hash, name));
874                }
875                for &(_, name, _) in &batch.stages {
876                    fixed_rule_names.insert(name);
877                }
878                let &(_, name, index) = batch.stages.last().unwrap();
879                this_iter_rule = name;
880                resume_at = if index + 1 == ordered_rules.len() { 0 } else { index + 1 };
881                total_fixed += count;
882                iterations += count - 1;
883                any_fix_applied = true;
884
885                // A single-stage native guard may already have parsed this
886                // exact output. Combined edits usually differ, so the
887                // handoff's full byte comparison remains authoritative.
888                parse_handoff = ctx.take_parse_handoff(&batch.content);
889                next_content = Some(batch.content);
890            }
891
892            if !any_fix_applied && let Some(analyze) = &mut on_stable {
893                analyze(ctx, &mut current_checks);
894            }
895
896            if let Some(fixed) = next_content {
897                *content = fixed;
898            }
899            let current_hash = hash_content(content);
900
901            // Check whether this content state has been seen before.
902            if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
903                if cycle_start == history.len() - 1 {
904                    // Content matches the last recorded state: nothing changed this iteration.
905                    return FixResult {
906                        rules_fixed: total_fixed,
907                        iterations,
908                        context_creations: total_ctx_creations,
909                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
910                        converged: true,
911                        conflicting_rules: Vec::new(),
912                        conflict_cycle: Vec::new(),
913                    };
914                } else {
915                    // Content matches an older state: oscillation cycle detected.
916                    // Collect the rules that participate in the cycle.
917                    let conflict_cycle: Vec<String> = history[cycle_start + 1..]
918                        .iter()
919                        .map(|(_, r)| r.to_string())
920                        .chain(std::iter::once(this_iter_rule.to_string()))
921                        .filter(|r| !r.is_empty())
922                        .collect();
923                    let conflicting_rules: Vec<String> = history[cycle_start + 1..]
924                        .iter()
925                        .map(|(_, r)| *r)
926                        .chain(std::iter::once(this_iter_rule))
927                        .filter(|r| !r.is_empty())
928                        .collect::<HashSet<&str>>()
929                        .into_iter()
930                        .map(std::string::ToString::to_string)
931                        .collect();
932                    return FixResult {
933                        rules_fixed: total_fixed,
934                        iterations,
935                        context_creations: total_ctx_creations,
936                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
937                        converged: false,
938                        conflicting_rules,
939                        conflict_cycle,
940                    };
941                }
942            }
943
944            // New state - record it.
945            history.push((current_hash, this_iter_rule));
946
947            // If no fix was applied this iteration, content is stable.
948            if !any_fix_applied {
949                return FixResult {
950                    rules_fixed: total_fixed,
951                    iterations,
952                    context_creations: total_ctx_creations,
953                    fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
954                    converged: true,
955                    conflicting_rules: Vec::new(),
956                    conflict_cycle: Vec::new(),
957                };
958            }
959        }
960
961        // Hit max iterations without detecting a cycle.
962        FixResult {
963            rules_fixed: total_fixed,
964            iterations,
965            context_creations: total_ctx_creations,
966            fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
967            converged: false,
968            conflicting_rules: Vec::new(),
969            conflict_cycle: Vec::new(),
970        }
971    }
972}
973
974#[cfg(test)]
975mod tests {
976    #[test]
977    fn conflict_guard_discards_analysis_from_another_content_version() {
978        let config = super::Config::default();
979        let rules = crate::rules::all_rules(&config);
980        let old = super::LintContext::new("# Safe\n", config.markdown_flavor(), None);
981        let checks = super::FixCheckResults::new();
982        let original = "<<<<<<< HEAD\ntext   \n";
983        let mut content = original.to_string();
984        let result = super::FixCoordinator::new().apply_fixes_with_analysis(
985            &rules,
986            &mut content,
987            &config,
988            100,
989            crate::DocumentPaths::same(None),
990            Some((&old, &checks)),
991            None,
992        );
993        assert_eq!(content, original);
994        assert_eq!(result.iterations, 0);
995        assert_eq!(result.rules_fixed, 0);
996    }
997
998    use super::*;
999    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
1000    use std::sync::atomic::{AtomicUsize, Ordering};
1001
1002    #[test]
1003    fn forwarding_a_policy_does_not_certify_a_custom_rule() {
1004        #[derive(Clone)]
1005        struct Wrapper(crate::rules::MD013LineLength);
1006        impl Rule for Wrapper {
1007            fn name(&self) -> &'static str {
1008                "MD013"
1009            }
1010            fn description(&self) -> &'static str {
1011                "Custom rewrite"
1012            }
1013            fn as_any(&self) -> &dyn std::any::Any {
1014                self
1015            }
1016            fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1017                self.0.native_format_policy()
1018            }
1019            fn check(&self, ctx: &LintContext) -> LintResult {
1020                Ok((!ctx.content.contains("custom"))
1021                    .then(|| LintWarning {
1022                        rule_name: Some(self.name().into()),
1023                        message: self.description().into(),
1024                        line: 1,
1025                        column: 1,
1026                        end_line: 1,
1027                        end_column: 2,
1028                        severity: Severity::Warning,
1029                        fix: None,
1030                    })
1031                    .into_iter()
1032                    .collect())
1033            }
1034            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1035                Ok(if ctx.content.contains("custom") {
1036                    ctx.content.into()
1037                } else {
1038                    format!("custom {}", ctx.content)
1039                })
1040            }
1041        }
1042        let rule = Wrapper(crate::rules::MD013LineLength::default());
1043        assert!(rule.native_format_policy().is_some());
1044        assert!(crate::format_proof::NativeFormatPolicy::validated(&rule).is_none());
1045        let rules: Vec<Box<dyn Rule>> = vec![Box::new(rule)];
1046        let config = Config::default();
1047        let (text, result) = crate::document_run::DocumentRun::new("plain\n", &rules, &config)
1048            .fix_for_formatter(10)
1049            .unwrap();
1050        assert_eq!(text, "custom plain\n");
1051        assert!(result.converged);
1052    }
1053
1054    #[test]
1055    fn configured_noops_keep_flavor_diagnostics_and_inline_fixes() {
1056        #[derive(Clone)]
1057        struct CountNative {
1058            rule: Box<dyn Rule>,
1059            checks: std::sync::Arc<AtomicUsize>,
1060        }
1061        impl Rule for CountNative {
1062            fn name(&self) -> &'static str {
1063                self.rule.name()
1064            }
1065            fn description(&self) -> &'static str {
1066                self.rule.description()
1067            }
1068            fn check(&self, ctx: &LintContext) -> LintResult {
1069                self.checks.fetch_add(1, Ordering::Relaxed);
1070                self.rule.check(ctx)
1071            }
1072            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1073                self.rule.fix(ctx)
1074            }
1075            fn should_skip(&self, ctx: &LintContext) -> bool {
1076                self.rule.should_skip(ctx)
1077            }
1078            fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1079                self.rule
1080                    .native_format_policy()
1081                    .map(crate::format_proof::NativeFormatPolicy::for_test_owner::<Self>)
1082            }
1083            fn as_any(&self) -> &dyn std::any::Any {
1084                self
1085            }
1086        }
1087        for flavor in crate::format_proof::ALL_FLAVORS {
1088            let mut config = Config::default();
1089            config.global.flavor = flavor;
1090            let counters = [
1091                std::sync::Arc::new(AtomicUsize::new(0)),
1092                std::sync::Arc::new(AtomicUsize::new(0)),
1093            ];
1094            let rules: Vec<Box<dyn Rule>> = ["MD013", "MD036"]
1095                .into_iter()
1096                .zip(&counters)
1097                .map(|(name, checks)| {
1098                    Box::new(CountNative {
1099                        rule: crate::rules::create_rule_by_name(name, &config).unwrap(),
1100                        checks: checks.clone(),
1101                    }) as Box<dyn Rule>
1102                })
1103                .collect();
1104            let original = format!("*Heading*\n\n{}\n", "ordinary prose ".repeat(10));
1105            let mut content = original.clone();
1106            let coordinator = FixCoordinator::new();
1107            let result = coordinator.apply_fixes_with_analysis(
1108                &rules,
1109                &mut content,
1110                &config,
1111                10,
1112                crate::DocumentPaths::same(None),
1113                None,
1114                None,
1115            );
1116            assert!(result.converged);
1117            assert_eq!(content, original, "{flavor:?}");
1118            assert!(
1119                counters.iter().all(|count| count.load(Ordering::Relaxed) == 0),
1120                "{flavor:?}"
1121            );
1122
1123            let mut warning_count = 0;
1124            let mut stable = |_: &LintContext, checks: &mut RawRuleChecks| {
1125                warning_count = checks.values().map(Vec::len).sum::<usize>();
1126            };
1127            coordinator.apply_fixes_with_analysis(
1128                &rules,
1129                &mut content,
1130                &config,
1131                10,
1132                crate::DocumentPaths::same(None),
1133                None,
1134                Some(&mut stable),
1135            );
1136            assert!(warning_count >= 2, "{flavor:?}: diagnostic consumer lost warnings");
1137            assert!(
1138                counters.iter().all(|count| count.load(Ordering::Relaxed) == 1),
1139                "{flavor:?}"
1140            );
1141
1142            for (name, original) in [
1143                (
1144                    "MD036",
1145                    "<!-- rumdl-configure-file {\"MD036\":{\"fix\":true}} -->\n\n*Heading*\n",
1146                ),
1147                (
1148                    "MD013",
1149                    "<!-- rumdl-configure-file {\"MD013\":{\"reflow\":true,\"line_length\":12}} -->\n\nalpha beta gamma delta epsilon zeta\n",
1150                ),
1151            ] {
1152                let ctx = LintContext::new(original, flavor, None);
1153                let merged = config.merge_with_inline_config(ctx.inline_config());
1154                let native = crate::rules::create_rule_by_name(name, &merged).unwrap();
1155                let expected = native.fix(&ctx).unwrap();
1156                assert_ne!(
1157                    expected, original,
1158                    "{flavor:?} {name}: fixture must exercise a native edit"
1159                );
1160                let rules = vec![crate::rules::create_rule_by_name(name, &config).unwrap()];
1161                let mut content = original.to_owned();
1162                coordinator.apply_fixes_with_analysis(
1163                    &rules,
1164                    &mut content,
1165                    &config,
1166                    10,
1167                    crate::DocumentPaths::same(None),
1168                    None,
1169                    None,
1170                );
1171                assert_eq!(
1172                    content, expected,
1173                    "{flavor:?} {name}: inline override lost its native fix"
1174                );
1175            }
1176        }
1177    }
1178
1179    #[derive(Clone)]
1180    struct WarningOnlyProbe;
1181
1182    impl Rule for WarningOnlyProbe {
1183        fn name(&self) -> &'static str {
1184            "TEST_WARNING_ONLY"
1185        }
1186        fn description(&self) -> &'static str {
1187            "A diagnostic that cannot change content"
1188        }
1189        fn fix_capability(&self) -> FixCapability {
1190            FixCapability::Unfixable
1191        }
1192        fn check(&self, _ctx: &LintContext) -> LintResult {
1193            panic!("warning-only rules must not be rechecked in the fix loop")
1194        }
1195        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
1196            panic!("warning-only rules must not be fixed")
1197        }
1198        fn as_any(&self) -> &dyn std::any::Any {
1199            self
1200        }
1201    }
1202
1203    #[test]
1204    fn fix_loop_skips_warning_only_rules_without_losing_other_fixes() {
1205        let rules: Vec<Box<dyn Rule>> = vec![
1206            Box::new(WarningOnlyProbe),
1207            Box::new(ConditionalFixRule {
1208                name: "TEST_FIX",
1209                check_fn: |s| s.contains("bad"),
1210                fix_fn: |s| s.replace("bad", "good"),
1211            }),
1212        ];
1213        let mut content = "bad\n".to_string();
1214        let result = FixCoordinator::new()
1215            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1216            .unwrap();
1217        assert!(result.converged);
1218        assert_eq!(content, "good\n");
1219    }
1220
1221    /// Mock rule that checks content and applies fixes based on a condition
1222    #[derive(Clone)]
1223    struct ConditionalFixRule {
1224        name: &'static str,
1225        /// Function to check if content has issues
1226        check_fn: fn(&str) -> bool,
1227        /// Function to fix content
1228        fix_fn: fn(&str) -> String,
1229    }
1230
1231    impl Rule for ConditionalFixRule {
1232        fn name(&self) -> &'static str {
1233            self.name
1234        }
1235
1236        fn check(&self, ctx: &LintContext) -> LintResult {
1237            if (self.check_fn)(ctx.content) {
1238                Ok(vec![LintWarning {
1239                    line: 1,
1240                    column: 1,
1241                    end_line: 1,
1242                    end_column: 1,
1243                    message: format!("{} issue found", self.name),
1244                    rule_name: Some(self.name.to_string()),
1245                    severity: Severity::Error,
1246                    fix: Some(Fix::new(0..0, String::new())),
1247                }])
1248            } else {
1249                Ok(vec![])
1250            }
1251        }
1252
1253        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1254            Ok((self.fix_fn)(ctx.content))
1255        }
1256
1257        fn description(&self) -> &'static str {
1258            "Conditional fix rule for testing"
1259        }
1260
1261        fn category(&self) -> RuleCategory {
1262            RuleCategory::Whitespace
1263        }
1264
1265        fn as_any(&self) -> &dyn std::any::Any {
1266            self
1267        }
1268    }
1269
1270    // Simple mock rule for basic tests
1271    #[derive(Clone)]
1272    struct MockRule {
1273        name: &'static str,
1274        warnings: Vec<LintWarning>,
1275        fix_content: String,
1276    }
1277
1278    impl Rule for MockRule {
1279        fn name(&self) -> &'static str {
1280            self.name
1281        }
1282
1283        fn check(&self, _ctx: &LintContext) -> LintResult {
1284            Ok(self.warnings.clone())
1285        }
1286
1287        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
1288            Ok(self.fix_content.clone())
1289        }
1290
1291        fn description(&self) -> &'static str {
1292            "Mock rule for testing"
1293        }
1294
1295        fn category(&self) -> RuleCategory {
1296            RuleCategory::Whitespace
1297        }
1298
1299        fn as_any(&self) -> &dyn std::any::Any {
1300            self
1301        }
1302    }
1303
1304    #[test]
1305    fn test_dependency_ordering() {
1306        let coordinator = FixCoordinator::new();
1307
1308        let rules: Vec<Box<dyn Rule>> = vec![
1309            Box::new(MockRule {
1310                name: "MD009",
1311                warnings: vec![],
1312                fix_content: "".to_string(),
1313            }),
1314            Box::new(MockRule {
1315                name: "MD013",
1316                warnings: vec![],
1317                fix_content: "".to_string(),
1318            }),
1319            Box::new(MockRule {
1320                name: "MD010",
1321                warnings: vec![],
1322                fix_content: "".to_string(),
1323            }),
1324            Box::new(MockRule {
1325                name: "MD007",
1326                warnings: vec![],
1327                fix_content: "".to_string(),
1328            }),
1329        ];
1330
1331        let ordered = coordinator.get_optimal_order(&rules);
1332        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
1333
1334        // MD010 should come before MD007 (dependency)
1335        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
1336        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
1337        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
1338
1339        // MD013 should come before MD009 (dependency)
1340        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
1341        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
1342        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
1343    }
1344
1345    #[test]
1346    fn test_single_rule_fix() {
1347        let coordinator = FixCoordinator::new();
1348
1349        // Rule that removes "BAD" from content
1350        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1351            name: "RemoveBad",
1352            check_fn: |content| content.contains("BAD"),
1353            fix_fn: |content| content.replace("BAD", "GOOD"),
1354        })];
1355
1356        let mut content = "This is BAD content".to_string();
1357        let config = Config::default();
1358
1359        let result = coordinator
1360            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1361            .unwrap();
1362
1363        assert_eq!(content, "This is GOOD content");
1364        assert_eq!(result.rules_fixed, 1);
1365        assert!(result.converged);
1366    }
1367
1368    #[test]
1369    fn test_cascading_fixes() {
1370        // Simulates MD046 -> MD040 cascade:
1371        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
1372        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
1373        let coordinator = FixCoordinator::new();
1374
1375        let rules: Vec<Box<dyn Rule>> = vec![
1376            Box::new(ConditionalFixRule {
1377                name: "Rule1_IndentToFence",
1378                check_fn: |content| content.contains("INDENT"),
1379                fix_fn: |content| content.replace("INDENT", "FENCE"),
1380            }),
1381            Box::new(ConditionalFixRule {
1382                name: "Rule2_FenceToLang",
1383                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
1384                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
1385            }),
1386        ];
1387
1388        let mut content = "Code: INDENT".to_string();
1389        let config = Config::default();
1390
1391        let result = coordinator
1392            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
1393            .unwrap();
1394
1395        // Should reach final state in one run (internally multiple iterations)
1396        assert_eq!(content, "Code: FENCE_LANG");
1397        assert_eq!(result.rules_fixed, 2);
1398        assert!(result.converged);
1399        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
1400    }
1401
1402    #[test]
1403    fn a_rule_that_never_fires_is_checked_once_per_pass_not_once_per_fix() {
1404        static QUIET_CHECKS: AtomicUsize = AtomicUsize::new(0);
1405        let coordinator = FixCoordinator::new();
1406
1407        // One rule with nothing to fix, then five that each fix once.
1408        let mut rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1409            name: "Quiet",
1410            check_fn: |_| {
1411                QUIET_CHECKS.fetch_add(1, Ordering::SeqCst);
1412                false
1413            },
1414            fix_fn: str::to_string,
1415        })];
1416        let fixers = [
1417            ConditionalFixRule {
1418                name: "FixA",
1419                check_fn: |c| c.contains('a'),
1420                fix_fn: |c| c.replace('a', "A"),
1421            },
1422            ConditionalFixRule {
1423                name: "FixB",
1424                check_fn: |c| c.contains('b'),
1425                fix_fn: |c| c.replace('b', "B"),
1426            },
1427            ConditionalFixRule {
1428                name: "FixC",
1429                check_fn: |c| c.contains('c'),
1430                fix_fn: |c| c.replace('c', "C"),
1431            },
1432            ConditionalFixRule {
1433                name: "FixD",
1434                check_fn: |c| c.contains('d'),
1435                fix_fn: |c| c.replace('d', "D"),
1436            },
1437            ConditionalFixRule {
1438                name: "FixE",
1439                check_fn: |c| c.contains('e'),
1440                fix_fn: |c| c.replace('e', "E"),
1441            },
1442        ];
1443        rules.extend(fixers.map(|rule| Box::new(rule) as Box<dyn Rule>));
1444
1445        let mut content = "a b c d e".to_string();
1446        let result = coordinator
1447            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 100, None)
1448            .unwrap();
1449
1450        assert_eq!(content, "A B C D E");
1451        assert!(result.converged);
1452        assert_eq!(result.rules_fixed, 5);
1453        // The pass that applied the five fixes, then the clean pass that proves
1454        // nothing is left. Restarting after each fix would check it six times.
1455        assert_eq!(QUIET_CHECKS.load(Ordering::SeqCst), 2);
1456    }
1457
1458    #[test]
1459    fn a_fix_that_creates_work_for_an_earlier_rule_is_picked_up_on_the_wrap() {
1460        let coordinator = FixCoordinator::new();
1461
1462        // The second rule's fix produces what the first rule fixes, and the
1463        // third has nothing to do. Wrapping around must find the earlier rule
1464        // without rebuilding the unchanged context or using up the fix budget.
1465        let rules: Vec<Box<dyn Rule>> = vec![
1466            Box::new(ConditionalFixRule {
1467                name: "Early_YToZ",
1468                check_fn: |content| content.contains('y'),
1469                fix_fn: |content| content.replace('y', "z"),
1470            }),
1471            Box::new(ConditionalFixRule {
1472                name: "Late_XToY",
1473                check_fn: |content| content.contains('x'),
1474                fix_fn: |content| content.replace('x', "y"),
1475            }),
1476            Box::new(ConditionalFixRule {
1477                name: "Tail_Quiet",
1478                check_fn: |_| false,
1479                fix_fn: str::to_string,
1480            }),
1481        ];
1482
1483        let mut content = "x".to_string();
1484        let result = coordinator
1485            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 3, None)
1486            .unwrap();
1487
1488        assert_eq!(content, "z");
1489        assert!(result.converged);
1490        assert_eq!(result.rules_fixed, 2);
1491        assert_eq!(result.context_creations, 3);
1492    }
1493
1494    #[test]
1495    fn a_clean_wrap_reuses_the_context_and_does_not_repeat_the_tail() {
1496        static TAIL_CHECKS: AtomicUsize = AtomicUsize::new(0);
1497        TAIL_CHECKS.store(0, Ordering::SeqCst);
1498        let rules: Vec<Box<dyn Rule>> = vec![
1499            Box::new(ConditionalFixRule {
1500                name: "Early_Quiet",
1501                check_fn: |_| false,
1502                fix_fn: str::to_string,
1503            }),
1504            Box::new(ConditionalFixRule {
1505                name: "Middle_Fix",
1506                check_fn: |content| content.contains('x'),
1507                fix_fn: |content| content.replace('x', "y"),
1508            }),
1509            Box::new(ConditionalFixRule {
1510                name: "Tail_Quiet",
1511                check_fn: |_| {
1512                    TAIL_CHECKS.fetch_add(1, Ordering::SeqCst);
1513                    false
1514                },
1515                fix_fn: str::to_string,
1516            }),
1517        ];
1518        let mut content = "x".to_string();
1519        let result = FixCoordinator::new()
1520            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 2, None)
1521            .unwrap();
1522
1523        assert_eq!(content, "y");
1524        assert!(result.converged);
1525        assert_eq!(result.rules_fixed, 1);
1526        assert_eq!(result.context_creations, 2);
1527        assert_eq!(TAIL_CHECKS.load(Ordering::SeqCst), 1);
1528    }
1529
1530    #[test]
1531    fn test_indirect_cascade() {
1532        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
1533        // Rule1: adds "BLANK" (like MD022 adding blank line)
1534        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
1535        // Rule3: converts "FENCE" to "FENCE_LANG"
1536        let coordinator = FixCoordinator::new();
1537
1538        let rules: Vec<Box<dyn Rule>> = vec![
1539            Box::new(ConditionalFixRule {
1540                name: "Rule1_AddBlank",
1541                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
1542                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
1543            }),
1544            Box::new(ConditionalFixRule {
1545                name: "Rule2_CodeToFence",
1546                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
1547                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
1548                fix_fn: |content| content.replace("CODE", "FENCE"),
1549            }),
1550            Box::new(ConditionalFixRule {
1551                name: "Rule3_AddLang",
1552                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
1553                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
1554            }),
1555        ];
1556
1557        let mut content = "HEADING CODE".to_string();
1558        let config = Config::default();
1559
1560        let result = coordinator
1561            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
1562            .unwrap();
1563
1564        // Key assertion: all fixes applied in single run
1565        assert_eq!(content, "HEADING BLANK FENCE_LANG");
1566        assert_eq!(result.rules_fixed, 3);
1567        assert!(result.converged);
1568    }
1569
1570    #[test]
1571    fn test_unfixable_rules_skipped() {
1572        let coordinator = FixCoordinator::new();
1573
1574        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1575            name: "MD001",
1576            check_fn: |content| content.contains("BAD"),
1577            fix_fn: |content| content.replace("BAD", "GOOD"),
1578        })];
1579
1580        let mut content = "BAD content".to_string();
1581        let mut config = Config::default();
1582        config.global.unfixable = vec!["MD001".to_string()];
1583
1584        let result = coordinator
1585            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1586            .unwrap();
1587
1588        assert_eq!(content, "BAD content"); // Should not be changed
1589        assert_eq!(result.rules_fixed, 0);
1590        assert!(result.converged);
1591    }
1592
1593    #[test]
1594    fn test_fixable_allowlist() {
1595        let coordinator = FixCoordinator::new();
1596
1597        let rules: Vec<Box<dyn Rule>> = vec![
1598            Box::new(ConditionalFixRule {
1599                name: "MD001",
1600                check_fn: |content| content.contains('A'),
1601                fix_fn: |content| content.replace('A', "X"),
1602            }),
1603            Box::new(ConditionalFixRule {
1604                name: "MD002",
1605                check_fn: |content| content.contains('B'),
1606                fix_fn: |content| content.replace('B', "Y"),
1607            }),
1608        ];
1609
1610        let mut content = "AB".to_string();
1611        let mut config = Config::default();
1612        config.global.fixable = vec!["MD001".to_string()];
1613
1614        let result = coordinator
1615            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1616            .unwrap();
1617
1618        assert_eq!(content, "XB"); // Only A->X, B unchanged
1619        assert_eq!(result.rules_fixed, 1);
1620    }
1621
1622    /// Aliases in `unfixable` (e.g. `"heading-increment"`) must reach
1623    /// `apply_fixes_iterative` already canonicalised — the runtime invariant
1624    /// enforced by `Config::canonicalize_rule_lists` at every mutation
1625    /// boundary (`From<SourcedConfig> for Config`, LSP `apply_lsp_settings_*`,
1626    /// WASM `to_config_with_warnings`). The fix coordinator therefore matches
1627    /// against `Rule::name()` with plain string equality.
1628    #[test]
1629    fn test_unfixable_rules_resolved_from_alias() {
1630        let coordinator = FixCoordinator::new();
1631
1632        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1633            name: "MD001",
1634            check_fn: |content| content.contains("BAD"),
1635            fix_fn: |content| content.replace("BAD", "GOOD"),
1636        })];
1637
1638        let mut content = "BAD content".to_string();
1639        let mut config = Config::default();
1640        // Caller writes the alias…
1641        config.global.unfixable = vec!["heading-increment".to_string()];
1642        // …and the boundary canonicalises it to "MD001" before lint/fix sees it.
1643        config.canonicalize_rule_lists();
1644
1645        let result = coordinator
1646            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1647            .unwrap();
1648
1649        assert_eq!(content, "BAD content");
1650        assert_eq!(result.rules_fixed, 0);
1651        assert!(result.converged);
1652    }
1653
1654    /// Counterpart to `test_unfixable_rules_resolved_from_alias` for the
1655    /// fixable allowlist. Same invariant: callers may write aliases, but the
1656    /// boundary canonicalises before the fix coordinator sees the config.
1657    #[test]
1658    fn test_fixable_allowlist_resolved_from_alias() {
1659        let coordinator = FixCoordinator::new();
1660
1661        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1662            name: "MD001",
1663            check_fn: |content| content.contains("BAD"),
1664            fix_fn: |content| content.replace("BAD", "GOOD"),
1665        })];
1666
1667        let mut content = "BAD content".to_string();
1668        let mut config = Config::default();
1669        config.global.fixable = vec!["heading-increment".to_string()];
1670        config.canonicalize_rule_lists();
1671
1672        let result = coordinator
1673            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1674            .unwrap();
1675
1676        assert_eq!(content, "GOOD content");
1677        assert_eq!(result.rules_fixed, 1);
1678    }
1679
1680    #[test]
1681    fn test_max_iterations_limit() {
1682        let coordinator = FixCoordinator::new();
1683
1684        // Rule that always changes content (pathological case)
1685        static COUNTER: AtomicUsize = AtomicUsize::new(0);
1686
1687        #[derive(Clone)]
1688        struct AlwaysChangeRule;
1689        impl Rule for AlwaysChangeRule {
1690            fn name(&self) -> &'static str {
1691                "AlwaysChange"
1692            }
1693            fn check(&self, _: &LintContext) -> LintResult {
1694                Ok(vec![LintWarning {
1695                    line: 1,
1696                    column: 1,
1697                    end_line: 1,
1698                    end_column: 1,
1699                    message: "Always".to_string(),
1700                    rule_name: Some("AlwaysChange".to_string()),
1701                    severity: Severity::Error,
1702                    fix: Some(Fix::new(0..0, String::new())),
1703                }])
1704            }
1705            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1706                COUNTER.fetch_add(1, Ordering::SeqCst);
1707                Ok(format!("{}x", ctx.content))
1708            }
1709            fn description(&self) -> &'static str {
1710                "Always changes"
1711            }
1712            fn category(&self) -> RuleCategory {
1713                RuleCategory::Whitespace
1714            }
1715            fn as_any(&self) -> &dyn std::any::Any {
1716                self
1717            }
1718        }
1719
1720        COUNTER.store(0, Ordering::SeqCst);
1721        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
1722
1723        let mut content = "test".to_string();
1724        let config = Config::default();
1725
1726        let result = coordinator
1727            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1728            .unwrap();
1729
1730        // Should stop at max iterations
1731        assert_eq!(result.iterations, 5);
1732        assert!(!result.converged);
1733        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
1734    }
1735
1736    #[test]
1737    fn test_empty_rules() {
1738        let coordinator = FixCoordinator::new();
1739        let rules: Vec<Box<dyn Rule>> = vec![];
1740
1741        let mut content = "unchanged".to_string();
1742        let config = Config::default();
1743
1744        let result = coordinator
1745            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1746            .unwrap();
1747
1748        assert_eq!(result.rules_fixed, 0);
1749        assert_eq!(result.iterations, 1);
1750        assert!(result.converged);
1751        assert_eq!(content, "unchanged");
1752    }
1753
1754    #[test]
1755    fn test_no_warnings_no_changes() {
1756        let coordinator = FixCoordinator::new();
1757
1758        // Rule that finds no issues
1759        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
1760            name: "NoIssues",
1761            check_fn: |_| false, // Never finds issues
1762            fix_fn: |content| content.to_string(),
1763        })];
1764
1765        let mut content = "clean content".to_string();
1766        let config = Config::default();
1767
1768        let result = coordinator
1769            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
1770            .unwrap();
1771
1772        assert_eq!(content, "clean content");
1773        assert_eq!(result.rules_fixed, 0);
1774        assert!(result.converged);
1775    }
1776
1777    #[test]
1778    fn test_oscillation_detection() {
1779        // Two rules that fight each other: Rule A changes "foo" → "bar", Rule B changes "bar" → "foo".
1780        // The fix loop should detect this as an oscillation cycle and stop early with
1781        // conflicting_rules populated rather than running all 100 iterations.
1782        let coordinator = FixCoordinator::new();
1783
1784        let rules: Vec<Box<dyn Rule>> = vec![
1785            Box::new(ConditionalFixRule {
1786                name: "RuleA",
1787                check_fn: |content| content.contains("foo"),
1788                fix_fn: |content| content.replace("foo", "bar"),
1789            }),
1790            Box::new(ConditionalFixRule {
1791                name: "RuleB",
1792                check_fn: |content| content.contains("bar"),
1793                fix_fn: |content| content.replace("bar", "foo"),
1794            }),
1795        ];
1796
1797        let mut content = "foo".to_string();
1798        let config = Config::default();
1799
1800        let result = coordinator
1801            .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
1802            .unwrap();
1803
1804        // Should detect the cycle quickly, not burn through all 100 iterations.
1805        assert!(!result.converged, "Should not converge in an oscillating pair");
1806        assert!(
1807            result.iterations < 10,
1808            "Cycle detection should stop well before max_iterations (got {})",
1809            result.iterations
1810        );
1811
1812        // Both conflicting rules should be identified.
1813        let mut conflicting = result.conflicting_rules.clone();
1814        conflicting.sort();
1815        assert_eq!(
1816            conflicting,
1817            vec!["RuleA".to_string(), "RuleB".to_string()],
1818            "Both oscillating rules must be reported"
1819        );
1820        assert_eq!(
1821            result.conflict_cycle,
1822            vec!["RuleA".to_string(), "RuleB".to_string()],
1823            "Cycle should preserve the observed application order"
1824        );
1825    }
1826
1827    #[test]
1828    fn test_cyclic_dependencies_handled() {
1829        let mut coordinator = FixCoordinator::new();
1830
1831        // Create a cycle: A -> B -> C -> A
1832        coordinator.dependencies.to_mut().insert("RuleA", vec!["RuleB"]);
1833        coordinator.dependencies.to_mut().insert("RuleB", vec!["RuleC"]);
1834        coordinator.dependencies.to_mut().insert("RuleC", vec!["RuleA"]);
1835
1836        let rules: Vec<Box<dyn Rule>> = vec![
1837            Box::new(MockRule {
1838                name: "RuleA",
1839                warnings: vec![],
1840                fix_content: "".to_string(),
1841            }),
1842            Box::new(MockRule {
1843                name: "RuleB",
1844                warnings: vec![],
1845                fix_content: "".to_string(),
1846            }),
1847            Box::new(MockRule {
1848                name: "RuleC",
1849                warnings: vec![],
1850                fix_content: "".to_string(),
1851            }),
1852        ];
1853
1854        // Should not panic or infinite loop
1855        let ordered = coordinator.get_optimal_order(&rules);
1856
1857        // Should return all rules despite cycle
1858        assert_eq!(ordered.len(), 3);
1859    }
1860
1861    #[test]
1862    fn test_fix_is_idempotent() {
1863        // This is the key test for issue #271
1864        let coordinator = FixCoordinator::new();
1865
1866        let rules: Vec<Box<dyn Rule>> = vec![
1867            Box::new(ConditionalFixRule {
1868                name: "Rule1",
1869                check_fn: |content| content.contains('A'),
1870                fix_fn: |content| content.replace('A', "B"),
1871            }),
1872            Box::new(ConditionalFixRule {
1873                name: "Rule2",
1874                check_fn: |content| content.contains('B') && !content.contains('C'),
1875                fix_fn: |content| content.replace('B', "BC"),
1876            }),
1877        ];
1878
1879        let config = Config::default();
1880
1881        // First run
1882        let mut content1 = "A".to_string();
1883        let result1 = coordinator
1884            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
1885            .unwrap();
1886
1887        // Second run on same final content
1888        let mut content2 = content1.clone();
1889        let result2 = coordinator
1890            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
1891            .unwrap();
1892
1893        // Should be identical (idempotent)
1894        assert_eq!(content1, content2);
1895        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
1896        assert!(result1.converged);
1897        assert!(result2.converged);
1898    }
1899
1900    #[test]
1901    fn test_apply_fixes_collapses_double_space_without_inline_override() {
1902        // Control: MD064 actually rewrites this content, so the override test below
1903        // is not vacuous - without an inline override the double space is collapsed.
1904        let mut content = String::from("`<svg>`.  Fortunately\n");
1905        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1906        FixCoordinator::new()
1907            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1908            .unwrap();
1909        assert_eq!(
1910            content, "`<svg>`. Fortunately\n",
1911            "MD064 collapses the sentence double space when not overridden"
1912        );
1913    }
1914
1915    #[test]
1916    fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
1917        // The coordinator is the single engine every fix path funnels through. Handed
1918        // the UNFILTERED rule set plus a file path, it must still skip any rule the
1919        // path excludes via [per-file-ignores] - so no caller can reintroduce #707 by
1920        // forgetting to pre-filter.
1921        let coordinator = FixCoordinator::new();
1922        let rules: Vec<Box<dyn Rule>> = vec![
1923            Box::new(ConditionalFixRule {
1924                name: "MD004",
1925                check_fn: |c| c.contains('*'),
1926                fix_fn: |c| c.replace('*', "-"),
1927            }),
1928            Box::new(ConditionalFixRule {
1929                name: "MD032",
1930                check_fn: |c| c.contains("PARENT"),
1931                fix_fn: |c| c.replace("PARENT", "parent"),
1932            }),
1933        ];
1934
1935        let mut config = Config::default();
1936        config
1937            .per_file_ignores
1938            .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1939        config.canonicalize_rule_lists();
1940
1941        // Path matches the ignore glob: MD004 must be skipped, MD032 must still apply.
1942        let mut content = "* PARENT".to_string();
1943        let result = coordinator
1944            .apply_fixes_iterative(
1945                &rules,
1946                &[],
1947                &mut content,
1948                &config,
1949                10,
1950                Some(std::path::Path::new("slides/deck.md")),
1951            )
1952            .unwrap();
1953        assert_eq!(
1954            content, "* parent",
1955            "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1956        );
1957        assert!(result.fixed_rule_names.contains("MD032"));
1958        assert!(!result.fixed_rule_names.contains("MD004"));
1959
1960        // Control: a path NOT matching the glob applies both rules, proving the skip
1961        // above is driven by per-file-ignores and not something else.
1962        let mut other = "* PARENT".to_string();
1963        coordinator
1964            .apply_fixes_iterative(
1965                &rules,
1966                &[],
1967                &mut other,
1968                &config,
1969                10,
1970                Some(std::path::Path::new("docs/other.md")),
1971            )
1972            .unwrap();
1973        assert_eq!(
1974            other, "- parent",
1975            "both rules apply when the path is not per-file-ignored"
1976        );
1977    }
1978
1979    #[test]
1980    fn test_apply_fixes_honors_inline_configure_file_overrides() {
1981        // A document that relaxes a rule via an inline `rumdl-configure-file` override
1982        // must survive the fix coordinator unchanged: fixes have to honor inline value
1983        // overrides the same way lint/diagnostics do, otherwise "fix" rewrites content
1984        // the configured rule considers valid.
1985        let mut content = String::from(
1986            "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`.  Fortunately\n",
1987        );
1988        let original = content.clone();
1989        let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1990        FixCoordinator::new()
1991            .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1992            .unwrap();
1993        assert_eq!(
1994            content, original,
1995            "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1996        );
1997    }
1998
1999    #[test]
2000    fn layout_rules_run_after_every_content_rewrite() {
2001        // Reflow decides wrap points from the inline content it sees, and its default
2002        // mode never revisits a line that is under the limit. Every rule that is not
2003        // downstream of MD013 must therefore be ordered before it, over the FULL rule
2004        // set, so a new inline rewrite (like MD054 in issue #819) is covered without
2005        // anyone remembering to list it.
2006        let rules = crate::rules::all_rules(&Config::default());
2007        let ordered = FixCoordinator::new().get_optimal_order(&rules);
2008        let names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
2009        assert_eq!(names.len(), rules.len(), "ordering must keep every rule exactly once");
2010
2011        let md013 = names.iter().position(|&n| n == "MD013").unwrap();
2012        let after: Vec<&str> = names[md013 + 1..].to_vec();
2013        let mut expected_after = vec!["MD009", "MD012"];
2014        expected_after.sort_unstable();
2015        let mut actual_after = after.clone();
2016        actual_after.sort_unstable();
2017        assert_eq!(
2018            actual_after, expected_after,
2019            "only MD013's own dependents may follow it; order was {names:?}"
2020        );
2021    }
2022
2023    #[test]
2024    fn link_style_rewrite_runs_before_reflow_regardless_of_input_order() {
2025        // Regression for issue #819: with MD054 after MD013, reflow wrapped on the
2026        // `[text](url)` width and the shortcut rewrite then left every line short.
2027        let coordinator = FixCoordinator::new();
2028        for input in [vec!["MD013", "MD054"], vec!["MD054", "MD013"]] {
2029            let rules: Vec<Box<dyn Rule>> = input
2030                .iter()
2031                .map(|name| crate::rules::create_rule_by_name(name, &Config::default()).unwrap())
2032                .collect();
2033            let names: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
2034            assert_eq!(names, vec!["MD054", "MD013"], "input order {input:?}");
2035        }
2036    }
2037
2038    #[test]
2039    fn optimal_order_is_the_same_on_every_call() {
2040        // The prerequisite sets are hashed, and every HashMap gets its own seed, so
2041        // an ordering that iterated them directly would differ from one call (and
2042        // one process) to the next. That showed up as `rumdl fmt` producing two
2043        // different outputs for the same file. Rules the table leaves unordered
2044        // keep their input order instead.
2045        let rules = crate::rules::all_rules(&Config::default());
2046        let coordinator = FixCoordinator::new();
2047        let first: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
2048        for _ in 0..20 {
2049            let again: Vec<&str> = coordinator.get_optimal_order(&rules).iter().map(|r| r.name()).collect();
2050            assert_eq!(again, first);
2051        }
2052    }
2053
2054    #[test]
2055    fn cached_order_respects_the_selection_and_current_rule_instances() {
2056        let config = Config::default();
2057        let original = crate::rules::all_rules(&config);
2058        let coordinator = FixCoordinator::new();
2059        coordinator.get_optimal_order(&original);
2060
2061        // The owned graph is the uncached reference, including when a caller
2062        // alternates selected rules or their input order between documents.
2063        let mut uncached = FixCoordinator::new();
2064        uncached.dependencies.to_mut();
2065        for selection in [
2066            original.iter().map(|rule| rule.name()).collect::<Vec<_>>(),
2067            vec!["MD013", "MD054", "MD009"],
2068            vec!["MD013", "MD054", "MD009"],
2069            vec!["MD009", "MD054", "MD013"],
2070            vec![],
2071            vec!["MD013"],
2072        ] {
2073            let current: Vec<Box<dyn Rule>> = selection
2074                .iter()
2075                .map(|name| crate::rules::create_rule_by_name(name, &config).unwrap())
2076                .collect();
2077            let ordered = coordinator.get_optimal_order(&current);
2078            let expected = uncached.get_optimal_order(&current);
2079            assert_eq!(
2080                ordered.iter().map(|rule| rule.name()).collect::<Vec<_>>(),
2081                expected.iter().map(|rule| rule.name()).collect::<Vec<_>>()
2082            );
2083            for rule in ordered {
2084                assert!(current.iter().any(|current| std::ptr::eq(rule, current.as_ref())));
2085            }
2086        }
2087    }
2088}