rumdl_lib/
fix_coordinator.rs

1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{LintWarning, Rule};
4use std::collections::{HashMap, HashSet};
5
6/// Coordinates rule fixing to minimize the number of passes needed
7pub struct FixCoordinator {
8    /// Rules that should run before others (rule -> rules that depend on it)
9    dependencies: HashMap<&'static str, Vec<&'static str>>,
10}
11
12impl Default for FixCoordinator {
13    fn default() -> Self {
14        Self::new()
15    }
16}
17
18impl FixCoordinator {
19    pub fn new() -> Self {
20        let mut dependencies = HashMap::new();
21
22        // CRITICAL DEPENDENCIES:
23        // These dependencies prevent cascading issues that require multiple passes
24
25        // MD010 (tabs->spaces) MUST run before:
26        // - MD007 (list indentation) - because tabs affect indent calculation
27        // - MD005 (list indent consistency) - same reason
28        dependencies.insert("MD010", vec!["MD007", "MD005"]);
29
30        // MD013 (line length) MUST run before:
31        // - MD009 (trailing spaces) - line wrapping might add trailing spaces that need cleanup
32        // - MD012 (multiple blanks) - reflowing can affect blank lines
33        // Note: MD013 now trims trailing whitespace during reflow to prevent mid-line spaces
34        dependencies.insert("MD013", vec!["MD009", "MD012"]);
35
36        // MD004 (list style) should run before:
37        // - MD007 (list indentation) - changing markers affects indentation
38        dependencies.insert("MD004", vec!["MD007"]);
39
40        // MD022/MD023 (heading spacing) should run before:
41        // - MD012 (multiple blanks) - heading fixes can affect blank lines
42        dependencies.insert("MD022", vec!["MD012"]);
43        dependencies.insert("MD023", vec!["MD012"]);
44
45        Self { dependencies }
46    }
47
48    /// Get the optimal order for running rules based on dependencies
49    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
50        // Build a map of rule names to rules for quick lookup
51        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
52
53        // Build reverse dependencies (rule -> rules it depends on)
54        let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
55        for (prereq, dependents) in &self.dependencies {
56            for dependent in dependents {
57                reverse_deps.entry(dependent).or_default().insert(prereq);
58            }
59        }
60
61        // Perform topological sort
62        let mut sorted = Vec::new();
63        let mut visited = HashSet::new();
64        let mut visiting = HashSet::new();
65
66        fn visit<'a>(
67            rule_name: &str,
68            rule_map: &HashMap<&str, &'a dyn Rule>,
69            reverse_deps: &HashMap<&str, HashSet<&str>>,
70            visited: &mut HashSet<String>,
71            visiting: &mut HashSet<String>,
72            sorted: &mut Vec<&'a dyn Rule>,
73        ) {
74            if visited.contains(rule_name) {
75                return;
76            }
77
78            if visiting.contains(rule_name) {
79                // Cycle detected, but we'll just skip it
80                return;
81            }
82
83            visiting.insert(rule_name.to_string());
84
85            // Visit dependencies first
86            if let Some(deps) = reverse_deps.get(rule_name) {
87                for dep in deps {
88                    if rule_map.contains_key(dep) {
89                        visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
90                    }
91                }
92            }
93
94            visiting.remove(rule_name);
95            visited.insert(rule_name.to_string());
96
97            // Add this rule to sorted list
98            if let Some(&rule) = rule_map.get(rule_name) {
99                sorted.push(rule);
100            }
101        }
102
103        // Visit all rules
104        for rule in rules {
105            visit(
106                rule.name(),
107                &rule_map,
108                &reverse_deps,
109                &mut visited,
110                &mut visiting,
111                &mut sorted,
112            );
113        }
114
115        // Add any rules not in dependency graph
116        for rule in rules {
117            if !sorted.iter().any(|r| r.name() == rule.name()) {
118                sorted.push(rule.as_ref());
119            }
120        }
121
122        sorted
123    }
124
125    /// Apply fixes iteratively until no more fixes are needed or max iterations reached
126    /// Returns (rules_fixed_count, iterations, context_creations, fixed_rule_names)
127    pub fn apply_fixes_iterative(
128        &self,
129        rules: &[Box<dyn Rule>],
130        all_warnings: &[LintWarning],
131        content: &mut String,
132        config: &Config,
133        max_iterations: usize,
134    ) -> Result<(usize, usize, usize, HashSet<String>), String> {
135        // Get optimal rule order
136        let ordered_rules = self.get_optimal_order(rules);
137
138        // Group warnings by rule for quick lookup
139        let mut warnings_by_rule: HashMap<&str, Vec<&LintWarning>> = HashMap::new();
140        for warning in all_warnings {
141            if let Some(rule_name) = warning.rule_name {
142                warnings_by_rule.entry(rule_name).or_default().push(warning);
143            }
144        }
145
146        let mut total_fixed = 0;
147        let mut total_ctx_creations = 0;
148        let mut iterations = 0;
149
150        // Keep track of which rules have been processed successfully
151        let mut processed_rules = HashSet::new();
152
153        // Track which rules actually applied fixes
154        let mut fixed_rule_names = HashSet::new();
155
156        // Keep applying fixes until content stabilizes
157        while iterations < max_iterations {
158            iterations += 1;
159
160            let mut fixes_in_iteration = 0;
161            let mut any_fix_applied = false;
162
163            // Process one rule at a time with its own context
164            for rule in &ordered_rules {
165                // Skip rules we've already successfully processed
166                if processed_rules.contains(rule.name()) {
167                    continue;
168                }
169
170                // Only process rules that had warnings
171                if !warnings_by_rule.contains_key(rule.name()) {
172                    processed_rules.insert(rule.name());
173                    continue;
174                }
175
176                // Check if rule is disabled
177                if config
178                    .global
179                    .unfixable
180                    .iter()
181                    .any(|r| r.eq_ignore_ascii_case(rule.name()))
182                {
183                    processed_rules.insert(rule.name());
184                    continue;
185                }
186
187                if !config.global.fixable.is_empty()
188                    && !config
189                        .global
190                        .fixable
191                        .iter()
192                        .any(|r| r.eq_ignore_ascii_case(rule.name()))
193                {
194                    processed_rules.insert(rule.name());
195                    continue;
196                }
197
198                // Create context for this specific rule
199                let ctx = LintContext::new(content, config.markdown_flavor());
200                total_ctx_creations += 1;
201
202                // Apply fix
203                match rule.fix(&ctx) {
204                    Ok(fixed_content) => {
205                        if fixed_content != *content {
206                            *content = fixed_content;
207                            fixes_in_iteration += 1;
208                            any_fix_applied = true;
209                            processed_rules.insert(rule.name());
210                            fixed_rule_names.insert(rule.name().to_string());
211
212                            // If this rule has dependents, break to start fresh iteration
213                            if self.dependencies.contains_key(rule.name()) {
214                                break;
215                            }
216                            // Otherwise continue with the next rule
217                        } else {
218                            // No changes from this rule, mark as processed
219                            processed_rules.insert(rule.name());
220                        }
221                    }
222                    Err(_) => {
223                        // Error applying fix, mark as processed to avoid retrying
224                        processed_rules.insert(rule.name());
225                    }
226                }
227            }
228
229            total_fixed += fixes_in_iteration;
230
231            // If no fixes were made in this iteration, we're done
232            if !any_fix_applied {
233                break;
234            }
235
236            // If all rules have been processed, we're done
237            if processed_rules.len() >= ordered_rules.len() {
238                break;
239            }
240        }
241
242        Ok((total_fixed, iterations, total_ctx_creations, fixed_rule_names))
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::config::GlobalConfig;
250    use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory};
251
252    // Mock rule for testing
253    #[derive(Clone)]
254    struct MockRule {
255        name: &'static str,
256        warnings: Vec<LintWarning>,
257        fix_content: String,
258    }
259
260    impl Rule for MockRule {
261        fn name(&self) -> &'static str {
262            self.name
263        }
264
265        fn check(&self, _ctx: &LintContext) -> LintResult {
266            Ok(self.warnings.clone())
267        }
268
269        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
270            Ok(self.fix_content.clone())
271        }
272
273        fn description(&self) -> &'static str {
274            "Mock rule for testing"
275        }
276
277        fn category(&self) -> RuleCategory {
278            RuleCategory::Whitespace
279        }
280
281        fn as_any(&self) -> &dyn std::any::Any {
282            self
283        }
284    }
285
286    #[test]
287    fn test_dependency_ordering() {
288        let coordinator = FixCoordinator::new();
289
290        let rules: Vec<Box<dyn Rule>> = vec![
291            Box::new(MockRule {
292                name: "MD009",
293                warnings: vec![],
294                fix_content: "".to_string(),
295            }),
296            Box::new(MockRule {
297                name: "MD013",
298                warnings: vec![],
299                fix_content: "".to_string(),
300            }),
301            Box::new(MockRule {
302                name: "MD010",
303                warnings: vec![],
304                fix_content: "".to_string(),
305            }),
306            Box::new(MockRule {
307                name: "MD007",
308                warnings: vec![],
309                fix_content: "".to_string(),
310            }),
311        ];
312
313        let ordered = coordinator.get_optimal_order(&rules);
314        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
315
316        // MD010 should come before MD007 (dependency)
317        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
318        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
319        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
320
321        // MD013 should come before MD009 (dependency)
322        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
323        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
324        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
325    }
326
327    #[test]
328    fn test_single_iteration_fix() {
329        let coordinator = FixCoordinator::new();
330
331        let rules: Vec<Box<dyn Rule>> = vec![Box::new(MockRule {
332            name: "MD001",
333            warnings: vec![LintWarning {
334                line: 1,
335                column: 1,
336                end_line: 1,
337                end_column: 10,
338                message: "Test warning".to_string(),
339                rule_name: Some("MD001"),
340                severity: crate::rule::Severity::Error,
341                fix: None,
342            }],
343            fix_content: "fixed content".to_string(),
344        })];
345
346        let warnings = vec![LintWarning {
347            line: 1,
348            column: 1,
349            end_line: 1,
350            end_column: 10,
351            message: "Test warning".to_string(),
352            rule_name: Some("MD001"),
353            severity: crate::rule::Severity::Error,
354            fix: None,
355        }];
356
357        let mut content = "original content".to_string();
358        let config = Config {
359            global: GlobalConfig::default(),
360            per_file_ignores: HashMap::new(),
361            rules: Default::default(),
362        };
363
364        let result = coordinator.apply_fixes_iterative(&rules, &warnings, &mut content, &config, 5);
365
366        assert!(result.is_ok());
367        let (total_fixed, iterations, ctx_creations, _) = result.unwrap();
368        assert_eq!(total_fixed, 1);
369        assert_eq!(iterations, 1);
370        assert_eq!(ctx_creations, 1);
371        assert_eq!(content, "fixed content");
372    }
373
374    #[test]
375    fn test_multiple_iteration_with_dependencies() {
376        let coordinator = FixCoordinator::new();
377
378        let rules: Vec<Box<dyn Rule>> = vec![
379            Box::new(MockRule {
380                name: "MD010", // Has dependents
381                warnings: vec![LintWarning {
382                    line: 1,
383                    column: 1,
384                    end_line: 1,
385                    end_column: 10,
386                    message: "Tabs".to_string(),
387                    rule_name: Some("MD010"),
388                    severity: crate::rule::Severity::Error,
389                    fix: None,
390                }],
391                fix_content: "content with spaces".to_string(),
392            }),
393            Box::new(MockRule {
394                name: "MD007", // Depends on MD010
395                warnings: vec![LintWarning {
396                    line: 1,
397                    column: 1,
398                    end_line: 1,
399                    end_column: 10,
400                    message: "Indentation".to_string(),
401                    rule_name: Some("MD007"),
402                    severity: crate::rule::Severity::Error,
403                    fix: None,
404                }],
405                fix_content: "content with spaces and proper indent".to_string(),
406            }),
407        ];
408
409        let warnings = vec![
410            LintWarning {
411                line: 1,
412                column: 1,
413                end_line: 1,
414                end_column: 10,
415                message: "Tabs".to_string(),
416                rule_name: Some("MD010"),
417                severity: crate::rule::Severity::Error,
418                fix: None,
419            },
420            LintWarning {
421                line: 1,
422                column: 1,
423                end_line: 1,
424                end_column: 10,
425                message: "Indentation".to_string(),
426                rule_name: Some("MD007"),
427                severity: crate::rule::Severity::Error,
428                fix: None,
429            },
430        ];
431
432        let mut content = "content with tabs".to_string();
433        let config = Config {
434            global: GlobalConfig::default(),
435            per_file_ignores: HashMap::new(),
436            rules: Default::default(),
437        };
438
439        let result = coordinator.apply_fixes_iterative(&rules, &warnings, &mut content, &config, 5);
440
441        assert!(result.is_ok());
442        let (total_fixed, iterations, ctx_creations, _) = result.unwrap();
443        assert_eq!(total_fixed, 2);
444        assert_eq!(iterations, 2); // Should take 2 iterations due to dependency
445        assert!(ctx_creations >= 2);
446    }
447
448    #[test]
449    fn test_unfixable_rules_skipped() {
450        let coordinator = FixCoordinator::new();
451
452        let rules: Vec<Box<dyn Rule>> = vec![Box::new(MockRule {
453            name: "MD001",
454            warnings: vec![LintWarning {
455                line: 1,
456                column: 1,
457                end_line: 1,
458                end_column: 10,
459                message: "Test".to_string(),
460                rule_name: Some("MD001"),
461                severity: crate::rule::Severity::Error,
462                fix: None,
463            }],
464            fix_content: "fixed".to_string(),
465        })];
466
467        let warnings = vec![LintWarning {
468            line: 1,
469            column: 1,
470            end_line: 1,
471            end_column: 10,
472            message: "Test".to_string(),
473            rule_name: Some("MD001"),
474            severity: crate::rule::Severity::Error,
475            fix: None,
476        }];
477
478        let mut content = "original".to_string();
479        let mut config = Config {
480            global: GlobalConfig::default(),
481            per_file_ignores: HashMap::new(),
482            rules: Default::default(),
483        };
484        config.global.unfixable = vec!["MD001".to_string()];
485
486        let result = coordinator.apply_fixes_iterative(&rules, &warnings, &mut content, &config, 5);
487
488        assert!(result.is_ok());
489        let (total_fixed, _, _, _) = result.unwrap();
490        assert_eq!(total_fixed, 0);
491        assert_eq!(content, "original"); // Should not be changed
492    }
493
494    #[test]
495    fn test_max_iterations_limit() {
496        // This test ensures we don't loop infinitely
497        let coordinator = FixCoordinator::new();
498
499        // Create a rule that always changes content
500        #[derive(Clone)]
501        struct AlwaysChangeRule;
502        impl Rule for AlwaysChangeRule {
503            fn name(&self) -> &'static str {
504                "MD999"
505            }
506            fn check(&self, _: &LintContext) -> LintResult {
507                Ok(vec![LintWarning {
508                    line: 1,
509                    column: 1,
510                    end_line: 1,
511                    end_column: 10,
512                    message: "Always warns".to_string(),
513                    rule_name: Some("MD999"),
514                    severity: crate::rule::Severity::Error,
515                    fix: None,
516                }])
517            }
518            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
519                Ok(format!("{}x", ctx.content))
520            }
521            fn description(&self) -> &'static str {
522                "Always changes"
523            }
524            fn category(&self) -> RuleCategory {
525                RuleCategory::Whitespace
526            }
527            fn as_any(&self) -> &dyn std::any::Any {
528                self
529            }
530        }
531
532        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
533        let warnings = vec![LintWarning {
534            line: 1,
535            column: 1,
536            end_line: 1,
537            end_column: 10,
538            message: "Always warns".to_string(),
539            rule_name: Some("MD999"),
540            severity: crate::rule::Severity::Error,
541            fix: None,
542        }];
543
544        let mut content = "test".to_string();
545        let config = Config {
546            global: GlobalConfig::default(),
547            per_file_ignores: HashMap::new(),
548            rules: Default::default(),
549        };
550
551        let result = coordinator.apply_fixes_iterative(&rules, &warnings, &mut content, &config, 3);
552
553        assert!(result.is_ok());
554        let (_, iterations, _, _) = result.unwrap();
555        assert_eq!(iterations, 1); // Should stop after first successful fix
556    }
557
558    #[test]
559    fn test_empty_rules_and_warnings() {
560        let coordinator = FixCoordinator::new();
561        let rules: Vec<Box<dyn Rule>> = vec![];
562        let warnings: Vec<LintWarning> = vec![];
563
564        let mut content = "unchanged".to_string();
565        let config = Config {
566            global: GlobalConfig::default(),
567            per_file_ignores: HashMap::new(),
568            rules: Default::default(),
569        };
570
571        let result = coordinator.apply_fixes_iterative(&rules, &warnings, &mut content, &config, 5);
572
573        assert!(result.is_ok());
574        let (total_fixed, iterations, ctx_creations, _) = result.unwrap();
575        assert_eq!(total_fixed, 0);
576        assert_eq!(iterations, 1);
577        assert_eq!(ctx_creations, 0);
578        assert_eq!(content, "unchanged");
579    }
580
581    #[test]
582    fn test_cyclic_dependencies_handled() {
583        // Test that cyclic dependencies don't cause infinite loops
584        let mut coordinator = FixCoordinator::new();
585
586        // Create a cycle: A -> B -> C -> A
587        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
588        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
589        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
590
591        let rules: Vec<Box<dyn Rule>> = vec![
592            Box::new(MockRule {
593                name: "RuleA",
594                warnings: vec![],
595                fix_content: "".to_string(),
596            }),
597            Box::new(MockRule {
598                name: "RuleB",
599                warnings: vec![],
600                fix_content: "".to_string(),
601            }),
602            Box::new(MockRule {
603                name: "RuleC",
604                warnings: vec![],
605                fix_content: "".to_string(),
606            }),
607        ];
608
609        // Should not panic or infinite loop
610        let ordered = coordinator.get_optimal_order(&rules);
611
612        // Should return all rules despite cycle
613        assert_eq!(ordered.len(), 3);
614    }
615}