1#![warn(unreachable_pub)]
2#![warn(clippy::pedantic)]
3#![allow(clippy::doc_markdown)]
7#![allow(clippy::must_use_candidate)]
8#![allow(clippy::missing_errors_doc)]
9#![allow(clippy::missing_panics_doc)]
10#![allow(clippy::too_many_lines)]
11#![allow(clippy::if_not_else)]
12#![allow(clippy::similar_names)]
13#![allow(clippy::wildcard_imports)]
14#![allow(clippy::case_sensitive_file_extension_comparisons)]
15#![allow(clippy::doc_link_with_quotes)]
16#![allow(clippy::needless_raw_string_hashes)]
17#![allow(clippy::trivially_copy_pass_by_ref)]
18#![allow(clippy::struct_excessive_bools)]
19#![allow(clippy::fn_params_excessive_bools)]
20#![allow(clippy::elidable_lifetime_names)]
21#![allow(clippy::return_self_not_must_use)]
22#![allow(clippy::redundant_else)]
23#![allow(clippy::single_match_else)]
24#![allow(clippy::needless_continue)]
25#![allow(clippy::semicolon_if_nothing_returned)]
26#![allow(clippy::ignored_unit_patterns)]
27#![allow(clippy::unreadable_literal)]
28#![allow(clippy::implicit_hasher)]
29#![allow(clippy::ref_option)]
30#![allow(clippy::struct_field_names)]
31#![allow(clippy::unused_self)]
32#![allow(clippy::unnested_or_patterns)]
33#![allow(clippy::cast_precision_loss)]
34#![allow(clippy::cast_sign_loss)]
35#![allow(clippy::cast_possible_wrap)]
36#![allow(clippy::cast_possible_truncation)]
37#![allow(clippy::cast_lossless)]
38#![allow(clippy::items_after_statements)]
39#![allow(clippy::match_same_arms)]
40#![allow(clippy::format_push_string)]
41#![allow(clippy::no_effect_underscore_binding)]
44#![allow(clippy::default_trait_access)]
46#![allow(clippy::manual_string_new)]
49
50pub mod code_block_tools;
51pub mod config;
52pub mod discovery;
53pub mod doc_comment_lint;
54pub mod embedded_lint;
55pub mod exit_codes;
56pub mod filtered_lines;
57pub mod fix_coordinator;
58pub mod inline_config;
59pub mod linguist_data;
60pub mod lint_context;
61pub mod markdownlint_config;
62pub mod profiling;
63pub mod rule;
64#[cfg(feature = "colored")]
65pub mod vscode;
66pub mod workspace_index;
67#[macro_use]
68pub mod rule_config;
69#[macro_use]
70pub mod rule_config_serde;
71pub mod rules;
72pub mod types;
73pub mod utils;
74
75#[cfg(feature = "native")]
77pub mod lsp;
78#[cfg(feature = "colored")]
79pub mod output;
80
81#[cfg(feature = "wasm")]
83pub mod wasm;
84
85pub use rules::heading_utils::HeadingStyle;
86pub use rules::*;
87
88pub use crate::lint_context::{LineInfo, LintContext, ListItemInfo};
89use crate::rule::{LintResult, Rule, RuleCategory};
90use crate::utils::calculate_indentation_width_default;
91#[cfg(not(target_arch = "wasm32"))]
92use std::time::Instant;
93
94#[derive(Debug, Default)]
96struct ContentCharacteristics {
97 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, }
107
108fn has_potential_indented_code_indent(line: &str) -> bool {
111 calculate_indentation_width_default(line) >= 4
112}
113
114impl ContentCharacteristics {
115 fn analyze(content: &str) -> Self {
116 let mut chars = Self { ..Default::default() };
117
118 let mut has_atx_heading = false;
120 let mut has_setext_heading = false;
121
122 for line in content.lines() {
123 let trimmed = line.trim();
124
125 if !has_atx_heading
132 && (trimmed.starts_with('#') || trimmed.trim_start_matches(['>', ' ', '\t']).starts_with('#'))
133 {
134 has_atx_heading = true;
135 }
136 if !has_setext_heading && (trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.len() > 1) {
137 has_setext_heading = true;
138 }
139
140 if !chars.has_lists
143 && (line.contains("* ")
144 || line.contains("- ")
145 || line.contains("+ ")
146 || trimmed.starts_with("* ")
147 || trimmed.starts_with("- ")
148 || trimmed.starts_with("+ ")
149 || trimmed.starts_with('*')
150 || trimmed.starts_with('-')
151 || trimmed.starts_with('+'))
152 {
153 chars.has_lists = true;
154 }
155 if !chars.has_lists
157 && ((line.chars().next().is_some_and(|c| c.is_ascii_digit())
158 && (line.contains(". ") || line.contains('.')))
159 || (trimmed.starts_with('>')
160 && trimmed.chars().any(|c| c.is_ascii_digit())
161 && (trimmed.contains(". ") || trimmed.contains('.'))))
162 {
163 chars.has_lists = true;
164 }
165 if !chars.has_links
166 && (line.contains('[')
167 || line.contains("http://")
168 || line.contains("https://")
169 || line.contains("ftp://")
170 || line.contains("www."))
171 {
172 chars.has_links = true;
173 }
174 if !chars.has_images && line.contains("![") {
175 chars.has_images = true;
176 }
177 if !chars.has_code
178 && (line.contains('`') || line.contains("~~~") || has_potential_indented_code_indent(line))
179 {
180 chars.has_code = true;
181 }
182 if !chars.has_emphasis && (line.contains('*') || line.contains('_')) {
183 chars.has_emphasis = true;
184 }
185 if !chars.has_html && line.contains('<') {
186 chars.has_html = true;
187 }
188 if !chars.has_tables && line.contains('|') {
189 chars.has_tables = true;
190 }
191 if !chars.has_blockquotes && line.starts_with('>') {
192 chars.has_blockquotes = true;
193 }
194 }
195
196 chars.has_headings = has_atx_heading || has_setext_heading;
197 chars
198 }
199
200 fn should_skip_rule(&self, rule: &dyn Rule) -> bool {
202 match rule.category() {
203 RuleCategory::Heading => !self.has_headings,
204 RuleCategory::List => !self.has_lists,
205 RuleCategory::Link => !self.has_links && !self.has_images,
206 RuleCategory::Image => !self.has_images,
207 RuleCategory::CodeBlock => !self.has_code,
208 RuleCategory::Html => !self.has_html,
209 RuleCategory::Emphasis => !self.has_emphasis,
210 RuleCategory::Blockquote => !self.has_blockquotes,
211 RuleCategory::Table => !self.has_tables,
212 RuleCategory::Whitespace | RuleCategory::FrontMatter | RuleCategory::Other => false,
214 }
215 }
216}
217
218#[cfg(feature = "native")]
223fn compute_content_hash(content: &str) -> String {
224 #[cfg(feature = "profiling")]
225 let start = std::time::Instant::now();
226 let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
227 #[cfg(feature = "profiling")]
228 profiling::record_duration("index: hash content", start.elapsed());
229 hash
230}
231
232#[cfg(not(feature = "native"))]
234fn compute_content_hash(content: &str) -> String {
235 use std::hash::{DefaultHasher, Hash, Hasher};
236 let mut hasher = DefaultHasher::new();
237 content.hash(&mut hasher);
238 format!("{:016x}", hasher.finish())
239}
240
241pub fn lint(
245 content: &str,
246 rules: &[Box<dyn Rule>],
247 verbose: bool,
248 flavor: crate::config::MarkdownFlavor,
249 source_file: Option<std::path::PathBuf>,
250 config: Option<&crate::config::Config>,
251) -> LintResult {
252 let (result, _file_index) = lint_and_index(content, rules, verbose, flavor, source_file, config);
253 result
254}
255
256pub fn build_file_index_only(
264 content: &str,
265 rules: &[Box<dyn Rule>],
266 flavor: crate::config::MarkdownFlavor,
267 source_file: Option<std::path::PathBuf>,
268) -> crate::workspace_index::FileIndex {
269 let content_hash = compute_content_hash(content);
271 let mut file_index = crate::workspace_index::FileIndex::with_hash(content_hash);
272
273 if content.is_empty() {
275 return file_index;
276 }
277
278 let lint_ctx = time_function!(
280 "index: parse lint context",
281 crate::lint_context::LintContext::new(content, flavor, source_file)
282 );
283
284 let (file_disabled, persistent_transitions, line_disabled) = lint_ctx.inline_config().export_for_file_index();
288 file_index.file_disabled_rules = file_disabled;
289 file_index.persistent_transitions = persistent_transitions;
290 file_index.line_disabled_rules = line_disabled;
291
292 time_section!("index: contribute cross-file data", {
294 for rule in rules {
295 if rule.cross_file_scope() == crate::rule::CrossFileScope::Workspace {
296 rule.contribute_to_index(&lint_ctx, &mut file_index);
297 }
298 }
299 });
300
301 file_index
302}
303
304fn retain_reportable_warnings(
312 lint_ctx: &crate::lint_context::LintContext,
313 config: Option<&crate::config::Config>,
314 rule_name: &str,
315 rule_warnings: Vec<crate::rule::LintWarning>,
316 mut suppressed: Option<&mut Vec<crate::rule::SuppressedWarning>>,
317) -> Vec<crate::rule::LintWarning> {
318 let inline_config = lint_ctx.inline_config();
319 let mut kept = Vec::with_capacity(rule_warnings.len());
320
321 for mut warning in rule_warnings {
322 if lint_ctx
323 .line_info(warning.line)
324 .is_some_and(|info| info.in_kramdown_extension_block)
325 {
326 continue;
327 }
328
329 let rule_name_to_check = warning.rule_name.as_deref().unwrap_or(rule_name);
331
332 let base_rule_name = if let Some(dash_pos) = rule_name_to_check.find('-') {
334 &rule_name_to_check[..dash_pos]
335 } else {
336 rule_name_to_check
337 };
338
339 let end = if warning.end_line >= warning.line {
345 warning.end_line
346 } else {
347 warning.line
348 };
349 let disabled_at = (warning.line..=end).find_map(|line| {
350 inline_config
351 .disabling_layer(base_rule_name, line)
352 .map(|layer| (line, layer))
353 });
354 if let Some((line, layer)) = disabled_at {
355 if let Some(record) = suppressed.as_deref_mut() {
356 record.push(crate::rule::SuppressedWarning {
357 rule_name: base_rule_name.to_string(),
358 line,
359 layer,
360 });
361 }
362 continue;
363 }
364
365 if let Some(cfg) = config
367 && let Some(override_severity) = cfg.get_rule_severity(rule_name_to_check)
368 {
369 warning.severity = override_severity;
370 }
371
372 kept.push(warning);
373 }
374
375 kept
376}
377
378#[cfg_attr(test, allow(unused_variables))]
386pub fn lint_and_index(
387 content: &str,
388 rules: &[Box<dyn Rule>],
389 verbose: bool,
390 flavor: crate::config::MarkdownFlavor,
391 source_file: Option<std::path::PathBuf>,
392 config: Option<&crate::config::Config>,
393) -> (LintResult, crate::workspace_index::FileIndex) {
394 let mut warnings = Vec::new();
395 let content_hash = compute_content_hash(content);
397 let mut file_index = crate::workspace_index::FileIndex::with_hash(content_hash);
398
399 if content.is_empty() {
401 return (Ok(warnings), file_index);
402 }
403
404 let ignored_for_file = match (config, source_file.as_deref()) {
411 (Some(cfg), Some(path)) => cfg.get_ignored_rules_for_file(path),
412 _ => std::collections::HashSet::new(),
413 };
414
415 let lint_ctx = time_function!(
417 "lint: parse lint context",
418 crate::lint_context::LintContext::new(content, flavor, source_file)
419 );
420 let inline_config = lint_ctx.inline_config();
421
422 let (file_disabled, persistent_transitions, line_disabled) = inline_config.export_for_file_index();
424 file_index.file_disabled_rules = file_disabled;
425 file_index.persistent_transitions = persistent_transitions;
426 file_index.line_disabled_rules = line_disabled;
427
428 let characteristics = time_function!(
430 "lint: analyze content characteristics",
431 ContentCharacteristics::analyze(content)
432 );
433
434 let applicable_rules: Vec<_> = rules
436 .iter()
437 .filter(|rule| !ignored_for_file.contains(rule.name()))
438 .filter(|rule| !(rule.skippable_by_category() && characteristics.should_skip_rule(rule.as_ref())))
439 .collect();
440
441 #[cfg(not(test))]
443 let total_rules = rules.len();
444 #[cfg(not(test))]
445 let applicable_count = applicable_rules.len();
446
447 #[cfg(not(target_arch = "wasm32"))]
448 let profile_rules = std::env::var("RUMDL_PROFILE_RULES").is_ok();
449
450 let inline_overrides = inline_config.get_all_rule_configs();
453 let merged_config = if !inline_overrides.is_empty() {
454 config.map(|c| c.merge_with_inline_config(inline_config))
455 } else {
456 None
457 };
458 let effective_config = merged_config.as_ref().or(config);
459
460 let mut recreated_rules: std::collections::HashMap<String, Box<dyn crate::rule::Rule>> =
462 std::collections::HashMap::new();
463
464 if let Some(cfg) = effective_config {
466 for rule_name in inline_overrides.keys() {
467 if let Some(recreated) = crate::rules::create_rule_by_name(rule_name, cfg) {
468 recreated_rules.insert(rule_name.clone(), recreated);
469 }
470 }
471 }
472
473 let suppression_observers: Vec<_> = applicable_rules
477 .iter()
478 .filter(|rule| rule.observes_suppressions() && !rule.should_skip(&lint_ctx))
479 .collect();
480 let mut suppressed = Vec::new();
481
482 {
483 let _timer = profiling::ScopedTimer::new("lint: run single-file rules");
484 for rule in &applicable_rules {
485 #[cfg(not(target_arch = "wasm32"))]
486 let rule_start = Instant::now();
487
488 if rule.should_skip(&lint_ctx) {
490 continue;
491 }
492
493 let effective_rule: &dyn crate::rule::Rule = recreated_rules
495 .get(rule.name())
496 .map_or(rule.as_ref(), std::convert::AsRef::as_ref);
497
498 let result = effective_rule.check(&lint_ctx);
500
501 match result {
502 Ok(rule_warnings) => {
503 let record = if suppression_observers.is_empty() {
504 None
505 } else {
506 Some(&mut suppressed)
507 };
508 let filtered_warnings =
509 retain_reportable_warnings(&lint_ctx, config, rule.name(), rule_warnings, record);
510 warnings.extend(filtered_warnings);
511 }
512 Err(e) => {
513 log::error!("Error checking rule {}: {}", rule.name(), e);
514 return (Err(e), file_index);
515 }
516 }
517
518 #[cfg(not(target_arch = "wasm32"))]
519 {
520 let rule_duration = rule_start.elapsed();
521 if profile_rules {
522 eprintln!("[RULE] {:6} {:?}", rule.name(), rule_duration);
523 }
524
525 #[cfg(not(test))]
526 if verbose && rule_duration.as_millis() > 500 {
527 log::debug!("Rule {} took {:?}", rule.name(), rule_duration);
528 }
529 }
530 }
531 }
532
533 if !suppression_observers.is_empty() {
536 let _timer = profiling::ScopedTimer::new("lint: run suppression rules");
537
538 let report = crate::rule::SuppressionReport {
543 suppressed,
544 judged_rules: rules
545 .iter()
546 .filter(|rule| rule.cross_file_scope() != crate::rule::CrossFileScope::Workspace)
547 .filter(|rule| !ignored_for_file.contains(rule.name()))
548 .map(|rule| rule.name().to_string())
549 .collect(),
550 };
551
552 for rule in &suppression_observers {
553 match rule.check_suppressions(&lint_ctx, &report) {
554 Ok(rule_warnings) => {
555 let filtered_warnings =
556 retain_reportable_warnings(&lint_ctx, config, rule.name(), rule_warnings, None);
557 warnings.extend(filtered_warnings);
558 }
559 Err(e) => {
560 log::error!("Error checking rule {}: {}", rule.name(), e);
561 return (Err(e), file_index);
562 }
563 }
564 }
565 }
566
567 time_section!("lint: contribute cross-file data", {
575 for rule in rules {
576 if rule.cross_file_scope() == crate::rule::CrossFileScope::Workspace {
577 rule.contribute_to_index(&lint_ctx, &mut file_index);
578 }
579 }
580 });
581
582 #[cfg(not(test))]
583 if verbose {
584 let skipped_rules = total_rules - applicable_count;
585 if skipped_rules > 0 {
586 log::debug!("Skipped {skipped_rules} of {total_rules} rules based on content analysis");
587 }
588 }
589
590 (Ok(warnings), file_index)
591}
592
593pub fn run_cross_file_checks(
606 file_path: &std::path::Path,
607 file_index: &crate::workspace_index::FileIndex,
608 rules: &[Box<dyn Rule>],
609 workspace_index: &crate::workspace_index::WorkspaceIndex,
610 config: Option<&crate::config::Config>,
611) -> LintResult {
612 use crate::rule::CrossFileScope;
613
614 let mut warnings = Vec::new();
615
616 let ignored_rules_for_file = config.map(|cfg| cfg.get_ignored_rules_for_file(file_path));
622
623 for rule in rules {
625 if rule.cross_file_scope() != CrossFileScope::Workspace {
626 continue;
627 }
628
629 if ignored_rules_for_file
630 .as_ref()
631 .is_some_and(|ignored| ignored.contains(rule.name()))
632 {
633 continue;
634 }
635
636 match time_function!(
637 "workspace: cross-file rule check",
638 rule.cross_file_check(file_path, file_index, workspace_index)
639 ) {
640 Ok(rule_warnings) => {
641 let filtered: Vec<_> = rule_warnings
643 .into_iter()
644 .filter(|w| !file_index.is_rule_disabled_at_line(rule.name(), w.line))
645 .map(|mut warning| {
646 if let Some(cfg) = config
648 && let Some(override_severity) = cfg.get_rule_severity(rule.name())
649 {
650 warning.severity = override_severity;
651 }
652 warning
653 })
654 .collect();
655 warnings.extend(filtered);
656 }
657 Err(e) => {
658 log::error!("Error in cross-file check for rule {}: {}", rule.name(), e);
659 return Err(e);
660 }
661 }
662 }
663
664 Ok(warnings)
665}
666
667pub fn get_profiling_report() -> String {
669 profiling::get_report()
670}
671
672pub fn reset_profiling() {
674 profiling::reset()
675}
676
677pub fn get_regex_cache_stats() -> std::collections::HashMap<String, u64> {
679 crate::utils::regex_cache::get_cache_stats()
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use crate::rule::Rule;
686 use crate::rules::{MD001HeadingIncrement, MD009TrailingSpaces};
687
688 #[test]
689 fn test_content_characteristics_analyze() {
690 let chars = ContentCharacteristics::analyze("");
692 assert!(!chars.has_headings);
693 assert!(!chars.has_lists);
694 assert!(!chars.has_links);
695 assert!(!chars.has_code);
696 assert!(!chars.has_emphasis);
697 assert!(!chars.has_html);
698 assert!(!chars.has_tables);
699 assert!(!chars.has_blockquotes);
700 assert!(!chars.has_images);
701
702 let chars = ContentCharacteristics::analyze("# Heading");
704 assert!(chars.has_headings);
705
706 let chars = ContentCharacteristics::analyze("Heading\n=======");
708 assert!(chars.has_headings);
709
710 let chars = ContentCharacteristics::analyze("> ## Alpha\n>\n> ## Alpha");
713 assert!(chars.has_headings, "blockquoted ATX heading must set has_headings");
714 let chars = ContentCharacteristics::analyze(">> # Nested");
715 assert!(
716 chars.has_headings,
717 "nested-blockquote ATX heading must set has_headings"
718 );
719 let chars = ContentCharacteristics::analyze(">\t## Tabbed");
722 assert!(
723 chars.has_headings,
724 "tab-separated blockquote ATX heading must set has_headings"
725 );
726
727 let chars = ContentCharacteristics::analyze("* Item\n- Item 2\n+ Item 3");
729 assert!(chars.has_lists);
730
731 let chars = ContentCharacteristics::analyze("1. First\n2. Second");
733 assert!(chars.has_lists);
734
735 let chars = ContentCharacteristics::analyze("[link](url)");
737 assert!(chars.has_links);
738
739 let chars = ContentCharacteristics::analyze("Visit https://example.com");
741 assert!(chars.has_links);
742
743 let chars = ContentCharacteristics::analyze("");
745 assert!(chars.has_images);
746
747 let chars = ContentCharacteristics::analyze("`inline code`");
749 assert!(chars.has_code);
750
751 let chars = ContentCharacteristics::analyze("~~~\ncode block\n~~~");
752 assert!(chars.has_code);
753
754 let chars = ContentCharacteristics::analyze("Text\n\n indented code\n\nMore text");
756 assert!(chars.has_code);
757
758 let chars = ContentCharacteristics::analyze("Text\n\n\ttab indented code\n\nMore text");
760 assert!(chars.has_code);
761
762 let chars = ContentCharacteristics::analyze("Text\n\n \tmixed indent code\n\nMore text");
764 assert!(chars.has_code);
765
766 let chars = ContentCharacteristics::analyze("Text\n\n \ttab after space\n\nMore text");
768 assert!(chars.has_code);
769
770 let chars = ContentCharacteristics::analyze("*emphasis* and _more_");
772 assert!(chars.has_emphasis);
773
774 let chars = ContentCharacteristics::analyze("<div>HTML content</div>");
776 assert!(chars.has_html);
777
778 let chars = ContentCharacteristics::analyze("| Header | Header |\n|--------|--------|");
780 assert!(chars.has_tables);
781
782 let chars = ContentCharacteristics::analyze("> Quote");
784 assert!(chars.has_blockquotes);
785
786 let content = "# Heading\n* List item\n[link](url)\n`code`\n*emphasis*\n<p>html</p>\n| table |\n> quote\n";
788 let chars = ContentCharacteristics::analyze(content);
789 assert!(chars.has_headings);
790 assert!(chars.has_lists);
791 assert!(chars.has_links);
792 assert!(chars.has_code);
793 assert!(chars.has_emphasis);
794 assert!(chars.has_html);
795 assert!(chars.has_tables);
796 assert!(chars.has_blockquotes);
797 assert!(chars.has_images);
798 }
799
800 #[test]
801 fn test_content_characteristics_should_skip_rule() {
802 let chars = ContentCharacteristics {
803 has_headings: true,
804 has_lists: false,
805 has_links: true,
806 has_code: false,
807 has_emphasis: true,
808 has_html: false,
809 has_tables: true,
810 has_blockquotes: false,
811 has_images: false,
812 };
813
814 let heading_rule = MD001HeadingIncrement::default();
816 assert!(!chars.should_skip_rule(&heading_rule));
817
818 let trailing_spaces_rule = MD009TrailingSpaces::new(2, false);
819 assert!(!chars.should_skip_rule(&trailing_spaces_rule)); let chars_no_headings = ContentCharacteristics {
823 has_headings: false,
824 ..Default::default()
825 };
826 assert!(chars_no_headings.should_skip_rule(&heading_rule));
827 }
828
829 #[test]
830 fn test_lint_empty_content() {
831 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement::default())];
832
833 let result = lint("", &rules, false, crate::config::MarkdownFlavor::Standard, None, None);
834 assert!(result.is_ok());
835 assert!(result.unwrap().is_empty());
836 }
837
838 #[test]
839 fn test_lint_with_violations() {
840 let content = "## Level 2\n#### Level 4"; let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement::default())];
842
843 let result = lint(
844 content,
845 &rules,
846 false,
847 crate::config::MarkdownFlavor::Standard,
848 None,
849 None,
850 );
851 assert!(result.is_ok());
852 let warnings = result.unwrap();
853 assert!(!warnings.is_empty());
854 assert_eq!(warnings[0].rule_name.as_deref(), Some("MD001"));
856 }
857
858 #[test]
859 fn test_lint_with_inline_disable() {
860 let content = "<!-- rumdl-disable MD001 -->\n## Level 2\n#### Level 4";
861 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement::default())];
862
863 let result = lint(
864 content,
865 &rules,
866 false,
867 crate::config::MarkdownFlavor::Standard,
868 None,
869 None,
870 );
871 assert!(result.is_ok());
872 let warnings = result.unwrap();
873 assert!(warnings.is_empty()); }
875
876 #[test]
877 fn test_lint_rule_filtering() {
878 let content = "# Heading\nJust text";
880 let rules: Vec<Box<dyn Rule>> = vec![
881 Box::new(MD001HeadingIncrement::default()),
882 ];
884
885 let result = lint(
886 content,
887 &rules,
888 false,
889 crate::config::MarkdownFlavor::Standard,
890 None,
891 None,
892 );
893 assert!(result.is_ok());
894 }
895
896 #[test]
897 fn test_get_profiling_report() {
898 let report = get_profiling_report();
900 assert!(!report.is_empty());
901 assert!(report.contains("Profiling"));
902 }
903
904 #[test]
905 fn test_reset_profiling() {
906 reset_profiling();
908
909 let report = get_profiling_report();
911 assert!(report.contains("disabled") || report.contains("no measurements"));
912 }
913
914 #[test]
915 fn test_get_regex_cache_stats() {
916 let stats = get_regex_cache_stats();
917 assert!(stats.is_empty() || !stats.is_empty());
919
920 for count in stats.values() {
922 assert!(*count > 0);
923 }
924 }
925
926 #[test]
927 fn test_content_characteristics_edge_cases() {
928 let chars = ContentCharacteristics::analyze("-"); assert!(!chars.has_headings);
931
932 let chars = ContentCharacteristics::analyze("--"); assert!(chars.has_headings);
934
935 let chars = ContentCharacteristics::analyze("*emphasis*"); assert!(chars.has_lists); let chars = ContentCharacteristics::analyze("1.Item"); assert!(chars.has_lists); let chars = ContentCharacteristics::analyze("text > not a quote");
945 assert!(!chars.has_blockquotes);
946 }
947}