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, NormalizedLineEndingMap, normalize_line_ending};
10use crate::workspace_index::FileIndex;
11
12pub struct DocumentAnalysis {
15 pub warnings: Vec<LintWarning>,
16 pub file_index: FileIndex,
17}
18
19pub struct DocumentFormatResult {
22 pub warnings: crate::LintResult,
23 pub file_index: FileIndex,
24 pub fixes: Option<Result<(String, FixResult), String>>,
25 pub remaining_warnings: Option<crate::LintResult>,
29}
30
31impl DocumentFormatResult {
32 pub(crate) fn without_fixes(warnings: crate::LintResult, file_index: FileIndex) -> Self {
33 Self {
34 warnings,
35 file_index,
36 fixes: None,
37 remaining_warnings: None,
38 }
39 }
40}
41
42pub struct DocumentRun<'a> {
49 content: &'a str,
50 conflict_scan: Option<&'a crate::merge_conflict::ConflictScan<'a>>,
51 rules: &'a [Box<dyn Rule>],
52 config: &'a Config,
53 config_path: Option<&'a Path>,
54 source_file: Option<&'a Path>,
55 link_target_policy: Option<&'a crate::lint_context::LinkTargetPolicy>,
56 invalid_utf8: Option<&'a [crate::encoding::InvalidSeq]>,
57 verbose: bool,
58}
59
60impl<'a> DocumentRun<'a> {
61 pub fn new(content: &'a str, rules: &'a [Box<dyn Rule>], config: &'a Config) -> Self {
62 Self {
63 content,
64 conflict_scan: None,
65 rules,
66 config,
67 config_path: None,
68 source_file: None,
69 link_target_policy: None,
70 invalid_utf8: None,
71 verbose: false,
72 }
73 }
74
75 pub fn from_scanned(
79 scan: &'a crate::merge_conflict::ConflictScan<'a>,
80 rules: &'a [Box<dyn Rule>],
81 config: &'a Config,
82 ) -> Self {
83 Self {
84 conflict_scan: Some(scan),
85 ..Self::new(scan.content(), rules, config)
86 }
87 }
88
89 pub fn file_path(mut self, path: &'a Path) -> Self {
91 self.config_path = Some(path);
92 self.source_file = Some(path);
93 self
94 }
95
96 pub fn config_path(mut self, path: Option<&'a Path>) -> Self {
98 self.config_path = path;
99 self
100 }
101
102 pub fn source_file(mut self, path: Option<&'a Path>) -> Self {
104 self.source_file = path;
105 self
106 }
107
108 pub fn link_target_policy(mut self, policy: &'a crate::lint_context::LinkTargetPolicy) -> Self {
110 self.link_target_policy = Some(policy);
111 self
112 }
113
114 pub fn invalid_utf8(mut self, invalid: Option<&'a [crate::encoding::InvalidSeq]>) -> Self {
117 self.invalid_utf8 = invalid;
118 self
119 }
120
121 pub fn verbose(mut self, verbose: bool) -> Self {
122 self.verbose = verbose;
123 self
124 }
125
126 pub fn flavor(&self) -> MarkdownFlavor {
127 self.config_path.map_or_else(
128 || self.config.markdown_flavor(),
129 |path| self.config.get_flavor_for_file(path),
130 )
131 }
132
133 pub fn analyze(&self) -> Result<DocumentAnalysis, LintError> {
134 let (warnings, file_index) = self.analyze_raw();
135 warnings.map(|warnings| DocumentAnalysis { warnings, file_index })
136 }
137
138 pub fn analyze_raw(&self) -> (Result<Vec<LintWarning>, LintError>, FileIndex) {
139 if let Some(scan) = self.conflict_scan {
140 let result = crate::analyze_scanned_document(
141 scan,
142 self.rules,
143 self.verbose,
144 self.flavor(),
145 self.paths(),
146 Some(self.config),
147 None,
148 );
149 return (result.warnings, result.file_index);
150 }
151 crate::lint_and_index_with_paths(
152 self.content,
153 self.rules,
154 self.verbose,
155 self.flavor(),
156 self.paths(),
157 Some(self.config),
158 )
159 }
160
161 pub fn analyze_and_fix(&self, max_iterations: usize) -> DocumentFormatResult {
165 if self.content.contains('\r') || self.link_target_policy.is_some() {
170 let (warnings, file_index) = self.analyze_raw();
171 let fixes = self.invalid_utf8.is_none() && warnings.as_ref().is_ok_and(|warnings| !warnings.is_empty());
172 let fixes = fixes.then(|| self.fix(max_iterations));
173 return DocumentFormatResult {
174 warnings,
175 file_index,
176 fixes,
177 remaining_warnings: None,
178 };
179 }
180 let scan;
181 let scan = match self.conflict_scan {
182 Some(scan) => scan,
183 None => {
184 scan = crate::merge_conflict::ConflictScan::new(self.content);
185 &scan
186 }
187 };
188 crate::analyze_scanned_document(
189 scan,
190 self.rules,
191 self.verbose,
192 self.flavor(),
193 self.paths(),
194 Some(self.config),
195 Some(max_iterations),
196 )
197 }
198
199 pub fn fix(&self, max_iterations: usize) -> Result<(String, FixResult), String> {
210 self.fix_with_coordinator(max_iterations, &FixCoordinator::new())
211 }
212
213 pub fn fix_for_formatter(&self, max_iterations: usize) -> Result<(String, FixResult), String> {
217 self.fix_with_coordinator(max_iterations, &FixCoordinator::for_formatter())
218 }
219
220 fn fix_with_coordinator(
221 &self,
222 max_iterations: usize,
223 coordinator: &FixCoordinator,
224 ) -> Result<(String, FixResult), String> {
225 let normalized = normalize_line_ending(self.content, LineEnding::Lf);
226 let mut content = normalized.to_string();
227 let result = coordinator.apply_fixes_iterative_with_paths(
228 self.rules,
229 &[],
230 &mut content,
231 self.config,
232 max_iterations,
233 self.paths(),
234 )?;
235 if content == *normalized {
236 return Ok((self.content.to_string(), result));
237 }
238 let restored = match NormalizedLineEndingMap::new(self.content).restore_fixed(&normalized, &content) {
239 Cow::Borrowed(_) => content,
240 Cow::Owned(restored) => restored,
241 };
242 Ok((restored, result))
243 }
244
245 pub fn config_path_buf(&self) -> Option<PathBuf> {
246 self.config_path.map(Path::to_path_buf)
247 }
248
249 fn paths(&self) -> crate::DocumentPaths<'a> {
250 crate::DocumentPaths {
251 config_path: self.config_path,
252 source_file: self.source_file,
253 link_target_policy: self.link_target_policy,
254 invalid_utf8: self.invalid_utf8,
255 }
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use std::any::Any;
262
263 use indexmap::IndexMap;
264
265 use super::*;
266 use crate::lint_context::LintContext;
267 use crate::rule::{LintResult, Severity};
268 use std::sync::Arc;
269 use std::sync::atomic::{AtomicUsize, Ordering};
270
271 #[derive(Clone)]
272 struct CountingRewrite {
273 name: &'static str,
274 from: &'static str,
275 to: &'static str,
276 checks: Arc<AtomicUsize>,
277 category: crate::rule::RuleCategory,
278 }
279
280 impl Rule for CountingRewrite {
281 fn name(&self) -> &'static str {
282 self.name
283 }
284 fn description(&self) -> &'static str {
285 "Count source-dependent checks"
286 }
287 fn check(&self, ctx: &LintContext) -> LintResult {
288 self.checks.fetch_add(1, Ordering::Relaxed);
289 Ok(ctx
290 .content
291 .find(self.from)
292 .map(|offset| {
293 let (line, column) = ctx.offset_to_line_col(offset);
294 LintWarning {
295 rule_name: Some(self.name.to_string()),
296 message: self.description().to_string(),
297 line,
298 column,
299 end_line: line,
300 end_column: column + self.from.len(),
301 severity: Severity::Warning,
302 fix: None,
303 }
304 })
305 .into_iter()
306 .collect())
307 }
308 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
309 Ok(ctx.content.replace(self.from, self.to))
310 }
311 fn as_any(&self) -> &dyn Any {
312 self
313 }
314 fn category(&self) -> crate::rule::RuleCategory {
315 self.category
316 }
317 }
318
319 fn counting_rule(name: &'static str, from: &'static str, to: &'static str) -> (Box<dyn Rule>, Arc<AtomicUsize>) {
320 let checks = Arc::new(AtomicUsize::new(0));
321 (
322 Box::new(CountingRewrite {
323 name,
324 from,
325 to,
326 checks: checks.clone(),
327 category: crate::rule::RuleCategory::Other,
328 }),
329 checks,
330 )
331 }
332
333 #[test]
334 fn combined_run_reuses_the_initial_context_and_checks() {
335 let config = Config::default();
336 let (rule, checks) = counting_rule("MD009", "text", "text");
337 let rules = vec![rule];
338 let result = DocumentRun::new("text\n", &rules, &config).analyze_and_fix(100);
339 assert_eq!(result.warnings.unwrap().len(), 1);
340 let (text, fixed) = result.fixes.unwrap().unwrap();
341 assert_eq!(text, "text\n");
342 assert_eq!(checks.load(Ordering::Relaxed), 1);
343 assert_eq!(fixed.context_creations, 0);
344 assert!(fixed.converged);
345 }
346
347 #[test]
348 fn combined_run_invalidates_all_checks_after_a_rewrite() {
349 let config = Config::default();
350 let (first, first_checks) = counting_rule("MD009", "bad", "middle");
351 let (second, second_checks) = counting_rule("MD011", "middle", "good");
352 let rules = vec![first, second];
353 let result = DocumentRun::new("bad\n", &rules, &config).analyze_and_fix(100);
354 let (text, fixed) = result.fixes.unwrap().unwrap();
355 assert_eq!(text, "good\n");
356 assert_eq!(first_checks.load(Ordering::Relaxed), 2);
357 assert_eq!(second_checks.load(Ordering::Relaxed), 3);
358 assert_eq!(fixed.context_creations, 2);
359 assert_eq!(fixed.rules_fixed, 2);
360 assert!(fixed.converged);
361 }
362
363 #[test]
364 fn stable_analysis_reuses_checks_and_still_runs_unfixable_rules() {
365 let mut config = Config::default();
366 config.global.unfixable.push("MD041".into());
367 let (rewrite, rewrite_checks) = counting_rule("MD009", "bad", "good");
368 let (unchanged, unchanged_checks) = counting_rule("MD011", "stable", "stable");
369 let (unfixable, unfixable_checks) = counting_rule("MD041", "other", "other");
370 let rules = vec![rewrite, unchanged, unfixable];
371 let result = DocumentRun::new("bad stable other\n", &rules, &config).analyze_and_fix(100);
372 let (text, fixed) = result.fixes.unwrap().unwrap();
373 assert_eq!(text, "good stable other\n");
374 assert!(fixed.converged);
375 assert_eq!(rewrite_checks.load(Ordering::Relaxed), 2);
378 assert_eq!(unchanged_checks.load(Ordering::Relaxed), 2);
379 assert_eq!(unfixable_checks.load(Ordering::Relaxed), 2);
380 let remaining = result.remaining_warnings.unwrap().unwrap();
381 assert_eq!(remaining.len(), 2);
382 assert_eq!(
383 format!("{remaining:?}"),
384 format!(
385 "{:?}",
386 DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings
387 ),
388 );
389 }
390
391 #[test]
392 fn remaining_bom_warnings_match_a_fresh_public_analysis() {
393 let mut config = Config::default();
394 config.global.unfixable.push("MD018".into());
395 let rules: Vec<_> = crate::rules::all_rules(&config)
396 .into_iter()
397 .filter(|r| r.name() == "MD009" || r.name() == "MD018")
398 .collect();
399 let result = DocumentRun::new("\u{feff}#Title\n\ntext \n", &rules, &config).analyze_and_fix(100);
400 let (text, _) = result.fixes.expect("prepared fix").unwrap();
401 assert_eq!(text, "\u{feff}#Title\n\ntext\n");
402 let remaining = result.remaining_warnings.expect("final analysis was reused").unwrap();
403 assert_eq!(remaining.len(), 1);
404 assert!(remaining[0].fix.is_some());
405 assert_eq!(remaining[0].fix.as_ref().unwrap().range, 3..9);
406 let fresh = DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings;
407 assert_eq!(remaining, fresh);
408 }
409
410 #[test]
411 fn stable_analysis_preserves_suppression_observers_and_severity() {
412 let mut config = Config::default();
413 config.rules.insert(
414 "MD087".into(),
415 crate::config::RuleConfig {
416 severity: Some(Severity::Error),
417 ..Default::default()
418 },
419 );
420 let (rewrite, _) = counting_rule("MD009", "bad", "good");
421 let (suppressed, _) = counting_rule("MD011", "stable", "stable");
422 let rules: Vec<Box<dyn Rule>> = vec![rewrite, suppressed, Box::new(crate::rules::MD087UnusedDisableComment)];
423 let source =
424 "<!-- rumdl-disable-next-line MD011 -->\nstable\n<!-- rumdl-disable-next-line MD009 -->\nquiet\nbad\n";
425 let result = DocumentRun::new(source, &rules, &config).analyze_and_fix(100);
426 let (text, _) = result.fixes.unwrap().unwrap();
427 let remaining = result.remaining_warnings.unwrap().unwrap();
428 assert_eq!(remaining.len(), 1);
429 assert_eq!(remaining[0].rule_name.as_deref(), Some("MD087"));
430 assert_eq!(remaining[0].line, 3);
431 assert_eq!(remaining[0].severity, Severity::Error);
432 assert_eq!(
433 format!("{remaining:?}"),
434 format!(
435 "{:?}",
436 DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings
437 ),
438 );
439 }
440
441 #[test]
442 fn stable_analysis_distinguishes_zero_sized_rule_instances() {
443 let config = Config::default();
444 let rules: Vec<Box<dyn Rule>> = vec![
445 Box::new(ContextProbe),
446 crate::rules::create_rule_by_name("MD018", &config).unwrap(),
447 ];
448 let result = DocumentRun::new("#Title\n", &rules, &config).analyze_and_fix(100);
449 let (text, _) = result.fixes.unwrap().unwrap();
450 assert_eq!(text, "# Title\n");
451 let remaining = result.remaining_warnings.unwrap().unwrap();
452 assert_eq!(remaining.len(), 1);
453 assert_eq!(remaining[0].rule_name.as_deref(), Some("TEST001"));
454 assert_eq!(
455 format!("{remaining:?}"),
456 format!(
457 "{:?}",
458 DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings
459 ),
460 );
461 }
462
463 #[test]
464 fn unfinished_fix_runs_leave_final_analysis_to_the_caller() {
465 let config = Config::default();
466 let (first, _) = counting_rule("MD009", "bad", "middle");
467 let (second, _) = counting_rule("MD011", "middle", "good");
468 let rules = vec![first, second];
469 let result = DocumentRun::new("bad\n", &rules, &config).analyze_and_fix(1);
470 let (text, fixed) = result.fixes.unwrap().unwrap();
471 assert_eq!(text, "middle\n");
472 assert!(!fixed.converged);
473 assert!(result.remaining_warnings.is_none());
474 }
475
476 #[test]
477 fn combined_run_keeps_inline_disabled_findings_out_of_fixes() {
478 let config = Config::default();
479 let (disabled, checks) = counting_rule("MD009", "bad", "good");
480 let (warning_only, _) = counting_rule("MD011", "other", "other");
481 let rules = vec![disabled, warning_only];
482 let source = "<!-- rumdl-disable MD009 -->\nbad other\n";
483 let result = DocumentRun::new(source, &rules, &config).analyze_and_fix(100);
484 assert_eq!(result.warnings.unwrap().len(), 1);
485 let (text, fixed) = result.fixes.unwrap().unwrap();
486 assert_eq!(text, source);
487 assert_eq!(checks.load(Ordering::Relaxed), 1);
488 assert_eq!(fixed.rules_fixed, 0);
489 }
490
491 #[test]
492 fn combined_run_checks_rules_that_the_lint_prefilter_did_not_run() {
493 let config = Config::default();
494 let checks = Arc::new(AtomicUsize::new(0));
495 let skipped: Box<dyn Rule> = Box::new(CountingRewrite {
496 name: "MD001",
497 from: "plain",
498 to: "changed",
499 checks: checks.clone(),
500 category: crate::rule::RuleCategory::Heading,
501 });
502 let (reported, _) = counting_rule("MD009", "warning", "warning");
503 let rules = vec![skipped, reported];
504 let result = DocumentRun::new("plain warning\n", &rules, &config).analyze_and_fix(100);
505 assert_eq!(result.warnings.unwrap().len(), 1);
506 let (text, fixed) = result.fixes.unwrap().unwrap();
507 assert_eq!(text, "changed warning\n");
508 assert_eq!(checks.load(Ordering::Relaxed), 2);
509 assert!(fixed.converged);
510 }
511
512 #[test]
513 fn combined_run_never_prepares_fixes_for_lossy_input() {
514 let config = Config::default();
515 let (rule, checks) = counting_rule("MD009", "bad", "good");
516 let rules = vec![rule];
517 let result = DocumentRun::new("bad\n", &rules, &config)
518 .invalid_utf8(Some(&[]))
519 .analyze_and_fix(100);
520 assert!(!result.warnings.unwrap().is_empty());
521 assert!(result.fixes.is_none());
522 assert_eq!(checks.load(Ordering::Relaxed), 1);
523 }
524
525 #[test]
526 fn combined_run_matches_separate_analysis_and_fixing() {
527 for flavor in crate::format_proof::ALL_FLAVORS {
528 let mut config = Config::default();
529 config.global.flavor = flavor;
530 let rules = crate::rules::all_rules(&config);
531 for source in [
532 "#Title\n\ntext \n",
533 "\u{feff}#Title\n\ntext \n",
534 "# Title\r\n\r\ntext \r\n",
535 "<!-- rumdl-disable MD018 -->\n#Title\n",
536 "<!-- rumdl-configure-file {\"MD013\":{\"line-length\":12}} -->\n\nA rather long line that can be wrapped.\n",
537 "- item\n\n```\ncode\n```\n",
538 ] {
539 let run = DocumentRun::new(source, &rules, &config);
540 let original = run.analyze().unwrap();
541 let combined = run.analyze_and_fix(100);
542 assert_eq!(
543 format!("{:?}", combined.warnings.unwrap()),
544 format!("{:?}", original.warnings)
545 );
546 if let Some(fixes) = combined.fixes {
547 let text = fixes.unwrap().0;
548 assert_eq!(text, run.fix(100).unwrap().0, "{flavor}: {source:?}");
549 if let Some(remaining) = combined.remaining_warnings {
550 assert_eq!(
551 format!("{:?}", remaining.unwrap()),
552 format!(
553 "{:?}",
554 DocumentRun::new(&text, &rules, &config).analyze().unwrap().warnings
555 ),
556 "{flavor}: {source:?}",
557 );
558 }
559 } else {
560 assert_eq!(run.fix(100).unwrap().0, source, "{flavor}: {source:?}");
561 }
562 }
563 }
564 }
565
566 #[derive(Clone)]
567 struct ContextProbe;
568
569 impl Rule for ContextProbe {
570 fn name(&self) -> &'static str {
571 "TEST001"
572 }
573
574 fn description(&self) -> &'static str {
575 "Probe document context"
576 }
577
578 fn check(&self, ctx: &LintContext) -> LintResult {
579 let message = format!("flavor={};source={}", ctx.flavor, ctx.source_file().is_some());
580 Ok(vec![LintWarning {
581 message,
582 line: 1,
583 column: 1,
584 end_line: 1,
585 end_column: 1,
586 severity: Severity::Warning,
587 fix: None,
588 rule_name: Some(self.name().to_string()),
589 }])
590 }
591
592 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
593 Ok(ctx.content.to_string())
594 }
595
596 fn as_any(&self) -> &dyn Any {
597 self
598 }
599 }
600
601 #[test]
602 fn logical_path_selects_flavor_without_exposing_a_filesystem_path() {
603 let mut config = Config::default();
604 config.per_file_flavor = IndexMap::from([("docs/**".to_string(), MarkdownFlavor::MkDocs)]);
605 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ContextProbe)];
606 let path = Path::new("docs/page.md");
607
608 let analysis = DocumentRun::new("text", &rules, &config)
609 .config_path(Some(path))
610 .analyze()
611 .unwrap();
612
613 assert_eq!(analysis.warnings[0].message, "flavor=mkdocs;source=false");
614 }
615
616 #[test]
617 fn native_file_path_selects_flavor_and_reaches_rules() {
618 let mut config = Config::default();
619 config.per_file_flavor = IndexMap::from([("docs/**".to_string(), MarkdownFlavor::MkDocs)]);
620 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ContextProbe)];
621 let path = Path::new("docs/page.md");
622
623 let analysis = DocumentRun::new("text", &rules, &config)
624 .file_path(path)
625 .analyze()
626 .unwrap();
627
628 assert_eq!(analysis.warnings[0].message, "flavor=mkdocs;source=true");
629 }
630}