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, detect_line_ending_enum, normalize_line_ending};
10use crate::workspace_index::FileIndex;
11
12/// The deterministic lint and index result for one document.
13pub struct DocumentAnalysis {
14    pub warnings: Vec<LintWarning>,
15    pub file_index: FileIndex,
16}
17
18/// A document run with path roles made explicit.
19///
20/// `config_path` is a logical identity used by per-file configuration. A
21/// separate `source_file` is exposed to rules that may access the filesystem.
22/// Native adapters usually set both; browser and other virtual adapters set only
23/// `config_path`.
24pub struct DocumentRun<'a> {
25    content: &'a str,
26    rules: &'a [Box<dyn Rule>],
27    config: &'a Config,
28    config_path: Option<&'a Path>,
29    source_file: Option<&'a Path>,
30    link_target_policy: Option<&'a crate::lint_context::LinkTargetPolicy>,
31    invalid_utf8: Option<&'a [crate::encoding::InvalidSeq]>,
32    verbose: bool,
33}
34
35impl<'a> DocumentRun<'a> {
36    pub fn new(content: &'a str, rules: &'a [Box<dyn Rule>], config: &'a Config) -> Self {
37        Self {
38            content,
39            rules,
40            config,
41            config_path: None,
42            source_file: None,
43            link_target_policy: None,
44            invalid_utf8: None,
45            verbose: false,
46        }
47    }
48
49    /// Set a real file path for both per-file configuration and rule filesystem access.
50    pub fn file_path(mut self, path: &'a Path) -> Self {
51        self.config_path = Some(path);
52        self.source_file = Some(path);
53        self
54    }
55
56    /// Set a logical path used only for per-file configuration matching.
57    pub fn config_path(mut self, path: Option<&'a Path>) -> Self {
58        self.config_path = path;
59        self
60    }
61
62    /// Set the filesystem path visible to rules independently of configuration matching.
63    pub fn source_file(mut self, path: Option<&'a Path>) -> Self {
64        self.source_file = path;
65        self
66    }
67
68    /// Supply a run-scoped view of virtual document paths for link validation.
69    pub fn link_target_policy(mut self, policy: &'a crate::lint_context::LinkTargetPolicy) -> Self {
70        self.link_target_policy = Some(policy);
71        self
72    }
73
74    /// Mark the content as lossily decoded from bytes holding these invalid
75    /// UTF-8 sequences. MD094 reports them, and no finding carries a fix.
76    pub fn invalid_utf8(mut self, invalid: Option<&'a [crate::encoding::InvalidSeq]>) -> Self {
77        self.invalid_utf8 = invalid;
78        self
79    }
80
81    pub fn verbose(mut self, verbose: bool) -> Self {
82        self.verbose = verbose;
83        self
84    }
85
86    pub fn flavor(&self) -> MarkdownFlavor {
87        self.config_path.map_or_else(
88            || self.config.markdown_flavor(),
89            |path| self.config.get_flavor_for_file(path),
90        )
91    }
92
93    pub fn analyze(&self) -> Result<DocumentAnalysis, LintError> {
94        let (warnings, file_index) = self.analyze_raw();
95        warnings.map(|warnings| DocumentAnalysis { warnings, file_index })
96    }
97
98    pub fn analyze_raw(&self) -> (Result<Vec<LintWarning>, LintError>, FileIndex) {
99        crate::lint_and_index_with_paths(
100            self.content,
101            self.rules,
102            self.verbose,
103            self.flavor(),
104            self.paths(),
105            Some(self.config),
106        )
107    }
108
109    /// Apply every fix and return the document in its own line-ending
110    /// convention.
111    ///
112    /// Rules fix LF text: a `fix()` that rebuilds the document joins its lines
113    /// with `\n`, and an inserted line ending is `\n`. The CLI normalises a file
114    /// to LF before it gets here and restores the ending on write; the same
115    /// happens here for every caller (LSP, wasm), so a CRLF document comes back
116    /// CRLF, and one with mixed endings comes back LF exactly as `rumdl fmt`
117    /// writes it. A document nothing changed comes back byte-identical.
118    pub fn fix(&self, max_iterations: usize) -> Result<(String, FixResult), String> {
119        let line_ending = detect_line_ending_enum(self.content);
120        let normalized = normalize_line_ending(self.content, LineEnding::Lf);
121        let mut content = normalized.to_string();
122        let result = FixCoordinator::new().apply_fixes_iterative_with_paths(
123            self.rules,
124            &[],
125            &mut content,
126            self.config,
127            max_iterations,
128            self.paths(),
129        )?;
130        if content == *normalized {
131            return Ok((self.content.to_string(), result));
132        }
133        let restored = match normalize_line_ending(&content, line_ending) {
134            Cow::Borrowed(_) => content,
135            Cow::Owned(restored) => restored,
136        };
137        Ok((restored, result))
138    }
139
140    pub fn config_path_buf(&self) -> Option<PathBuf> {
141        self.config_path.map(Path::to_path_buf)
142    }
143
144    fn paths(&self) -> crate::DocumentPaths<'a> {
145        crate::DocumentPaths {
146            config_path: self.config_path,
147            source_file: self.source_file,
148            link_target_policy: self.link_target_policy,
149            invalid_utf8: self.invalid_utf8,
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use std::any::Any;
157
158    use indexmap::IndexMap;
159
160    use super::*;
161    use crate::lint_context::LintContext;
162    use crate::rule::{LintResult, Severity};
163
164    #[derive(Clone)]
165    struct ContextProbe;
166
167    impl Rule for ContextProbe {
168        fn name(&self) -> &'static str {
169            "TEST001"
170        }
171
172        fn description(&self) -> &'static str {
173            "Probe document context"
174        }
175
176        fn check(&self, ctx: &LintContext) -> LintResult {
177            let message = format!("flavor={};source={}", ctx.flavor, ctx.source_file().is_some());
178            Ok(vec![LintWarning {
179                message,
180                line: 1,
181                column: 1,
182                end_line: 1,
183                end_column: 1,
184                severity: Severity::Warning,
185                fix: None,
186                rule_name: Some(self.name().to_string()),
187            }])
188        }
189
190        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
191            Ok(ctx.content.to_string())
192        }
193
194        fn as_any(&self) -> &dyn Any {
195            self
196        }
197    }
198
199    #[test]
200    fn logical_path_selects_flavor_without_exposing_a_filesystem_path() {
201        let mut config = Config::default();
202        config.per_file_flavor = IndexMap::from([("docs/**".to_string(), MarkdownFlavor::MkDocs)]);
203        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ContextProbe)];
204        let path = Path::new("docs/page.md");
205
206        let analysis = DocumentRun::new("text", &rules, &config)
207            .config_path(Some(path))
208            .analyze()
209            .unwrap();
210
211        assert_eq!(analysis.warnings[0].message, "flavor=mkdocs;source=false");
212    }
213
214    #[test]
215    fn native_file_path_selects_flavor_and_reaches_rules() {
216        let mut config = Config::default();
217        config.per_file_flavor = IndexMap::from([("docs/**".to_string(), MarkdownFlavor::MkDocs)]);
218        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ContextProbe)];
219        let path = Path::new("docs/page.md");
220
221        let analysis = DocumentRun::new("text", &rules, &config)
222            .file_path(path)
223            .analyze()
224            .unwrap();
225
226        assert_eq!(analysis.warnings[0].message, "flavor=mkdocs;source=true");
227    }
228}