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