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