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