1use 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
12pub struct DocumentAnalysis {
14 pub warnings: Vec<LintWarning>,
15 pub file_index: FileIndex,
16}
17
18pub 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 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 pub fn config_path(mut self, path: Option<&'a Path>) -> Self {
58 self.config_path = path;
59 self
60 }
61
62 pub fn source_file(mut self, path: Option<&'a Path>) -> Self {
64 self.source_file = path;
65 self
66 }
67
68 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 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 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}