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::path::{Path, PathBuf};
4
5use crate::config::{Config, MarkdownFlavor};
6use crate::fix_coordinator::{FixCoordinator, FixResult};
7use crate::rule::{LintError, LintWarning, Rule};
8use crate::workspace_index::FileIndex;
9
10/// The deterministic lint and index result for one document.
11pub struct DocumentAnalysis {
12    pub warnings: Vec<LintWarning>,
13    pub file_index: FileIndex,
14}
15
16/// A document run with path roles made explicit.
17///
18/// `config_path` is a logical identity used by per-file configuration. A
19/// separate `source_file` is exposed to rules that may access the filesystem.
20/// Native adapters usually set both; browser and other virtual adapters set only
21/// `config_path`.
22pub struct DocumentRun<'a> {
23    content: &'a str,
24    rules: &'a [Box<dyn Rule>],
25    config: &'a Config,
26    config_path: Option<&'a Path>,
27    source_file: Option<&'a Path>,
28    verbose: bool,
29}
30
31impl<'a> DocumentRun<'a> {
32    pub fn new(content: &'a str, rules: &'a [Box<dyn Rule>], config: &'a Config) -> Self {
33        Self {
34            content,
35            rules,
36            config,
37            config_path: None,
38            source_file: None,
39            verbose: false,
40        }
41    }
42
43    /// Set a real file path for both per-file configuration and rule filesystem access.
44    pub fn file_path(mut self, path: &'a Path) -> Self {
45        self.config_path = Some(path);
46        self.source_file = Some(path);
47        self
48    }
49
50    /// Set a logical path used only for per-file configuration matching.
51    pub fn config_path(mut self, path: Option<&'a Path>) -> Self {
52        self.config_path = path;
53        self
54    }
55
56    /// Set the filesystem path visible to rules independently of configuration matching.
57    pub fn source_file(mut self, path: Option<&'a Path>) -> Self {
58        self.source_file = path;
59        self
60    }
61
62    pub fn verbose(mut self, verbose: bool) -> Self {
63        self.verbose = verbose;
64        self
65    }
66
67    pub fn flavor(&self) -> MarkdownFlavor {
68        self.config_path.map_or_else(
69            || self.config.markdown_flavor(),
70            |path| self.config.get_flavor_for_file(path),
71        )
72    }
73
74    pub fn analyze(&self) -> Result<DocumentAnalysis, LintError> {
75        let (warnings, file_index) = self.analyze_raw();
76        warnings.map(|warnings| DocumentAnalysis { warnings, file_index })
77    }
78
79    pub fn analyze_raw(&self) -> (Result<Vec<LintWarning>, LintError>, FileIndex) {
80        crate::lint_and_index_with_paths(
81            self.content,
82            self.rules,
83            self.verbose,
84            self.flavor(),
85            self.paths(),
86            Some(self.config),
87        )
88    }
89
90    pub fn fix(&self, max_iterations: usize) -> Result<(String, FixResult), String> {
91        let mut content = self.content.to_string();
92        let result = FixCoordinator::new().apply_fixes_iterative_with_paths(
93            self.rules,
94            &[],
95            &mut content,
96            self.config,
97            max_iterations,
98            self.paths(),
99        )?;
100        Ok((content, result))
101    }
102
103    pub fn config_path_buf(&self) -> Option<PathBuf> {
104        self.config_path.map(Path::to_path_buf)
105    }
106
107    fn paths(&self) -> crate::DocumentPaths<'a> {
108        crate::DocumentPaths {
109            config_path: self.config_path,
110            source_file: self.source_file,
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use std::any::Any;
118
119    use indexmap::IndexMap;
120
121    use super::*;
122    use crate::lint_context::LintContext;
123    use crate::rule::{LintResult, Severity};
124
125    #[derive(Clone)]
126    struct ContextProbe;
127
128    impl Rule for ContextProbe {
129        fn name(&self) -> &'static str {
130            "TEST001"
131        }
132
133        fn description(&self) -> &'static str {
134            "Probe document context"
135        }
136
137        fn check(&self, ctx: &LintContext) -> LintResult {
138            let message = format!("flavor={};source={}", ctx.flavor, ctx.source_file().is_some());
139            Ok(vec![LintWarning {
140                message,
141                line: 1,
142                column: 1,
143                end_line: 1,
144                end_column: 1,
145                severity: Severity::Warning,
146                fix: None,
147                rule_name: Some(self.name().to_string()),
148            }])
149        }
150
151        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
152            Ok(ctx.content.to_string())
153        }
154
155        fn as_any(&self) -> &dyn Any {
156            self
157        }
158    }
159
160    #[test]
161    fn logical_path_selects_flavor_without_exposing_a_filesystem_path() {
162        let mut config = Config::default();
163        config.per_file_flavor = IndexMap::from([("docs/**".to_string(), MarkdownFlavor::MkDocs)]);
164        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ContextProbe)];
165        let path = Path::new("docs/page.md");
166
167        let analysis = DocumentRun::new("text", &rules, &config)
168            .config_path(Some(path))
169            .analyze()
170            .unwrap();
171
172        assert_eq!(analysis.warnings[0].message, "flavor=mkdocs;source=false");
173    }
174
175    #[test]
176    fn native_file_path_selects_flavor_and_reaches_rules() {
177        let mut config = Config::default();
178        config.per_file_flavor = IndexMap::from([("docs/**".to_string(), MarkdownFlavor::MkDocs)]);
179        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ContextProbe)];
180        let path = Path::new("docs/page.md");
181
182        let analysis = DocumentRun::new("text", &rules, &config)
183            .file_path(path)
184            .analyze()
185            .unwrap();
186
187        assert_eq!(analysis.warnings[0].message, "flavor=mkdocs;source=true");
188    }
189}