Skip to main content

rumdl_lib/
lib.rs

1#![warn(unreachable_pub)]
2#![warn(clippy::pedantic)]
3// Style-only pedantic lints we don't enforce. Each one generated >5 occurrences
4// that were either deliberate design choices or too noisy for the value
5// delivered. Categories that flag potential bugs stay on.
6#![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// Test smoke-constructors like `let _formatter = Foo;` fire this lint,
42// but are acceptable: they verify the type exists without asserting behavior.
43#![allow(clippy::no_effect_underscore_binding)]
44// Style-only: `Default::default()` vs `T::default()`. Both are readable.
45#![allow(clippy::default_trait_access)]
46// Style-only: `"".to_string()` vs `String::new()`. Tests favor the former
47// for symmetry with non-empty string literals.
48#![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 = "native")]
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// Native-only modules (require tokio, tower-lsp, etc.)
76#[cfg(feature = "native")]
77pub mod lsp;
78#[cfg(feature = "native")]
79pub mod output;
80#[cfg(feature = "native")]
81pub mod parallel;
82#[cfg(feature = "native")]
83pub mod performance;
84
85// WASM module
86#[cfg(feature = "wasm")]
87pub mod wasm;
88
89pub use rules::heading_utils::HeadingStyle;
90pub use rules::*;
91
92pub use crate::lint_context::{LineInfo, LintContext, ListItemInfo};
93use crate::rule::{LintResult, Rule, RuleCategory};
94use crate::utils::calculate_indentation_width_default;
95#[cfg(not(target_arch = "wasm32"))]
96use std::time::Instant;
97
98/// Content characteristics for efficient rule filtering
99#[derive(Debug, Default)]
100struct ContentCharacteristics {
101    has_headings: bool,    // # or setext headings
102    has_lists: bool,       // *, -, +, 1. etc
103    has_links: bool,       // [text](url) or [text][ref]
104    has_code: bool,        // ``` or ~~~ or indented code
105    has_emphasis: bool,    // * or _ for emphasis
106    has_html: bool,        // < > tags
107    has_tables: bool,      // | pipes
108    has_blockquotes: bool, // > markers
109    has_images: bool,      // ![alt](url)
110}
111
112/// Check if a line has enough leading whitespace to be an indented code block.
113/// Indented code blocks require 4+ columns of leading whitespace (with proper tab expansion).
114fn has_potential_indented_code_indent(line: &str) -> bool {
115    calculate_indentation_width_default(line) >= 4
116}
117
118impl ContentCharacteristics {
119    fn analyze(content: &str) -> Self {
120        let mut chars = Self { ..Default::default() };
121
122        // Quick single-pass analysis
123        let mut has_atx_heading = false;
124        let mut has_setext_heading = false;
125
126        for line in content.lines() {
127            let trimmed = line.trim();
128
129            // Headings: ATX (#) or Setext (underlines). A blockquoted ATX
130            // heading (`> ## Title`) still emits a fragment anchor, so rules
131            // like MD051/MD080 must run for blockquote-only documents too.
132            // Stripping `>`/space/tab is a coarse, deliberately
133            // over-inclusive prefilter check (it must never skip a rule that
134            // has work; `parse_blockquote_prefix` also accepts a tab marker).
135            if !has_atx_heading
136                && (trimmed.starts_with('#') || trimmed.trim_start_matches(['>', ' ', '\t']).starts_with('#'))
137            {
138                has_atx_heading = true;
139            }
140            if !has_setext_heading && (trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.len() > 1) {
141                has_setext_heading = true;
142            }
143
144            // Quick character-based detection (more efficient than regex)
145            // Include patterns without spaces to enable user-intention detection (MD030)
146            if !chars.has_lists
147                && (line.contains("* ")
148                    || line.contains("- ")
149                    || line.contains("+ ")
150                    || trimmed.starts_with("* ")
151                    || trimmed.starts_with("- ")
152                    || trimmed.starts_with("+ ")
153                    || trimmed.starts_with('*')
154                    || trimmed.starts_with('-')
155                    || trimmed.starts_with('+'))
156            {
157                chars.has_lists = true;
158            }
159            // Ordered lists: line starts with digit, or blockquote line contains digit followed by period
160            if !chars.has_lists
161                && ((line.chars().next().is_some_and(|c| c.is_ascii_digit())
162                    && (line.contains(". ") || line.contains('.')))
163                    || (trimmed.starts_with('>')
164                        && trimmed.chars().any(|c| c.is_ascii_digit())
165                        && (trimmed.contains(". ") || trimmed.contains('.'))))
166            {
167                chars.has_lists = true;
168            }
169            if !chars.has_links
170                && (line.contains('[')
171                    || line.contains("http://")
172                    || line.contains("https://")
173                    || line.contains("ftp://")
174                    || line.contains("www."))
175            {
176                chars.has_links = true;
177            }
178            if !chars.has_images && line.contains("![") {
179                chars.has_images = true;
180            }
181            if !chars.has_code
182                && (line.contains('`') || line.contains("~~~") || has_potential_indented_code_indent(line))
183            {
184                chars.has_code = true;
185            }
186            if !chars.has_emphasis && (line.contains('*') || line.contains('_')) {
187                chars.has_emphasis = true;
188            }
189            if !chars.has_html && line.contains('<') {
190                chars.has_html = true;
191            }
192            if !chars.has_tables && line.contains('|') {
193                chars.has_tables = true;
194            }
195            if !chars.has_blockquotes && line.starts_with('>') {
196                chars.has_blockquotes = true;
197            }
198        }
199
200        chars.has_headings = has_atx_heading || has_setext_heading;
201        chars
202    }
203
204    /// Check if a rule should be skipped based on content characteristics
205    fn should_skip_rule(&self, rule: &dyn Rule) -> bool {
206        match rule.category() {
207            RuleCategory::Heading => !self.has_headings,
208            RuleCategory::List => !self.has_lists,
209            RuleCategory::Link => !self.has_links && !self.has_images,
210            RuleCategory::Image => !self.has_images,
211            RuleCategory::CodeBlock => !self.has_code,
212            RuleCategory::Html => !self.has_html,
213            RuleCategory::Emphasis => !self.has_emphasis,
214            RuleCategory::Blockquote => !self.has_blockquotes,
215            RuleCategory::Table => !self.has_tables,
216            // Always check these categories as they apply to all content
217            RuleCategory::Whitespace | RuleCategory::FrontMatter | RuleCategory::Other => false,
218        }
219    }
220}
221
222/// Compute content hash for incremental indexing change detection
223///
224/// Uses blake3 for native builds (fast, cryptographic-strength hash)
225/// Falls back to std::hash for WASM builds
226#[cfg(feature = "native")]
227fn compute_content_hash(content: &str) -> String {
228    #[cfg(feature = "profiling")]
229    let start = std::time::Instant::now();
230    let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
231    #[cfg(feature = "profiling")]
232    profiling::record_duration("index: hash content", start.elapsed());
233    hash
234}
235
236/// Compute content hash for WASM builds using std::hash
237#[cfg(not(feature = "native"))]
238fn compute_content_hash(content: &str) -> String {
239    use std::hash::{DefaultHasher, Hash, Hasher};
240    let mut hasher = DefaultHasher::new();
241    content.hash(&mut hasher);
242    format!("{:016x}", hasher.finish())
243}
244
245/// Lint a file against the given rules with intelligent rule filtering
246/// Assumes the provided `rules` vector contains the final,
247/// configured, and filtered set of rules to be executed.
248pub fn lint(
249    content: &str,
250    rules: &[Box<dyn Rule>],
251    verbose: bool,
252    flavor: crate::config::MarkdownFlavor,
253    source_file: Option<std::path::PathBuf>,
254    config: Option<&crate::config::Config>,
255) -> LintResult {
256    let (result, _file_index) = lint_and_index(content, rules, verbose, flavor, source_file, config);
257    result
258}
259
260/// Build FileIndex only (no linting) for cross-file analysis on cache hits
261///
262/// This is a lightweight function that only builds the FileIndex without running
263/// any rules. Used when we have a cache hit but still need the FileIndex for
264/// cross-file validation.
265///
266/// This avoids the overhead of re-running all rules when only the index data is needed.
267pub fn build_file_index_only(
268    content: &str,
269    rules: &[Box<dyn Rule>],
270    flavor: crate::config::MarkdownFlavor,
271    source_file: Option<std::path::PathBuf>,
272) -> crate::workspace_index::FileIndex {
273    // Compute content hash for change detection
274    let content_hash = compute_content_hash(content);
275    let mut file_index = crate::workspace_index::FileIndex::with_hash(content_hash);
276
277    // Early return for empty content
278    if content.is_empty() {
279        return file_index;
280    }
281
282    // Parse LintContext once with the provided flavor
283    let lint_ctx = time_function!(
284        "index: parse lint context",
285        crate::lint_context::LintContext::new(content, flavor, source_file)
286    );
287
288    // Export inline disable data to the FileIndex so cross-file checks honor
289    // `<!-- rumdl-disable -->` blocks on the lint-cache fast path, exactly as
290    // lint_and_index does on the normal path.
291    let (file_disabled, persistent_transitions, line_disabled) = lint_ctx.inline_config().export_for_file_index();
292    file_index.file_disabled_rules = file_disabled;
293    file_index.persistent_transitions = persistent_transitions;
294    file_index.line_disabled_rules = line_disabled;
295
296    // Only call contribute_to_index for cross-file rules (no rule checking!)
297    time_section!("index: contribute cross-file data", {
298        for rule in rules {
299            if rule.cross_file_scope() == crate::rule::CrossFileScope::Workspace {
300                rule.contribute_to_index(&lint_ctx, &mut file_index);
301            }
302        }
303    });
304
305    file_index
306}
307
308/// Lint a file and contribute to workspace index for cross-file analysis
309///
310/// This variant performs linting and optionally populates a `FileIndex` with data
311/// needed for cross-file validation. The FileIndex is populated during linting,
312/// avoiding duplicate parsing.
313///
314/// Returns: (warnings, FileIndex) - the FileIndex contains headings/links for cross-file rules
315#[cfg_attr(test, allow(unused_variables))]
316pub fn lint_and_index(
317    content: &str,
318    rules: &[Box<dyn Rule>],
319    verbose: bool,
320    flavor: crate::config::MarkdownFlavor,
321    source_file: Option<std::path::PathBuf>,
322    config: Option<&crate::config::Config>,
323) -> (LintResult, crate::workspace_index::FileIndex) {
324    let mut warnings = Vec::new();
325    // Compute content hash for change detection
326    let content_hash = compute_content_hash(content);
327    let mut file_index = crate::workspace_index::FileIndex::with_hash(content_hash);
328
329    // Early return for empty content
330    if content.is_empty() {
331        return (Ok(warnings), file_index);
332    }
333
334    // Parse LintContext once (includes inline config parsing)
335    let lint_ctx = time_function!(
336        "lint: parse lint context",
337        crate::lint_context::LintContext::new(content, flavor, source_file)
338    );
339    let inline_config = lint_ctx.inline_config();
340
341    // Export inline config data to FileIndex for cross-file rule filtering
342    let (file_disabled, persistent_transitions, line_disabled) = inline_config.export_for_file_index();
343    file_index.file_disabled_rules = file_disabled;
344    file_index.persistent_transitions = persistent_transitions;
345    file_index.line_disabled_rules = line_disabled;
346
347    // Analyze content characteristics for rule filtering
348    let characteristics = time_function!(
349        "lint: analyze content characteristics",
350        ContentCharacteristics::analyze(content)
351    );
352
353    // Filter rules based on content characteristics
354    let applicable_rules: Vec<_> = rules
355        .iter()
356        .filter(|rule| !characteristics.should_skip_rule(rule.as_ref()))
357        .collect();
358
359    // Calculate skipped rules count before consuming applicable_rules
360    #[cfg(not(test))]
361    let total_rules = rules.len();
362    #[cfg(not(test))]
363    let applicable_count = applicable_rules.len();
364
365    #[cfg(not(target_arch = "wasm32"))]
366    let profile_rules = std::env::var("RUMDL_PROFILE_RULES").is_ok();
367
368    // Automatic inline config support: merge inline overrides into config once,
369    // then recreate only the affected rules. Works for ALL rules without per-rule changes.
370    let inline_overrides = inline_config.get_all_rule_configs();
371    let merged_config = if !inline_overrides.is_empty() {
372        config.map(|c| c.merge_with_inline_config(inline_config))
373    } else {
374        None
375    };
376    let effective_config = merged_config.as_ref().or(config);
377
378    // Cache recreated rules for rules with inline overrides
379    let mut recreated_rules: std::collections::HashMap<String, Box<dyn crate::rule::Rule>> =
380        std::collections::HashMap::new();
381
382    // Pre-create rules that have inline config overrides
383    if let Some(cfg) = effective_config {
384        for rule_name in inline_overrides.keys() {
385            if let Some(recreated) = crate::rules::create_rule_by_name(rule_name, cfg) {
386                recreated_rules.insert(rule_name.clone(), recreated);
387            }
388        }
389    }
390
391    {
392        let _timer = profiling::ScopedTimer::new("lint: run single-file rules");
393        for rule in &applicable_rules {
394            #[cfg(not(target_arch = "wasm32"))]
395            let rule_start = Instant::now();
396
397            // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
398            if rule.should_skip(&lint_ctx) {
399                continue;
400            }
401
402            // Use recreated rule if inline config overrides exist for this rule
403            let effective_rule: &dyn crate::rule::Rule = recreated_rules
404                .get(rule.name())
405                .map_or(rule.as_ref(), std::convert::AsRef::as_ref);
406
407            // Run single-file check with the effective rule (possibly with inline config applied)
408            let result = effective_rule.check(&lint_ctx);
409
410            match result {
411                Ok(rule_warnings) => {
412                    // Filter out warnings inside kramdown extension blocks (Layer 3 safety net)
413                    // and warnings for rules disabled via inline comments
414                    let filtered_warnings: Vec<_> = rule_warnings
415                        .into_iter()
416                        .filter(|warning| {
417                            // Layer 3: Suppress warnings inside kramdown extension blocks
418                            if lint_ctx
419                                .line_info(warning.line)
420                                .is_some_and(|info| info.in_kramdown_extension_block)
421                            {
422                                return false;
423                            }
424
425                            // Use the warning's rule_name if available, otherwise use the rule's name
426                            let rule_name_to_check = warning.rule_name.as_deref().unwrap_or(rule.name());
427
428                            // Extract the base rule name for sub-rules like "MD029-style" -> "MD029"
429                            let base_rule_name = if let Some(dash_pos) = rule_name_to_check.find('-') {
430                                &rule_name_to_check[..dash_pos]
431                            } else {
432                                rule_name_to_check
433                            };
434
435                            // Check if the rule is disabled at any line in the warning's range.
436                            // Multi-line warnings (e.g., reflow) report on the first line,
437                            // but inline disable comments may appear later in the range.
438                            // Guard: if end_line < line (e.g., end_line=0), fall back to
439                            // checking only the warning's line to match original behavior.
440                            {
441                                let end = if warning.end_line >= warning.line {
442                                    warning.end_line
443                                } else {
444                                    warning.line
445                                };
446                                !(warning.line..=end).any(|line| inline_config.is_rule_disabled(base_rule_name, line))
447                            }
448                        })
449                        .map(|mut warning| {
450                            // Apply severity override from config if present
451                            if let Some(cfg) = config {
452                                let rule_name_to_check = warning.rule_name.as_deref().unwrap_or(rule.name());
453                                if let Some(override_severity) = cfg.get_rule_severity(rule_name_to_check) {
454                                    warning.severity = override_severity;
455                                }
456                            }
457                            warning
458                        })
459                        .collect();
460                    warnings.extend(filtered_warnings);
461                }
462                Err(e) => {
463                    log::error!("Error checking rule {}: {}", rule.name(), e);
464                    return (Err(e), file_index);
465                }
466            }
467
468            #[cfg(not(target_arch = "wasm32"))]
469            {
470                let rule_duration = rule_start.elapsed();
471                if profile_rules {
472                    eprintln!("[RULE] {:6} {:?}", rule.name(), rule_duration);
473                }
474
475                #[cfg(not(test))]
476                if verbose && rule_duration.as_millis() > 500 {
477                    log::debug!("Rule {} took {:?}", rule.name(), rule_duration);
478                }
479            }
480        }
481    }
482
483    // Contribute to index for cross-file rules (done after all rules checked)
484    // NOTE: We iterate over ALL rules (not just applicable_rules) because cross-file
485    // rules need to extract data from every file in the workspace, regardless of whether
486    // that file has content that would trigger the rule. For example, MD051 needs to
487    // index headings from files that have no links (like target.md) so that links
488    // FROM other files TO those headings can be validated.
489    time_section!("lint: contribute cross-file data", {
490        for rule in rules {
491            if rule.cross_file_scope() == crate::rule::CrossFileScope::Workspace {
492                rule.contribute_to_index(&lint_ctx, &mut file_index);
493            }
494        }
495    });
496
497    #[cfg(not(test))]
498    if verbose {
499        let skipped_rules = total_rules - applicable_count;
500        if skipped_rules > 0 {
501            log::debug!("Skipped {skipped_rules} of {total_rules} rules based on content analysis");
502        }
503    }
504
505    (Ok(warnings), file_index)
506}
507
508/// Run cross-file checks for rules that need workspace-wide validation
509///
510/// This should be called after all files have been linted and the WorkspaceIndex
511/// has been built from the accumulated FileIndex data.
512///
513/// Note: This takes the FileIndex instead of content to avoid re-parsing each file.
514/// The FileIndex was already populated during contribute_to_index in the linting phase.
515///
516/// Rules can use workspace_index methods for cross-file validation:
517/// - `get_file(path)` - to look up headings in target files (for MD051)
518///
519/// Returns additional warnings from cross-file validation.
520pub fn run_cross_file_checks(
521    file_path: &std::path::Path,
522    file_index: &crate::workspace_index::FileIndex,
523    rules: &[Box<dyn Rule>],
524    workspace_index: &crate::workspace_index::WorkspaceIndex,
525    config: Option<&crate::config::Config>,
526) -> LintResult {
527    use crate::rule::CrossFileScope;
528
529    let mut warnings = Vec::new();
530
531    // Honor `per-file-ignores` for cross-file rules. Cross-file warnings are
532    // attributed to `file_path` (the file holding the link), so a rule ignored
533    // for that file must not emit them. This applies on every path; single-file
534    // rule filtering does not cover cross-file checks because they run over the
535    // config group's full rule set, and cross-file rules share link data.
536    let ignored_rules_for_file = config.map(|cfg| cfg.get_ignored_rules_for_file(file_path));
537
538    // Only check rules that need cross-file analysis
539    for rule in rules {
540        if rule.cross_file_scope() != CrossFileScope::Workspace {
541            continue;
542        }
543
544        if ignored_rules_for_file
545            .as_ref()
546            .is_some_and(|ignored| ignored.contains(rule.name()))
547        {
548            continue;
549        }
550
551        match time_function!(
552            "workspace: cross-file rule check",
553            rule.cross_file_check(file_path, file_index, workspace_index)
554        ) {
555            Ok(rule_warnings) => {
556                // Filter cross-file warnings based on inline config stored in file_index
557                let filtered: Vec<_> = rule_warnings
558                    .into_iter()
559                    .filter(|w| !file_index.is_rule_disabled_at_line(rule.name(), w.line))
560                    .map(|mut warning| {
561                        // Apply severity override from config if present
562                        if let Some(cfg) = config
563                            && let Some(override_severity) = cfg.get_rule_severity(rule.name())
564                        {
565                            warning.severity = override_severity;
566                        }
567                        warning
568                    })
569                    .collect();
570                warnings.extend(filtered);
571            }
572            Err(e) => {
573                log::error!("Error in cross-file check for rule {}: {}", rule.name(), e);
574                return Err(e);
575            }
576        }
577    }
578
579    Ok(warnings)
580}
581
582/// Get the profiling report
583pub fn get_profiling_report() -> String {
584    profiling::get_report()
585}
586
587/// Reset the profiling data
588pub fn reset_profiling() {
589    profiling::reset()
590}
591
592/// Get regex cache statistics for performance monitoring
593pub fn get_regex_cache_stats() -> std::collections::HashMap<String, u64> {
594    crate::utils::regex_cache::get_cache_stats()
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use crate::rule::Rule;
601    use crate::rules::{MD001HeadingIncrement, MD009TrailingSpaces};
602
603    #[test]
604    fn test_content_characteristics_analyze() {
605        // Test empty content
606        let chars = ContentCharacteristics::analyze("");
607        assert!(!chars.has_headings);
608        assert!(!chars.has_lists);
609        assert!(!chars.has_links);
610        assert!(!chars.has_code);
611        assert!(!chars.has_emphasis);
612        assert!(!chars.has_html);
613        assert!(!chars.has_tables);
614        assert!(!chars.has_blockquotes);
615        assert!(!chars.has_images);
616
617        // Test content with headings
618        let chars = ContentCharacteristics::analyze("# Heading");
619        assert!(chars.has_headings);
620
621        // Test setext headings
622        let chars = ContentCharacteristics::analyze("Heading\n=======");
623        assert!(chars.has_headings);
624
625        // Blockquoted ATX headings emit fragment anchors, so Heading-category
626        // rules (MD051/MD080) must run for blockquote-only documents.
627        let chars = ContentCharacteristics::analyze("> ## Alpha\n>\n> ## Alpha");
628        assert!(chars.has_headings, "blockquoted ATX heading must set has_headings");
629        let chars = ContentCharacteristics::analyze(">> # Nested");
630        assert!(
631            chars.has_headings,
632            "nested-blockquote ATX heading must set has_headings"
633        );
634        // A tab after the blockquote marker is also a valid heading
635        // (`parse_blockquote_prefix` accepts it).
636        let chars = ContentCharacteristics::analyze(">\t## Tabbed");
637        assert!(
638            chars.has_headings,
639            "tab-separated blockquote ATX heading must set has_headings"
640        );
641
642        // Test lists
643        let chars = ContentCharacteristics::analyze("* Item\n- Item 2\n+ Item 3");
644        assert!(chars.has_lists);
645
646        // Test ordered lists
647        let chars = ContentCharacteristics::analyze("1. First\n2. Second");
648        assert!(chars.has_lists);
649
650        // Test links
651        let chars = ContentCharacteristics::analyze("[link](url)");
652        assert!(chars.has_links);
653
654        // Test URLs
655        let chars = ContentCharacteristics::analyze("Visit https://example.com");
656        assert!(chars.has_links);
657
658        // Test images
659        let chars = ContentCharacteristics::analyze("![alt text](image.png)");
660        assert!(chars.has_images);
661
662        // Test code
663        let chars = ContentCharacteristics::analyze("`inline code`");
664        assert!(chars.has_code);
665
666        let chars = ContentCharacteristics::analyze("~~~\ncode block\n~~~");
667        assert!(chars.has_code);
668
669        // Test indented code blocks (4 spaces)
670        let chars = ContentCharacteristics::analyze("Text\n\n    indented code\n\nMore text");
671        assert!(chars.has_code);
672
673        // Test tab-indented code blocks
674        let chars = ContentCharacteristics::analyze("Text\n\n\ttab indented code\n\nMore text");
675        assert!(chars.has_code);
676
677        // Test mixed whitespace indented code (2 spaces + tab = 4 columns)
678        let chars = ContentCharacteristics::analyze("Text\n\n  \tmixed indent code\n\nMore text");
679        assert!(chars.has_code);
680
681        // Test 1 space + tab (also 4 columns due to tab expansion)
682        let chars = ContentCharacteristics::analyze("Text\n\n \ttab after space\n\nMore text");
683        assert!(chars.has_code);
684
685        // Test emphasis
686        let chars = ContentCharacteristics::analyze("*emphasis* and _more_");
687        assert!(chars.has_emphasis);
688
689        // Test HTML
690        let chars = ContentCharacteristics::analyze("<div>HTML content</div>");
691        assert!(chars.has_html);
692
693        // Test tables
694        let chars = ContentCharacteristics::analyze("| Header | Header |\n|--------|--------|");
695        assert!(chars.has_tables);
696
697        // Test blockquotes
698        let chars = ContentCharacteristics::analyze("> Quote");
699        assert!(chars.has_blockquotes);
700
701        // Test mixed content
702        let content = "# Heading\n* List item\n[link](url)\n`code`\n*emphasis*\n<p>html</p>\n| table |\n> quote\n![image](img.png)";
703        let chars = ContentCharacteristics::analyze(content);
704        assert!(chars.has_headings);
705        assert!(chars.has_lists);
706        assert!(chars.has_links);
707        assert!(chars.has_code);
708        assert!(chars.has_emphasis);
709        assert!(chars.has_html);
710        assert!(chars.has_tables);
711        assert!(chars.has_blockquotes);
712        assert!(chars.has_images);
713    }
714
715    #[test]
716    fn test_content_characteristics_should_skip_rule() {
717        let chars = ContentCharacteristics {
718            has_headings: true,
719            has_lists: false,
720            has_links: true,
721            has_code: false,
722            has_emphasis: true,
723            has_html: false,
724            has_tables: true,
725            has_blockquotes: false,
726            has_images: false,
727        };
728
729        // Create test rules for different categories
730        let heading_rule = MD001HeadingIncrement::default();
731        assert!(!chars.should_skip_rule(&heading_rule));
732
733        let trailing_spaces_rule = MD009TrailingSpaces::new(2, false);
734        assert!(!chars.should_skip_rule(&trailing_spaces_rule)); // Whitespace rules always run
735
736        // Test skipping based on content
737        let chars_no_headings = ContentCharacteristics {
738            has_headings: false,
739            ..Default::default()
740        };
741        assert!(chars_no_headings.should_skip_rule(&heading_rule));
742    }
743
744    #[test]
745    fn test_lint_empty_content() {
746        let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement::default())];
747
748        let result = lint("", &rules, false, crate::config::MarkdownFlavor::Standard, None, None);
749        assert!(result.is_ok());
750        assert!(result.unwrap().is_empty());
751    }
752
753    #[test]
754    fn test_lint_with_violations() {
755        let content = "## Level 2\n#### Level 4"; // Skips level 3
756        let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement::default())];
757
758        let result = lint(
759            content,
760            &rules,
761            false,
762            crate::config::MarkdownFlavor::Standard,
763            None,
764            None,
765        );
766        assert!(result.is_ok());
767        let warnings = result.unwrap();
768        assert!(!warnings.is_empty());
769        // Check the rule field of LintWarning struct
770        assert_eq!(warnings[0].rule_name.as_deref(), Some("MD001"));
771    }
772
773    #[test]
774    fn test_lint_with_inline_disable() {
775        let content = "<!-- rumdl-disable MD001 -->\n## Level 2\n#### Level 4";
776        let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement::default())];
777
778        let result = lint(
779            content,
780            &rules,
781            false,
782            crate::config::MarkdownFlavor::Standard,
783            None,
784            None,
785        );
786        assert!(result.is_ok());
787        let warnings = result.unwrap();
788        assert!(warnings.is_empty()); // Should be disabled by inline comment
789    }
790
791    #[test]
792    fn test_lint_rule_filtering() {
793        // Content with no lists
794        let content = "# Heading\nJust text";
795        let rules: Vec<Box<dyn Rule>> = vec![
796            Box::new(MD001HeadingIncrement::default()),
797            // A list-related rule would be skipped
798        ];
799
800        let result = lint(
801            content,
802            &rules,
803            false,
804            crate::config::MarkdownFlavor::Standard,
805            None,
806            None,
807        );
808        assert!(result.is_ok());
809    }
810
811    #[test]
812    fn test_get_profiling_report() {
813        // Just test that it returns a string without panicking
814        let report = get_profiling_report();
815        assert!(!report.is_empty());
816        assert!(report.contains("Profiling"));
817    }
818
819    #[test]
820    fn test_reset_profiling() {
821        // Test that reset_profiling doesn't panic
822        reset_profiling();
823
824        // After reset, report should indicate no measurements or profiling disabled
825        let report = get_profiling_report();
826        assert!(report.contains("disabled") || report.contains("no measurements"));
827    }
828
829    #[test]
830    fn test_get_regex_cache_stats() {
831        let stats = get_regex_cache_stats();
832        // Stats should be a valid HashMap (might be empty)
833        assert!(stats.is_empty() || !stats.is_empty());
834
835        // If not empty, all values should be positive
836        for count in stats.values() {
837            assert!(*count > 0);
838        }
839    }
840
841    #[test]
842    fn test_content_characteristics_edge_cases() {
843        // Test setext heading edge case
844        let chars = ContentCharacteristics::analyze("-"); // Single dash, not a heading
845        assert!(!chars.has_headings);
846
847        let chars = ContentCharacteristics::analyze("--"); // Two dashes, valid setext
848        assert!(chars.has_headings);
849
850        // Test list detection - we now include potential list patterns (with or without space)
851        // to support user-intention detection in MD030
852        let chars = ContentCharacteristics::analyze("*emphasis*"); // Could be list or emphasis
853        assert!(chars.has_lists); // Run list rules to be safe
854
855        let chars = ContentCharacteristics::analyze("1.Item"); // Could be list without space
856        assert!(chars.has_lists); // Run list rules for user-intention detection
857
858        // Test blockquote must be at start of line
859        let chars = ContentCharacteristics::analyze("text > not a quote");
860        assert!(!chars.has_blockquotes);
861    }
862}