1pub mod config;
2pub mod exit_codes;
3pub mod fix_coordinator;
4pub mod inline_config;
5pub mod lint_context;
6pub mod lsp;
7pub mod markdownlint_config;
8pub mod output;
9pub mod parallel;
10pub mod performance;
11pub mod profiling;
12pub mod rule;
13pub mod vscode;
14#[macro_use]
15pub mod rule_config;
16#[macro_use]
17pub mod rule_config_serde;
18pub mod rules;
19pub mod utils;
20
21#[cfg(feature = "python")]
22pub mod python;
23
24pub use rules::heading_utils::{Heading, HeadingStyle};
25pub use rules::*;
26
27pub use crate::lint_context::{LineInfo, LintContext, ListItemInfo};
28use crate::rule::{LintResult, Rule, RuleCategory};
29use std::time::Instant;
30
31#[derive(Debug, Default)]
33struct ContentCharacteristics {
34 has_headings: bool, has_lists: bool, has_links: bool, has_code: bool, has_emphasis: bool, has_html: bool, has_tables: bool, has_blockquotes: bool, has_images: bool, }
44
45impl ContentCharacteristics {
46 fn analyze(content: &str) -> Self {
47 let mut chars = Self { ..Default::default() };
48
49 let mut has_atx_heading = false;
51 let mut has_setext_heading = false;
52
53 for line in content.lines() {
54 let trimmed = line.trim();
55
56 if !has_atx_heading && trimmed.starts_with('#') {
58 has_atx_heading = true;
59 }
60 if !has_setext_heading && (trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.len() > 1) {
61 has_setext_heading = true;
62 }
63
64 if !chars.has_lists && (line.contains("* ") || line.contains("- ") || line.contains("+ ")) {
66 chars.has_lists = true;
67 }
68 if !chars.has_lists && line.chars().next().is_some_and(|c| c.is_ascii_digit()) && line.contains(". ") {
69 chars.has_lists = true;
70 }
71 if !chars.has_links
72 && (line.contains('[')
73 || line.contains("http://")
74 || line.contains("https://")
75 || line.contains("ftp://"))
76 {
77 chars.has_links = true;
78 }
79 if !chars.has_images && line.contains("![") {
80 chars.has_images = true;
81 }
82 if !chars.has_code && (line.contains('`') || line.contains("~~~")) {
83 chars.has_code = true;
84 }
85 if !chars.has_emphasis && (line.contains('*') || line.contains('_')) {
86 chars.has_emphasis = true;
87 }
88 if !chars.has_html && line.contains('<') {
89 chars.has_html = true;
90 }
91 if !chars.has_tables && line.contains('|') {
92 chars.has_tables = true;
93 }
94 if !chars.has_blockquotes && line.starts_with('>') {
95 chars.has_blockquotes = true;
96 }
97 }
98
99 chars.has_headings = has_atx_heading || has_setext_heading;
100 chars
101 }
102
103 fn should_skip_rule(&self, rule: &dyn Rule) -> bool {
105 match rule.category() {
106 RuleCategory::Heading => !self.has_headings,
107 RuleCategory::List => !self.has_lists,
108 RuleCategory::Link => !self.has_links && !self.has_images,
109 RuleCategory::Image => !self.has_images,
110 RuleCategory::CodeBlock => !self.has_code,
111 RuleCategory::Html => !self.has_html,
112 RuleCategory::Emphasis => !self.has_emphasis,
113 RuleCategory::Blockquote => !self.has_blockquotes,
114 RuleCategory::Table => !self.has_tables,
115 RuleCategory::Whitespace | RuleCategory::FrontMatter | RuleCategory::Other => false,
117 }
118 }
119}
120
121pub fn lint(
125 content: &str,
126 rules: &[Box<dyn Rule>],
127 _verbose: bool,
128 flavor: crate::config::MarkdownFlavor,
129) -> LintResult {
130 let mut warnings = Vec::new();
131 let _overall_start = Instant::now();
132
133 if content.is_empty() {
135 return Ok(warnings);
136 }
137
138 let inline_config = crate::inline_config::InlineConfig::from_content(content);
140
141 let characteristics = ContentCharacteristics::analyze(content);
143
144 let applicable_rules: Vec<_> = rules
146 .iter()
147 .filter(|rule| !characteristics.should_skip_rule(rule.as_ref()))
148 .collect();
149
150 let _total_rules = rules.len();
152 let _applicable_count = applicable_rules.len();
153
154 let ast_rules_count = applicable_rules.iter().filter(|rule| rule.uses_ast()).count();
156 let ast = if ast_rules_count > 0 {
157 Some(crate::utils::ast_utils::get_cached_ast(content))
158 } else {
159 None
160 };
161
162 let lint_ctx = crate::lint_context::LintContext::new(content, flavor);
164
165 for rule in applicable_rules {
166 let _rule_start = Instant::now();
167
168 let result = if rule.uses_ast() {
170 if let Some(ref ast_ref) = ast {
171 rule.as_maybe_ast()
173 .and_then(|ext| ext.check_with_ast_opt(&lint_ctx, ast_ref))
174 .unwrap_or_else(|| rule.check_with_ast(&lint_ctx, ast_ref))
175 } else {
176 rule.check(&lint_ctx)
178 }
179 } else {
180 rule.check(&lint_ctx)
182 };
183
184 match result {
185 Ok(rule_warnings) => {
186 let filtered_warnings: Vec<_> = rule_warnings
188 .into_iter()
189 .filter(|warning| {
190 let rule_name_to_check = warning.rule_name.unwrap_or(rule.name());
192
193 let base_rule_name = if let Some(dash_pos) = rule_name_to_check.find('-') {
195 &rule_name_to_check[..dash_pos]
196 } else {
197 rule_name_to_check
198 };
199
200 !inline_config.is_rule_disabled(
201 base_rule_name,
202 warning.line, )
204 })
205 .collect();
206 warnings.extend(filtered_warnings);
207 }
208 Err(e) => {
209 log::error!("Error checking rule {}: {}", rule.name(), e);
210 return Err(e);
211 }
212 }
213
214 #[cfg(not(test))]
215 if _verbose {
216 let rule_duration = _rule_start.elapsed();
217 if rule_duration.as_millis() > 500 {
218 log::debug!("Rule {} took {:?}", rule.name(), rule_duration);
219 }
220 }
221 }
222
223 #[cfg(not(test))]
224 if _verbose {
225 let skipped_rules = _total_rules - _applicable_count;
226 if skipped_rules > 0 {
227 log::debug!("Skipped {skipped_rules} of {_total_rules} rules based on content analysis");
228 }
229 if ast.is_some() {
230 log::debug!("Used shared AST for {ast_rules_count} rules");
231 }
232 }
233
234 Ok(warnings)
235}
236
237pub fn get_profiling_report() -> String {
239 profiling::get_report()
240}
241
242pub fn reset_profiling() {
244 profiling::reset()
245}
246
247pub fn get_regex_cache_stats() -> std::collections::HashMap<String, u64> {
249 crate::utils::regex_cache::get_cache_stats()
250}
251
252pub fn get_ast_cache_stats() -> std::collections::HashMap<u64, u64> {
254 crate::utils::ast_utils::get_ast_cache_stats()
255}
256
257pub fn clear_all_caches() {
259 crate::utils::ast_utils::clear_ast_cache();
260 }
262
263pub fn get_cache_performance_report() -> String {
265 let regex_stats = get_regex_cache_stats();
266 let ast_stats = get_ast_cache_stats();
267
268 let mut report = String::new();
269
270 report.push_str("=== Cache Performance Report ===\n\n");
271
272 report.push_str("Regex Cache:\n");
274 if regex_stats.is_empty() {
275 report.push_str(" No regex patterns cached\n");
276 } else {
277 let total_usage: u64 = regex_stats.values().sum();
278 report.push_str(&format!(" Total patterns: {}\n", regex_stats.len()));
279 report.push_str(&format!(" Total usage: {total_usage}\n"));
280
281 let mut sorted_patterns: Vec<_> = regex_stats.iter().collect();
283 sorted_patterns.sort_by(|a, b| b.1.cmp(a.1));
284
285 report.push_str(" Top patterns by usage:\n");
286 for (pattern, count) in sorted_patterns.iter().take(5) {
287 let truncated_pattern = if pattern.len() > 50 {
288 format!("{}...", &pattern[..47])
289 } else {
290 pattern.to_string()
291 };
292 report.push_str(&format!(
293 " {} ({}x): {}\n",
294 count,
295 pattern.len().min(50),
296 truncated_pattern
297 ));
298 }
299 }
300
301 report.push('\n');
302
303 report.push_str("AST Cache:\n");
305 if ast_stats.is_empty() {
306 report.push_str(" No AST nodes cached\n");
307 } else {
308 let total_usage: u64 = ast_stats.values().sum();
309 report.push_str(&format!(" Total ASTs: {}\n", ast_stats.len()));
310 report.push_str(&format!(" Total usage: {total_usage}\n"));
311
312 if total_usage > ast_stats.len() as u64 {
313 let cache_hit_rate = ((total_usage - ast_stats.len() as u64) as f64 / total_usage as f64) * 100.0;
314 report.push_str(&format!(" Cache hit rate: {cache_hit_rate:.1}%\n"));
315 }
316 }
317
318 report
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::rule::Rule;
325 use crate::rules::{MD001HeadingIncrement, MD009TrailingSpaces, MD012NoMultipleBlanks};
326
327 #[test]
328 fn test_content_characteristics_analyze() {
329 let chars = ContentCharacteristics::analyze("");
331 assert!(!chars.has_headings);
332 assert!(!chars.has_lists);
333 assert!(!chars.has_links);
334 assert!(!chars.has_code);
335 assert!(!chars.has_emphasis);
336 assert!(!chars.has_html);
337 assert!(!chars.has_tables);
338 assert!(!chars.has_blockquotes);
339 assert!(!chars.has_images);
340
341 let chars = ContentCharacteristics::analyze("# Heading");
343 assert!(chars.has_headings);
344
345 let chars = ContentCharacteristics::analyze("Heading\n=======");
347 assert!(chars.has_headings);
348
349 let chars = ContentCharacteristics::analyze("* Item\n- Item 2\n+ Item 3");
351 assert!(chars.has_lists);
352
353 let chars = ContentCharacteristics::analyze("1. First\n2. Second");
355 assert!(chars.has_lists);
356
357 let chars = ContentCharacteristics::analyze("[link](url)");
359 assert!(chars.has_links);
360
361 let chars = ContentCharacteristics::analyze("Visit https://example.com");
363 assert!(chars.has_links);
364
365 let chars = ContentCharacteristics::analyze("");
367 assert!(chars.has_images);
368
369 let chars = ContentCharacteristics::analyze("`inline code`");
371 assert!(chars.has_code);
372
373 let chars = ContentCharacteristics::analyze("~~~\ncode block\n~~~");
374 assert!(chars.has_code);
375
376 let chars = ContentCharacteristics::analyze("*emphasis* and _more_");
378 assert!(chars.has_emphasis);
379
380 let chars = ContentCharacteristics::analyze("<div>HTML content</div>");
382 assert!(chars.has_html);
383
384 let chars = ContentCharacteristics::analyze("| Header | Header |\n|--------|--------|");
386 assert!(chars.has_tables);
387
388 let chars = ContentCharacteristics::analyze("> Quote");
390 assert!(chars.has_blockquotes);
391
392 let content = "# Heading\n* List item\n[link](url)\n`code`\n*emphasis*\n<p>html</p>\n| table |\n> quote\n";
394 let chars = ContentCharacteristics::analyze(content);
395 assert!(chars.has_headings);
396 assert!(chars.has_lists);
397 assert!(chars.has_links);
398 assert!(chars.has_code);
399 assert!(chars.has_emphasis);
400 assert!(chars.has_html);
401 assert!(chars.has_tables);
402 assert!(chars.has_blockquotes);
403 assert!(chars.has_images);
404 }
405
406 #[test]
407 fn test_content_characteristics_should_skip_rule() {
408 let chars = ContentCharacteristics {
409 has_headings: true,
410 has_lists: false,
411 has_links: true,
412 has_code: false,
413 has_emphasis: true,
414 has_html: false,
415 has_tables: true,
416 has_blockquotes: false,
417 has_images: false,
418 };
419
420 let heading_rule = MD001HeadingIncrement;
422 assert!(!chars.should_skip_rule(&heading_rule));
423
424 let trailing_spaces_rule = MD009TrailingSpaces::new(2, false);
425 assert!(!chars.should_skip_rule(&trailing_spaces_rule)); let chars_no_headings = ContentCharacteristics {
429 has_headings: false,
430 ..Default::default()
431 };
432 assert!(chars_no_headings.should_skip_rule(&heading_rule));
433 }
434
435 #[test]
436 fn test_lint_empty_content() {
437 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement)];
438
439 let result = lint("", &rules, false, crate::config::MarkdownFlavor::Standard);
440 assert!(result.is_ok());
441 assert!(result.unwrap().is_empty());
442 }
443
444 #[test]
445 fn test_lint_with_violations() {
446 let content = "## Level 2\n#### Level 4"; let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement)];
448
449 let result = lint(content, &rules, false, crate::config::MarkdownFlavor::Standard);
450 assert!(result.is_ok());
451 let warnings = result.unwrap();
452 assert!(!warnings.is_empty());
453 assert_eq!(warnings[0].rule_name, Some("MD001"));
455 }
456
457 #[test]
458 fn test_lint_with_inline_disable() {
459 let content = "<!-- rumdl-disable MD001 -->\n## Level 2\n#### Level 4";
460 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement)];
461
462 let result = lint(content, &rules, false, crate::config::MarkdownFlavor::Standard);
463 assert!(result.is_ok());
464 let warnings = result.unwrap();
465 assert!(warnings.is_empty()); }
467
468 #[test]
469 fn test_lint_rule_filtering() {
470 let content = "# Heading\nJust text";
472 let rules: Vec<Box<dyn Rule>> = vec![
473 Box::new(MD001HeadingIncrement),
474 ];
476
477 let result = lint(content, &rules, false, crate::config::MarkdownFlavor::Standard);
478 assert!(result.is_ok());
479 }
480
481 #[test]
482 fn test_get_profiling_report() {
483 let report = get_profiling_report();
485 assert!(!report.is_empty());
486 assert!(report.contains("Profiling"));
487 }
488
489 #[test]
490 fn test_reset_profiling() {
491 reset_profiling();
493
494 let report = get_profiling_report();
496 assert!(report.contains("disabled") || report.contains("no measurements"));
497 }
498
499 #[test]
500 fn test_get_regex_cache_stats() {
501 let stats = get_regex_cache_stats();
502 assert!(stats.is_empty() || !stats.is_empty());
504
505 for count in stats.values() {
507 assert!(*count > 0);
508 }
509 }
510
511 #[test]
512 fn test_get_ast_cache_stats() {
513 let stats = get_ast_cache_stats();
514 assert!(stats.is_empty() || !stats.is_empty());
516
517 for count in stats.values() {
519 assert!(*count > 0);
520 }
521 }
522
523 #[test]
524 fn test_clear_all_caches() {
525 clear_all_caches();
527
528 let ast_stats = get_ast_cache_stats();
530 assert!(ast_stats.is_empty());
531 }
532
533 #[test]
534 fn test_get_cache_performance_report() {
535 let report = get_cache_performance_report();
536
537 assert!(report.contains("Cache Performance Report"));
539 assert!(report.contains("Regex Cache:"));
540 assert!(report.contains("AST Cache:"));
541
542 clear_all_caches();
544 let report_empty = get_cache_performance_report();
545 assert!(report_empty.contains("No AST nodes cached"));
546 }
547
548 #[test]
549 fn test_lint_with_ast_rules() {
550 let content = "# Heading\n\nParagraph with **bold** text.";
552 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD012NoMultipleBlanks::new(1))];
553
554 let result = lint(content, &rules, false, crate::config::MarkdownFlavor::Standard);
555 assert!(result.is_ok());
556 }
557
558 #[test]
559 fn test_content_characteristics_edge_cases() {
560 let chars = ContentCharacteristics::analyze("-"); assert!(!chars.has_headings);
563
564 let chars = ContentCharacteristics::analyze("--"); assert!(chars.has_headings);
566
567 let chars = ContentCharacteristics::analyze("*emphasis*"); assert!(!chars.has_lists);
570
571 let chars = ContentCharacteristics::analyze("1.Item"); assert!(!chars.has_lists);
573
574 let chars = ContentCharacteristics::analyze("text > not a quote");
576 assert!(!chars.has_blockquotes);
577 }
578
579 #[test]
580 fn test_cache_performance_report_formatting() {
581 let report = get_cache_performance_report();
585
586 assert!(!report.is_empty());
590 assert!(report.lines().count() > 3); }
592}