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