Skip to main content

rumdl_lib/
document_run.rs

1//! Path-aware document analysis shared by CLI, stdin, LSP, and virtual adapters.
2
3use std::borrow::Cow;
4use std::path::{Path, PathBuf};
5
6use crate::config::{Config, MarkdownFlavor};
7use crate::fix_coordinator::{FixCoordinator, FixResult};
8use crate::rule::{LintError, LintWarning, Rule};
9use crate::utils::{LineEnding, NormalizedLineEndingMap, normalize_line_ending};
10use crate::workspace_index::FileIndex;
11
12/// The deterministic lint and index result for one document.
13/// Fix byte ranges address the supplied text, including any leading BOM.
14pub struct DocumentAnalysis {
15    pub warnings: Vec<LintWarning>,
16    pub file_index: FileIndex,
17}
18
19/// Diagnostics and index for the original source, with its prepared rewrite.
20/// Keeping the rewrite separate lets adapters finish validation before writing.
21pub struct DocumentFormatResult {
22    pub warnings: crate::LintResult,
23    pub file_index: FileIndex,
24    pub fixes: Option<Result<(String, FixResult), String>>,
25    /// Findings on the fixed source, when the fix loop reached a stable changed
26    /// document and could share its last context. Adapters must discard these
27    /// after any further edit and lint that text again.
28    pub remaining_warnings: Option<crate::LintResult>,
29}
30
31impl DocumentFormatResult {
32    pub(crate) fn without_fixes(warnings: crate::LintResult, file_index: FileIndex) -> Self {
33        Self {
34            warnings,
35            file_index,
36            fixes: None,
37            remaining_warnings: None,
38        }
39    }
40}
41
42/// A document run with path roles made explicit.
43///
44/// `config_path` is a logical identity used by per-file configuration. A
45/// separate `source_file` is exposed to rules that may access the filesystem.
46/// Native adapters usually set both; browser and other virtual adapters set only
47/// `config_path`.
48pub struct DocumentRun<'a> {
49    content: &'a str,
50    conflict_scan: Option<&'a crate::merge_conflict::ConflictScan<'a>>,
51    rules: &'a [Box<dyn Rule>],
52    config: &'a Config,
53    config_path: Option<&'a Path>,
54    source_file: Option<&'a Path>,
55    link_target_policy: Option<&'a crate::lint_context::LinkTargetPolicy>,
56    invalid_utf8: Option<&'a [crate::encoding::InvalidSeq]>,
57    verbose: bool,
58}
59
60impl<'a> DocumentRun<'a> {
61    pub fn new(content: &'a str, rules: &'a [Box<dyn Rule>], config: &'a Config) -> Self {
62        Self {
63            content,
64            conflict_scan: None,
65            rules,
66            config,
67            config_path: None,
68            source_file: None,
69            link_target_policy: None,
70            invalid_utf8: None,
71            verbose: false,
72        }
73    }
74
75    /// Reuse detection performed before normalization or external tool work.
76    /// The scan supplies its own immutable content, so facts cannot be attached
77    /// to a different document or retained after an edit.
78    pub fn from_scanned(
79        scan: &'a crate::merge_conflict::ConflictScan<'a>,
80        rules: &'a [Box<dyn Rule>],
81        config: &'a Config,
82    ) -> Self {
83        Self {
84            conflict_scan: Some(scan),
85            ..Self::new(scan.content(), rules, config)
86        }
87    }
88
89    /// Set a real file path for both per-file configuration and rule filesystem access.
90    pub fn file_path(mut self, path: &'a Path) -> Self {
91        self.config_path = Some(path);
92        self.source_file = Some(path);
93        self
94    }
95
96    /// Set a logical path used only for per-file configuration matching.
97    pub fn config_path(mut self, path: Option<&'a Path>) -> Self {
98        self.config_path = path;
99        self
100    }
101
102    /// Set the filesystem path visible to rules independently of configuration matching.
103    pub fn source_file(mut self, path: Option<&'a Path>) -> Self {
104        self.source_file = path;
105        self
106    }
107
108    /// Supply a run-scoped view of virtual document paths for link validation.
109    pub fn link_target_policy(mut self, policy: &'a crate::lint_context::LinkTargetPolicy) -> Self {
110        self.link_target_policy = Some(policy);
111        self
112    }
113
114    /// Mark the content as lossily decoded from bytes holding these invalid
115    /// UTF-8 sequences. MD094 reports them, and no finding carries a fix.
116    pub fn invalid_utf8(mut self, invalid: Option<&'a [crate::encoding::InvalidSeq]>) -> Self {
117        self.invalid_utf8 = invalid;
118        self
119    }
120
121    pub fn verbose(mut self, verbose: bool) -> Self {
122        self.verbose = verbose;
123        self
124    }
125
126    pub fn flavor(&self) -> MarkdownFlavor {
127        self.config_path.map_or_else(
128            || self.config.markdown_flavor(),
129            |path| self.config.get_flavor_for_file(path),
130        )
131    }
132
133    pub fn analyze(&self) -> Result<DocumentAnalysis, LintError> {
134        let (warnings, file_index) = self.analyze_raw();
135        warnings.map(|warnings| DocumentAnalysis { warnings, file_index })
136    }
137
138    pub fn analyze_raw(&self) -> (Result<Vec<LintWarning>, LintError>, FileIndex) {
139        if let Some(scan) = self.conflict_scan {
140            let result = crate::analyze_scanned_document(
141                scan,
142                self.rules,
143                self.verbose,
144                self.flavor(),
145                self.paths(),
146                Some(self.config),
147                None,
148            );
149            return (result.warnings, result.file_index);
150        }
151        crate::lint_and_index_with_paths(
152            self.content,
153            self.rules,
154            self.verbose,
155            self.flavor(),
156            self.paths(),
157            Some(self.config),
158        )
159    }
160
161    /// Analyze and prepare a fix while the first context and rule checks are
162    /// still available. Every rewrite invalidates that analysis; subsequent
163    /// iterations build a fresh context as they do in `fix`.
164    pub fn analyze_and_fix(&self, max_iterations: usize) -> DocumentFormatResult {
165        // The fix engine works on LF input. Preserve the separate operations'
166        // diagnostic offsets and line-ending restoration for other input.
167        // Virtual link policies also belong to analysis, while `fix` uses the
168        // filesystem context; keep that existing distinction for these runs.
169        if self.content.contains('\r') || self.link_target_policy.is_some() {
170            let (warnings, file_index) = self.analyze_raw();
171            let fixes = self.invalid_utf8.is_none() && warnings.as_ref().is_ok_and(|warnings| !warnings.is_empty());
172            let fixes = fixes.then(|| self.fix(max_iterations));
173            return DocumentFormatResult {
174                warnings,
175                file_index,
176                fixes,
177                remaining_warnings: None,
178            };
179        }
180        let scan;
181        let scan = match self.conflict_scan {
182            Some(scan) => scan,
183            None => {
184                scan = crate::merge_conflict::ConflictScan::new(self.content);
185                &scan
186            }
187        };
188        crate::analyze_scanned_document(
189            scan,
190            self.rules,
191            self.verbose,
192            self.flavor(),
193            self.paths(),
194            Some(self.config),
195            Some(max_iterations),
196        )
197    }
198
199    /// Apply every fix and return the document in its own line-ending
200    /// convention.
201    ///
202    /// Rules fix LF text: a `fix()` that rebuilds the document joins its lines
203    /// with `\n`, and an inserted line ending is `\n`. The CLI normalises a file
204    /// to LF before it gets here and restores the endings on write; the same
205    /// happens here for every caller (LSP, wasm), so the document comes back
206    /// with its line endings exactly as `rumdl fmt` writes it (see
207    /// `NormalizedLineEndingMap::restore_fixed`). A document nothing changed
208    /// comes back byte-identical.
209    pub fn fix(&self, max_iterations: usize) -> Result<(String, FixResult), String> {
210        self.fix_with_coordinator(max_iterations, &FixCoordinator::new())
211    }
212
213    /// Formatter-only execution with the same bytes, iterations, cycle report
214    /// and fixed rule names as `fix`. Only `context_creations` may differ.
215    /// Analysis consumers retain `fix`.
216    pub fn fix_for_formatter(&self, max_iterations: usize) -> Result<(String, FixResult), String> {
217        self.fix_with_coordinator(max_iterations, &FixCoordinator::for_formatter())
218    }
219
220    fn fix_with_coordinator(
221        &self,
222        max_iterations: usize,
223        coordinator: &FixCoordinator,
224    ) -> Result<(String, FixResult), String> {
225        let normalized = normalize_line_ending(self.content, LineEnding::Lf);
226        let mut content = normalized.to_string();
227        let result = coordinator.apply_fixes_iterative_with_paths(
228            self.rules,
229            &[],
230            &mut content,
231            self.config,
232            max_iterations,
233            self.paths(),
234        )?;
235        if content == *normalized {
236            return Ok((self.content.to_string(), result));
237        }
238        let restored = match NormalizedLineEndingMap::new(self.content).restore_fixed(&normalized, &content) {
239            Cow::Borrowed(_) => content,
240            Cow::Owned(restored) => restored,
241        };
242        Ok((restored, result))
243    }
244
245    pub fn config_path_buf(&self) -> Option<PathBuf> {
246        self.config_path.map(Path::to_path_buf)
247    }
248
249    fn paths(&self) -> crate::DocumentPaths<'a> {
250        crate::DocumentPaths {
251            config_path: self.config_path,
252            source_file: self.source_file,
253            link_target_policy: self.link_target_policy,
254            invalid_utf8: self.invalid_utf8,
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use std::any::Any;
262
263    use indexmap::IndexMap;
264
265    use super::*;
266    use crate::lint_context::LintContext;
267    use crate::rule::{LintResult, Severity};
268    use std::sync::Arc;
269    use std::sync::atomic::{AtomicUsize, Ordering};
270
271    #[derive(Clone)]
272    struct CountingRewrite {
273        name: &'static str,
274        from: &'static str,
275        to: &'static str,
276        checks: Arc<AtomicUsize>,
277        category: crate::rule::RuleCategory,
278    }
279
280    impl Rule for CountingRewrite {
281        fn name(&self) -> &'static str {
282            self.name
283        }
284        fn description(&self) -> &'static str {
285            "Count source-dependent checks"
286        }
287        fn check(&self, ctx: &LintContext) -> LintResult {
288            self.checks.fetch_add(1, Ordering::Relaxed);
289            Ok(ctx
290                .content
291                .find(self.from)
292                .map(|offset| {
293                    let (line, column) = ctx.offset_to_line_col(offset);
294                    LintWarning {
295                        rule_name: Some(self.name.to_string()),
296                        message: self.description().to_string(),
297                        line,
298                        column,
299                        end_line: line,
300                        end_column: column + self.from.len(),
301                        severity: Severity::Warning,
302                        fix: None,
303                    }
304                })
305                .into_iter()
306                .collect())
307        }
308        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
309            Ok(ctx.content.replace(self.from, self.to))
310        }
311        fn as_any(&self) -> &dyn Any {
312            self
313        }
314        fn category(&self) -> crate::rule::RuleCategory {
315            self.category
316        }
317    }
318
319    fn counting_rule(name: &'static str, from: &'static str, to: &'static str) -> (Box<dyn Rule>, Arc<AtomicUsize>) {
320        let checks = Arc::new(AtomicUsize::new(0));
321        (
322            Box::new(CountingRewrite {
323                name,
324                from,
325                to,
326                checks: checks.clone(),
327                category: crate::rule::RuleCategory::Other,
328            }),
329            checks,
330        )
331    }
332
333    #[test]
334    fn combined_run_reuses_the_initial_context_and_checks() {
335        let config = Config::default();
336        let (rule, checks) = counting_rule("MD009", "text", "text");
337        let rules = vec![rule];
338        let result = DocumentRun::new("text\n", &rules, &config).analyze_and_fix(100);
339        assert_eq!(result.warnings.unwrap().len(), 1);
340        let (text, fixed) = result.fixes.unwrap().unwrap();
341        assert_eq!(text, "text\n");
342        assert_eq!(checks.load(Ordering::Relaxed), 1);
343        assert_eq!(fixed.context_creations, 0);
344        assert!(fixed.converged);
345    }
346
347    #[test]
348    fn combined_run_invalidates_all_checks_after_a_rewrite() {
349        let config = Config::default();
350        let (first, first_checks) = counting_rule("MD009", "bad", "middle");
351        let (second, second_checks) = counting_rule("MD011", "middle", "good");
352        let rules = vec![first, second];
353        let result = DocumentRun::new("bad\n", &rules, &config).analyze_and_fix(100);
354        let (text, fixed) = result.fixes.unwrap().unwrap();
355        assert_eq!(text, "good\n");
356        assert_eq!(first_checks.load(Ordering::Relaxed), 2);
357        assert_eq!(second_checks.load(Ordering::Relaxed), 3);
358        assert_eq!(fixed.context_creations, 2);
359        assert_eq!(fixed.rules_fixed, 2);
360        assert!(fixed.converged);
361    }
362
363    #[test]
364    fn stable_analysis_reuses_checks_and_still_runs_unfixable_rules() {
365        let mut config = Config::default();
366        config.global.unfixable.push("MD041".into());
367        let (rewrite, rewrite_checks) = counting_rule("MD009", "bad", "good");
368        let (unchanged, unchanged_checks) = counting_rule("MD011", "stable", "stable");
369        let (unfixable, unfixable_checks) = counting_rule("MD041", "other", "other");
370        let rules = vec![rewrite, unchanged, unfixable];
371        let result = DocumentRun::new("bad stable other\n", &rules, &config).analyze_and_fix(100);
372        let (text, fixed) = result.fixes.unwrap().unwrap();
373        assert_eq!(text, "good stable other\n");
374        assert!(fixed.converged);
375        // Both the fix loop and reporting need the final checks, but each
376        // source version is checked only once. Warning-only rules still run.
377        assert_eq!(rewrite_checks.load(Ordering::Relaxed), 2);
378        assert_eq!(unchanged_checks.load(Ordering::Relaxed), 2);
379        assert_eq!(unfixable_checks.load(Ordering::Relaxed), 2);
380        let remaining = result.remaining_warnings.unwrap().unwrap();
381        assert_eq!(remaining.len(), 2);
382        assert_eq!(
383            format!("{remaining:?}"),
384            format!(
385                "{:?}",
386                DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings
387            ),
388        );
389    }
390
391    #[test]
392    fn remaining_bom_warnings_match_a_fresh_public_analysis() {
393        let mut config = Config::default();
394        config.global.unfixable.push("MD018".into());
395        let rules: Vec<_> = crate::rules::all_rules(&config)
396            .into_iter()
397            .filter(|r| r.name() == "MD009" || r.name() == "MD018")
398            .collect();
399        let result = DocumentRun::new("\u{feff}#Title\n\ntext   \n", &rules, &config).analyze_and_fix(100);
400        let (text, _) = result.fixes.expect("prepared fix").unwrap();
401        assert_eq!(text, "\u{feff}#Title\n\ntext\n");
402        let remaining = result.remaining_warnings.expect("final analysis was reused").unwrap();
403        assert_eq!(remaining.len(), 1);
404        assert!(remaining[0].fix.is_some());
405        assert_eq!(remaining[0].fix.as_ref().unwrap().range, 3..9);
406        let fresh = DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings;
407        assert_eq!(remaining, fresh);
408    }
409
410    #[test]
411    fn stable_analysis_preserves_suppression_observers_and_severity() {
412        let mut config = Config::default();
413        config.rules.insert(
414            "MD087".into(),
415            crate::config::RuleConfig {
416                severity: Some(Severity::Error),
417                ..Default::default()
418            },
419        );
420        let (rewrite, _) = counting_rule("MD009", "bad", "good");
421        let (suppressed, _) = counting_rule("MD011", "stable", "stable");
422        let rules: Vec<Box<dyn Rule>> = vec![rewrite, suppressed, Box::new(crate::rules::MD087UnusedDisableComment)];
423        let source =
424            "<!-- rumdl-disable-next-line MD011 -->\nstable\n<!-- rumdl-disable-next-line MD009 -->\nquiet\nbad\n";
425        let result = DocumentRun::new(source, &rules, &config).analyze_and_fix(100);
426        let (text, _) = result.fixes.unwrap().unwrap();
427        let remaining = result.remaining_warnings.unwrap().unwrap();
428        assert_eq!(remaining.len(), 1);
429        assert_eq!(remaining[0].rule_name.as_deref(), Some("MD087"));
430        assert_eq!(remaining[0].line, 3);
431        assert_eq!(remaining[0].severity, Severity::Error);
432        assert_eq!(
433            format!("{remaining:?}"),
434            format!(
435                "{:?}",
436                DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings
437            ),
438        );
439    }
440
441    #[test]
442    fn stable_analysis_distinguishes_zero_sized_rule_instances() {
443        let config = Config::default();
444        let rules: Vec<Box<dyn Rule>> = vec![
445            Box::new(ContextProbe),
446            crate::rules::create_rule_by_name("MD018", &config).unwrap(),
447        ];
448        let result = DocumentRun::new("#Title\n", &rules, &config).analyze_and_fix(100);
449        let (text, _) = result.fixes.unwrap().unwrap();
450        assert_eq!(text, "# Title\n");
451        let remaining = result.remaining_warnings.unwrap().unwrap();
452        assert_eq!(remaining.len(), 1);
453        assert_eq!(remaining[0].rule_name.as_deref(), Some("TEST001"));
454        assert_eq!(
455            format!("{remaining:?}"),
456            format!(
457                "{:?}",
458                DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings
459            ),
460        );
461    }
462
463    #[test]
464    fn unfinished_fix_runs_leave_final_analysis_to_the_caller() {
465        let config = Config::default();
466        let (first, _) = counting_rule("MD009", "bad", "middle");
467        let (second, _) = counting_rule("MD011", "middle", "good");
468        let rules = vec![first, second];
469        let result = DocumentRun::new("bad\n", &rules, &config).analyze_and_fix(1);
470        let (text, fixed) = result.fixes.unwrap().unwrap();
471        assert_eq!(text, "middle\n");
472        assert!(!fixed.converged);
473        assert!(result.remaining_warnings.is_none());
474    }
475
476    #[test]
477    fn combined_run_keeps_inline_disabled_findings_out_of_fixes() {
478        let config = Config::default();
479        let (disabled, checks) = counting_rule("MD009", "bad", "good");
480        let (warning_only, _) = counting_rule("MD011", "other", "other");
481        let rules = vec![disabled, warning_only];
482        let source = "<!-- rumdl-disable MD009 -->\nbad other\n";
483        let result = DocumentRun::new(source, &rules, &config).analyze_and_fix(100);
484        assert_eq!(result.warnings.unwrap().len(), 1);
485        let (text, fixed) = result.fixes.unwrap().unwrap();
486        assert_eq!(text, source);
487        assert_eq!(checks.load(Ordering::Relaxed), 1);
488        assert_eq!(fixed.rules_fixed, 0);
489    }
490
491    #[test]
492    fn combined_run_checks_rules_that_the_lint_prefilter_did_not_run() {
493        let config = Config::default();
494        let checks = Arc::new(AtomicUsize::new(0));
495        let skipped: Box<dyn Rule> = Box::new(CountingRewrite {
496            name: "MD001",
497            from: "plain",
498            to: "changed",
499            checks: checks.clone(),
500            category: crate::rule::RuleCategory::Heading,
501        });
502        let (reported, _) = counting_rule("MD009", "warning", "warning");
503        let rules = vec![skipped, reported];
504        let result = DocumentRun::new("plain warning\n", &rules, &config).analyze_and_fix(100);
505        assert_eq!(result.warnings.unwrap().len(), 1);
506        let (text, fixed) = result.fixes.unwrap().unwrap();
507        assert_eq!(text, "changed warning\n");
508        assert_eq!(checks.load(Ordering::Relaxed), 2);
509        assert!(fixed.converged);
510    }
511
512    #[test]
513    fn combined_run_never_prepares_fixes_for_lossy_input() {
514        let config = Config::default();
515        let (rule, checks) = counting_rule("MD009", "bad", "good");
516        let rules = vec![rule];
517        let result = DocumentRun::new("bad\n", &rules, &config)
518            .invalid_utf8(Some(&[]))
519            .analyze_and_fix(100);
520        assert!(!result.warnings.unwrap().is_empty());
521        assert!(result.fixes.is_none());
522        assert_eq!(checks.load(Ordering::Relaxed), 1);
523    }
524
525    #[test]
526    fn combined_run_matches_separate_analysis_and_fixing() {
527        for flavor in crate::format_proof::ALL_FLAVORS {
528            let mut config = Config::default();
529            config.global.flavor = flavor;
530            let rules = crate::rules::all_rules(&config);
531            for source in [
532                "#Title\n\ntext  \n",
533                "\u{feff}#Title\n\ntext  \n",
534                "# Title\r\n\r\ntext  \r\n",
535                "<!-- rumdl-disable MD018 -->\n#Title\n",
536                "<!-- rumdl-configure-file {\"MD013\":{\"line-length\":12}} -->\n\nA rather long line that can be wrapped.\n",
537                "- item\n\n```\ncode\n```\n",
538            ] {
539                let run = DocumentRun::new(source, &rules, &config);
540                let original = run.analyze().unwrap();
541                let combined = run.analyze_and_fix(100);
542                assert_eq!(
543                    format!("{:?}", combined.warnings.unwrap()),
544                    format!("{:?}", original.warnings)
545                );
546                if let Some(fixes) = combined.fixes {
547                    let text = fixes.unwrap().0;
548                    assert_eq!(text, run.fix(100).unwrap().0, "{flavor}: {source:?}");
549                    if let Some(remaining) = combined.remaining_warnings {
550                        assert_eq!(
551                            format!("{:?}", remaining.unwrap()),
552                            format!(
553                                "{:?}",
554                                DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings
555                            ),
556                            "{flavor}: {source:?}",
557                        );
558                    }
559                } else {
560                    assert_eq!(run.fix(100).unwrap().0, source, "{flavor}: {source:?}");
561                }
562            }
563        }
564    }
565
566    #[derive(Clone)]
567    struct ContextProbe;
568
569    impl Rule for ContextProbe {
570        fn name(&self) -> &'static str {
571            "TEST001"
572        }
573
574        fn description(&self) -> &'static str {
575            "Probe document context"
576        }
577
578        fn check(&self, ctx: &LintContext) -> LintResult {
579            let message = format!("flavor={};source={}", ctx.flavor, ctx.source_file().is_some());
580            Ok(vec![LintWarning {
581                message,
582                line: 1,
583                column: 1,
584                end_line: 1,
585                end_column: 1,
586                severity: Severity::Warning,
587                fix: None,
588                rule_name: Some(self.name().to_string()),
589            }])
590        }
591
592        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
593            Ok(ctx.content.to_string())
594        }
595
596        fn as_any(&self) -> &dyn Any {
597            self
598        }
599    }
600
601    #[test]
602    fn logical_path_selects_flavor_without_exposing_a_filesystem_path() {
603        let mut config = Config::default();
604        config.per_file_flavor = IndexMap::from([("docs/**".to_string(), MarkdownFlavor::MkDocs)]);
605        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ContextProbe)];
606        let path = Path::new("docs/page.md");
607
608        let analysis = DocumentRun::new("text", &rules, &config)
609            .config_path(Some(path))
610            .analyze()
611            .unwrap();
612
613        assert_eq!(analysis.warnings[0].message, "flavor=mkdocs;source=false");
614    }
615
616    #[test]
617    fn native_file_path_selects_flavor_and_reaches_rules() {
618        let mut config = Config::default();
619        config.per_file_flavor = IndexMap::from([("docs/**".to_string(), MarkdownFlavor::MkDocs)]);
620        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ContextProbe)];
621        let path = Path::new("docs/page.md");
622
623        let analysis = DocumentRun::new("text", &rules, &config)
624            .file_path(path)
625            .analyze()
626            .unwrap();
627
628        assert_eq!(analysis.warnings[0].message, "flavor=mkdocs;source=true");
629    }
630}