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 document_run;
55pub mod embedded_lint;
56pub mod exit_codes;
57pub mod filtered_lines;
58pub mod fix_coordinator;
59pub mod inline_config;
60pub mod linguist_data;
61pub mod lint_context;
62pub mod markdownlint_config;
63pub mod profiling;
64pub mod rule;
65#[cfg(feature = "colored")]
66pub mod vscode;
67pub mod workspace_index;
68#[macro_use]
69pub mod rule_config;
70#[macro_use]
71pub mod rule_config_serde;
72pub mod rules;
73pub mod types;
74pub mod utils;
75
76// Native-only modules (require tokio, tower-lsp, etc.)
77#[cfg(feature = "native")]
78pub mod lsp;
79#[cfg(feature = "colored")]
80pub mod output;
81
82// WASM module
83#[cfg(feature = "wasm")]
84pub mod wasm;
85
86pub use rules::heading_utils::HeadingStyle;
87pub use rules::*;
88
89pub use crate::lint_context::{LineInfo, LintContext, ListItemInfo};
90use crate::rule::{LintResult, Rule, RuleCategory};
91use crate::utils::calculate_indentation_width_default;
92#[cfg(not(target_arch = "wasm32"))]
93use std::time::Instant;
94
95/// Content characteristics for efficient rule filtering
96#[derive(Debug, Default)]
97struct ContentCharacteristics {
98    has_headings: bool,    // # or setext headings
99    has_lists: bool,       // *, -, +, 1. etc
100    has_links: bool,       // [text](url) or [text][ref]
101    has_code: bool,        // ``` or ~~~ or indented code
102    has_emphasis: bool,    // * or _ for emphasis
103    has_html: bool,        // < > tags
104    has_tables: bool,      // | pipes
105    has_blockquotes: bool, // > markers
106    has_images: bool,      // ![alt](url)
107}
108
109/// Check if a line has enough leading whitespace to be an indented code block.
110/// Indented code blocks require 4+ columns of leading whitespace (with proper tab expansion).
111fn has_potential_indented_code_indent(line: &str) -> bool {
112    calculate_indentation_width_default(line) >= 4
113}
114
115impl ContentCharacteristics {
116    fn analyze(content: &str) -> Self {
117        let mut chars = Self { ..Default::default() };
118
119        // Quick single-pass analysis
120        let mut has_atx_heading = false;
121        let mut has_setext_heading = false;
122
123        for line in content.lines() {
124            let trimmed = line.trim();
125
126            // Headings: ATX (#) or Setext (underlines). A blockquoted ATX
127            // heading (`> ## Title`) still emits a fragment anchor, so rules
128            // like MD051/MD080 must run for blockquote-only documents too.
129            // Stripping `>`/space/tab is a coarse, deliberately
130            // over-inclusive prefilter check (it must never skip a rule that
131            // has work; `parse_blockquote_prefix` also accepts a tab marker).
132            if !has_atx_heading
133                && (trimmed.starts_with('#') || trimmed.trim_start_matches(['>', ' ', '\t']).starts_with('#'))
134            {
135                has_atx_heading = true;
136            }
137            if !has_setext_heading && (trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.len() > 1) {
138                has_setext_heading = true;
139            }
140
141            // Quick character-based detection (more efficient than regex)
142            // Include patterns without spaces to enable user-intention detection (MD030)
143            if !chars.has_lists
144                && (line.contains("* ")
145                    || line.contains("- ")
146                    || line.contains("+ ")
147                    || trimmed.starts_with("* ")
148                    || trimmed.starts_with("- ")
149                    || trimmed.starts_with("+ ")
150                    || trimmed.starts_with('*')
151                    || trimmed.starts_with('-')
152                    || trimmed.starts_with('+'))
153            {
154                chars.has_lists = true;
155            }
156            // Ordered lists: a line whose text starts with a digit (a marker may
157            // be indented), or a blockquote line holding one, and either marker
158            // delimiter (`.` or `)`) after it
159            if !chars.has_lists
160                && ((trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && trimmed.contains(['.', ')']))
161                    || (trimmed.starts_with('>')
162                        && trimmed.chars().any(|c| c.is_ascii_digit())
163                        && trimmed.contains(['.', ')'])))
164            {
165                chars.has_lists = true;
166            }
167            if !chars.has_links
168                && (line.contains('[')
169                    || line.contains("http://")
170                    || line.contains("https://")
171                    || line.contains("ftp://")
172                    || line.contains("www."))
173            {
174                chars.has_links = true;
175            }
176            if !chars.has_images && line.contains("![") {
177                chars.has_images = true;
178            }
179            if !chars.has_code
180                && (line.contains('`') || line.contains("~~~") || has_potential_indented_code_indent(line))
181            {
182                chars.has_code = true;
183            }
184            if !chars.has_emphasis && (line.contains('*') || line.contains('_')) {
185                chars.has_emphasis = true;
186            }
187            if !chars.has_html && line.contains('<') {
188                chars.has_html = true;
189            }
190            if !chars.has_tables && line.contains('|') {
191                chars.has_tables = true;
192            }
193            if !chars.has_blockquotes && line.starts_with('>') {
194                chars.has_blockquotes = true;
195            }
196        }
197
198        chars.has_headings = has_atx_heading || has_setext_heading;
199        chars
200    }
201
202    /// Check if a rule should be skipped based on content characteristics
203    fn should_skip_rule(&self, rule: &dyn Rule) -> bool {
204        match rule.category() {
205            RuleCategory::Heading => !self.has_headings,
206            RuleCategory::List => !self.has_lists,
207            RuleCategory::Link => !self.has_links && !self.has_images,
208            RuleCategory::Image => !self.has_images,
209            RuleCategory::CodeBlock => !self.has_code,
210            RuleCategory::Html => !self.has_html,
211            RuleCategory::Emphasis => !self.has_emphasis,
212            RuleCategory::Blockquote => !self.has_blockquotes,
213            RuleCategory::Table => !self.has_tables,
214            // Always check these categories as they apply to all content
215            RuleCategory::Whitespace | RuleCategory::FrontMatter | RuleCategory::Other => false,
216        }
217    }
218}
219
220/// Compute content hash for incremental indexing change detection
221///
222/// Uses blake3 for native builds (fast, cryptographic-strength hash)
223/// Falls back to std::hash for WASM builds
224#[cfg(feature = "native")]
225fn compute_content_hash(content: &str) -> String {
226    #[cfg(feature = "profiling")]
227    let start = std::time::Instant::now();
228    let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
229    #[cfg(feature = "profiling")]
230    profiling::record_duration("index: hash content", start.elapsed());
231    hash
232}
233
234/// Compute content hash for WASM builds using std::hash
235#[cfg(not(feature = "native"))]
236fn compute_content_hash(content: &str) -> String {
237    use std::hash::{DefaultHasher, Hash, Hasher};
238    let mut hasher = DefaultHasher::new();
239    content.hash(&mut hasher);
240    format!("{:016x}", hasher.finish())
241}
242
243/// Lint a file against the given rules with intelligent rule filtering
244/// Assumes the provided `rules` vector contains the final,
245/// configured, and filtered set of rules to be executed.
246pub fn lint(
247    content: &str,
248    rules: &[Box<dyn Rule>],
249    verbose: bool,
250    flavor: crate::config::MarkdownFlavor,
251    source_file: Option<std::path::PathBuf>,
252    config: Option<&crate::config::Config>,
253) -> LintResult {
254    let (result, _file_index) = lint_and_index(content, rules, verbose, flavor, source_file, config);
255    result
256}
257
258/// Build FileIndex only (no linting) for cross-file analysis on cache hits
259///
260/// This is a lightweight function that only builds the FileIndex without running
261/// any rules. Used when we have a cache hit but still need the FileIndex for
262/// cross-file validation.
263///
264/// This avoids the overhead of re-running all rules when only the index data is needed.
265pub fn build_file_index_only(
266    content: &str,
267    rules: &[Box<dyn Rule>],
268    flavor: crate::config::MarkdownFlavor,
269    source_file: Option<std::path::PathBuf>,
270) -> crate::workspace_index::FileIndex {
271    // Compute content hash for change detection
272    let content_hash = compute_content_hash(content);
273    let mut file_index = crate::workspace_index::FileIndex::with_hash(content_hash);
274
275    // Early return for empty content
276    if content.is_empty() {
277        return file_index;
278    }
279
280    // Parse LintContext once with the provided flavor
281    let lint_ctx = time_function!(
282        "index: parse lint context",
283        crate::lint_context::LintContext::new(content, flavor, source_file)
284    );
285
286    // Export inline disable data to the FileIndex so cross-file checks honor
287    // `<!-- rumdl-disable -->` blocks on the lint-cache fast path, exactly as
288    // lint_and_index does on the normal path.
289    let (file_disabled, persistent_transitions, line_disabled) = lint_ctx.inline_config().export_for_file_index();
290    file_index.file_disabled_rules = file_disabled;
291    file_index.persistent_transitions = persistent_transitions;
292    file_index.line_disabled_rules = line_disabled;
293
294    // Only call contribute_to_index for cross-file rules (no rule checking!)
295    time_section!("index: contribute cross-file data", {
296        for rule in rules {
297            if rule.cross_file_scope() == crate::rule::CrossFileScope::Workspace {
298                rule.contribute_to_index(&lint_ctx, &mut file_index);
299            }
300        }
301    });
302
303    file_index
304}
305
306/// Rewrite every fix replacement in the document's own line ending.
307///
308/// Rules build a fix on LF text, so a line ending they insert is `\n`, and the
309/// CLI really does hand them LF: it normalises a file on read and restores the
310/// ending on write. The LSP and wasm lint the editor's or host's text as it is,
311/// and a quick fix that inserted a bare `\n` into a CRLF document left it with
312/// mixed endings. Conforming here, where every rule's warnings meet, settles it
313/// for every caller and every rule at once. A document with mixed endings has no
314/// single convention to conform to and keeps the fix as the rule wrote it.
315fn conform_fix_line_endings(content: &str, warnings: &mut [crate::rule::LintWarning]) {
316    if !content.contains('\r') || crate::utils::detect_line_ending_enum(content) != crate::utils::LineEnding::Crlf {
317        return;
318    }
319    fn conform(fix: &mut crate::rule::Fix) {
320        if fix.replacement.contains('\n') {
321            fix.replacement =
322                crate::utils::normalize_line_ending(&fix.replacement, crate::utils::LineEnding::Crlf).into_owned();
323        }
324        for extra in &mut fix.additional_edits {
325            conform(extra);
326        }
327    }
328    for fix in warnings.iter_mut().filter_map(|warning| warning.fix.as_mut()) {
329        conform(fix);
330    }
331}
332
333/// Drop the warnings a document silences, and apply any configured severity override
334/// to the rest.
335///
336/// A warning is dropped when it sits in a kramdown extension block or when an inline
337/// comment disables its rule somewhere in its range. `suppressed`, when given,
338/// collects what the inline comments removed, for the rules that report on them. A
339/// kramdown extension block is not an inline comment, so what it drops is left out.
340fn retain_reportable_warnings(
341    lint_ctx: &crate::lint_context::LintContext,
342    config: Option<&crate::config::Config>,
343    rule_name: &str,
344    rule_warnings: Vec<crate::rule::LintWarning>,
345    mut suppressed: Option<&mut Vec<crate::rule::SuppressedWarning>>,
346) -> Vec<crate::rule::LintWarning> {
347    let inline_config = lint_ctx.inline_config();
348    let mut kept = Vec::with_capacity(rule_warnings.len());
349
350    for mut warning in rule_warnings {
351        if lint_ctx
352            .line_info(warning.line)
353            .is_some_and(|info| info.in_kramdown_extension_block)
354        {
355            continue;
356        }
357
358        // Use the warning's rule_name if available, otherwise use the rule's name
359        let rule_name_to_check = warning.rule_name.as_deref().unwrap_or(rule_name);
360
361        // Extract the base rule name for sub-rules like "MD029-style" -> "MD029"
362        let base_rule_name = if let Some(dash_pos) = rule_name_to_check.find('-') {
363            &rule_name_to_check[..dash_pos]
364        } else {
365            rule_name_to_check
366        };
367
368        // Check if the rule is disabled at any line in the warning's range.
369        // Multi-line warnings (e.g., reflow) report on the first line,
370        // but inline disable comments may appear later in the range.
371        // Guard: if end_line < line (e.g., end_line=0), fall back to
372        // checking only the warning's line to match original behavior.
373        let end = if warning.end_line >= warning.line {
374            warning.end_line
375        } else {
376            warning.line
377        };
378        let disabled_at = (warning.line..=end).find_map(|line| {
379            inline_config
380                .disabling_layer(base_rule_name, line)
381                .map(|layer| (line, layer))
382        });
383        if let Some((line, layer)) = disabled_at {
384            if let Some(record) = suppressed.as_deref_mut() {
385                record.push(crate::rule::SuppressedWarning {
386                    rule_name: base_rule_name.to_string(),
387                    line,
388                    layer,
389                });
390            }
391            continue;
392        }
393
394        // Apply severity override from config if present
395        if let Some(cfg) = config
396            && let Some(override_severity) = cfg.get_rule_severity(rule_name_to_check)
397        {
398            warning.severity = override_severity;
399        }
400
401        kept.push(warning);
402    }
403
404    kept
405}
406
407/// Lint a file and contribute to workspace index for cross-file analysis
408///
409/// This variant performs linting and optionally populates a `FileIndex` with data
410/// needed for cross-file validation. The FileIndex is populated during linting,
411/// avoiding duplicate parsing.
412///
413/// Returns: (warnings, FileIndex) - the FileIndex contains headings/links for cross-file rules
414#[cfg_attr(test, allow(unused_variables))]
415#[allow(clippy::needless_pass_by_value)] // Public compatibility: callers already pass an owned path.
416pub fn lint_and_index(
417    content: &str,
418    rules: &[Box<dyn Rule>],
419    verbose: bool,
420    flavor: crate::config::MarkdownFlavor,
421    source_file: Option<std::path::PathBuf>,
422    config: Option<&crate::config::Config>,
423) -> (LintResult, crate::workspace_index::FileIndex) {
424    lint_and_index_with_paths(
425        content,
426        rules,
427        verbose,
428        flavor,
429        DocumentPaths::same(source_file.as_deref()),
430        config,
431    )
432}
433
434/// The two path roles involved in document processing.
435#[derive(Debug, Clone, Copy, Default)]
436pub struct DocumentPaths<'a> {
437    /// Logical path used for per-file configuration matching.
438    pub config_path: Option<&'a std::path::Path>,
439    /// Native path exposed to filesystem-aware rules.
440    pub source_file: Option<&'a std::path::Path>,
441    /// Run-scoped virtual paths visible to filesystem-aware link rules.
442    pub link_target_policy: Option<&'a crate::lint_context::LinkTargetPolicy>,
443}
444
445impl<'a> DocumentPaths<'a> {
446    /// Use one native path for both roles.
447    pub fn same(path: Option<&'a std::path::Path>) -> Self {
448        Self {
449            config_path: path,
450            source_file: path,
451            link_target_policy: None,
452        }
453    }
454}
455
456/// Lint a document while keeping configuration matching separate from filesystem context.
457///
458/// `paths.config_path` selects per-file ignores and other path-scoped configuration.
459/// `paths.source_file` is exposed to rules that need a real filesystem location. Native
460/// adapters normally pass the same path for both; virtual adapters can supply only
461/// `config_path` without accidentally enabling filesystem-dependent rule behavior.
462#[cfg_attr(test, allow(unused_variables))]
463pub fn lint_and_index_with_paths(
464    content: &str,
465    rules: &[Box<dyn Rule>],
466    verbose: bool,
467    flavor: crate::config::MarkdownFlavor,
468    paths: DocumentPaths<'_>,
469    config: Option<&crate::config::Config>,
470) -> (LintResult, crate::workspace_index::FileIndex) {
471    let mut warnings = Vec::new();
472    // Compute content hash for change detection
473    let content_hash = compute_content_hash(content);
474    let mut file_index = crate::workspace_index::FileIndex::with_hash(content_hash);
475
476    // Early return for empty content
477    if content.is_empty() {
478        return (Ok(warnings), file_index);
479    }
480
481    // The rules `per-file-ignores` takes away for this file. It decides what this
482    // file REPORTS, and nothing else: the index contribution at the end of this
483    // function deliberately keeps running every cross-file rule, because a file's
484    // headings and links belong to the workspace rather than to its own report.
485    // Dropping a rule from the index instead would break the links pointing HERE,
486    // in files that never named it.
487    let ignored_for_file = match (config, paths.config_path) {
488        (Some(cfg), Some(path)) => cfg.get_ignored_rules_for_file(path),
489        _ => std::collections::HashSet::new(),
490    };
491
492    // Parse LintContext once (includes inline config parsing)
493    let lint_ctx = time_function!(
494        "lint: parse lint context",
495        crate::lint_context::LintContext::new(content, flavor, paths.source_file.map(std::path::Path::to_path_buf))
496    );
497    let lint_ctx = match paths.link_target_policy {
498        Some(policy) => lint_ctx.with_link_target_policy(policy.clone()),
499        None => lint_ctx,
500    };
501    let inline_config = lint_ctx.inline_config();
502
503    // Export inline config data to FileIndex for cross-file rule filtering
504    let (file_disabled, persistent_transitions, line_disabled) = inline_config.export_for_file_index();
505    file_index.file_disabled_rules = file_disabled;
506    file_index.persistent_transitions = persistent_transitions;
507    file_index.line_disabled_rules = line_disabled;
508
509    // Analyze content characteristics for rule filtering
510    let characteristics = time_function!(
511        "lint: analyze content characteristics",
512        ContentCharacteristics::analyze(content)
513    );
514
515    // Filter rules based on per-file-ignores and content characteristics
516    let applicable_rules: Vec<_> = rules
517        .iter()
518        .filter(|rule| !ignored_for_file.contains(rule.name()))
519        .filter(|rule| !(rule.skippable_by_category() && characteristics.should_skip_rule(rule.as_ref())))
520        .collect();
521
522    // Calculate skipped rules count before consuming applicable_rules
523    #[cfg(not(test))]
524    let total_rules = rules.len();
525    #[cfg(not(test))]
526    let applicable_count = applicable_rules.len();
527
528    #[cfg(not(target_arch = "wasm32"))]
529    let profile_rules = std::env::var("RUMDL_PROFILE_RULES").is_ok();
530
531    // Automatic inline config support: merge inline overrides into config once,
532    // then recreate only the affected rules. Works for ALL rules without per-rule changes.
533    let inline_overrides = inline_config.get_all_rule_configs();
534    let merged_config = if !inline_overrides.is_empty() {
535        config.map(|c| c.merge_with_inline_config(inline_config))
536    } else {
537        None
538    };
539    let effective_config = merged_config.as_ref().or(config);
540
541    // Cache recreated rules for rules with inline overrides
542    let mut recreated_rules: std::collections::HashMap<String, Box<dyn crate::rule::Rule>> =
543        std::collections::HashMap::new();
544
545    // Pre-create rules that have inline config overrides
546    if let Some(cfg) = effective_config {
547        for rule_name in inline_overrides.keys() {
548            if let Some(recreated) = crate::rules::create_rule_by_name(rule_name, cfg) {
549                recreated_rules.insert(rule_name.clone(), recreated);
550            }
551        }
552    }
553
554    // A rule reporting on the run's inline disable comments needs to know what they
555    // removed, which costs a record per suppressed warning, so it is only kept when
556    // such a rule is going to read it.
557    let suppression_observers: Vec<_> = applicable_rules
558        .iter()
559        .filter(|rule| rule.observes_suppressions() && !rule.should_skip(&lint_ctx))
560        .collect();
561    let mut suppressed = Vec::new();
562
563    {
564        let _timer = profiling::ScopedTimer::new("lint: run single-file rules");
565        for rule in &applicable_rules {
566            #[cfg(not(target_arch = "wasm32"))]
567            let rule_start = Instant::now();
568
569            // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
570            if rule.should_skip(&lint_ctx) {
571                continue;
572            }
573
574            // Use recreated rule if inline config overrides exist for this rule
575            let effective_rule: &dyn crate::rule::Rule = recreated_rules
576                .get(rule.name())
577                .map_or(rule.as_ref(), std::convert::AsRef::as_ref);
578
579            // Run single-file check with the effective rule (possibly with inline config applied)
580            let result = effective_rule.check(&lint_ctx);
581
582            match result {
583                Ok(rule_warnings) => {
584                    let record = if suppression_observers.is_empty() {
585                        None
586                    } else {
587                        Some(&mut suppressed)
588                    };
589                    let filtered_warnings =
590                        retain_reportable_warnings(&lint_ctx, config, rule.name(), rule_warnings, record);
591                    warnings.extend(filtered_warnings);
592                }
593                Err(e) => {
594                    log::error!("Error checking rule {}: {}", rule.name(), e);
595                    return (Err(e), file_index);
596                }
597            }
598
599            #[cfg(not(target_arch = "wasm32"))]
600            {
601                let rule_duration = rule_start.elapsed();
602                if profile_rules {
603                    eprintln!("[RULE] {:6} {:?}", rule.name(), rule_duration);
604                }
605
606                #[cfg(not(test))]
607                if verbose && rule_duration.as_millis() > 500 {
608                    log::debug!("Rule {} took {:?}", rule.name(), rule_duration);
609                }
610            }
611        }
612    }
613
614    // Report on the inline disable comments, now that every single-file rule has run
615    // and the suppressions are complete.
616    if !suppression_observers.is_empty() {
617        let _timer = profiling::ScopedTimer::new("lint: run suppression rules");
618
619        // A workspace-scope rule has its warnings filtered after this point, and for a
620        // single-file run not at all, so its findings never reach the report and
621        // nothing can be concluded about a comment naming it. A rule this file
622        // ignores does not report either, for the same reason.
623        let report = crate::rule::SuppressionReport {
624            suppressed,
625            judged_rules: rules
626                .iter()
627                .filter(|rule| rule.cross_file_scope() != crate::rule::CrossFileScope::Workspace)
628                .filter(|rule| !ignored_for_file.contains(rule.name()))
629                .map(|rule| rule.name().to_string())
630                .collect(),
631        };
632
633        for rule in &suppression_observers {
634            match rule.check_suppressions(&lint_ctx, &report) {
635                Ok(rule_warnings) => {
636                    let filtered_warnings =
637                        retain_reportable_warnings(&lint_ctx, config, rule.name(), rule_warnings, None);
638                    warnings.extend(filtered_warnings);
639                }
640                Err(e) => {
641                    log::error!("Error checking rule {}: {}", rule.name(), e);
642                    return (Err(e), file_index);
643                }
644            }
645        }
646    }
647
648    // Contribute to index for cross-file rules (done after all rules checked)
649    // NOTE: We iterate over ALL rules (not just applicable_rules) because cross-file
650    // rules need to extract data from every file in the workspace, regardless of whether
651    // that file has content that would trigger the rule, and regardless of what this
652    // file's own configuration reports. For example, MD051 needs to index headings from
653    // files that have no links (like target.md) so that links FROM other files TO those
654    // headings can be validated - including from a file that ignores MD051 itself.
655    time_section!("lint: contribute cross-file data", {
656        for rule in rules {
657            if rule.cross_file_scope() == crate::rule::CrossFileScope::Workspace {
658                rule.contribute_to_index(&lint_ctx, &mut file_index);
659            }
660        }
661    });
662
663    #[cfg(not(test))]
664    if verbose {
665        let skipped_rules = total_rules - applicable_count;
666        if skipped_rules > 0 {
667            log::debug!("Skipped {skipped_rules} of {total_rules} rules based on content analysis");
668        }
669    }
670
671    conform_fix_line_endings(content, &mut warnings);
672
673    (Ok(warnings), file_index)
674}
675
676/// Run cross-file checks for rules that need workspace-wide validation
677///
678/// This should be called after all files have been linted and the WorkspaceIndex
679/// has been built from the accumulated FileIndex data.
680///
681/// Note: This takes the FileIndex instead of content to avoid re-parsing each file.
682/// The FileIndex was already populated during contribute_to_index in the linting phase.
683///
684/// Rules can use workspace_index methods for cross-file validation:
685/// - `get_file(path)` - to look up headings in target files (for MD051)
686///
687/// Returns additional warnings from cross-file validation.
688pub fn run_cross_file_checks(
689    file_path: &std::path::Path,
690    file_index: &crate::workspace_index::FileIndex,
691    rules: &[Box<dyn Rule>],
692    workspace_index: &crate::workspace_index::WorkspaceIndex,
693    config: Option<&crate::config::Config>,
694) -> LintResult {
695    use crate::rule::CrossFileScope;
696
697    let mut warnings = Vec::new();
698
699    // Honor `per-file-ignores` for cross-file rules. Cross-file warnings are
700    // attributed to `file_path` (the file holding the link), so a rule ignored
701    // for that file must not emit them. This applies on every path; single-file
702    // rule filtering does not cover cross-file checks because they run over the
703    // config group's full rule set, and cross-file rules share link data.
704    let ignored_rules_for_file = config.map(|cfg| cfg.get_ignored_rules_for_file(file_path));
705
706    // Only check rules that need cross-file analysis
707    for rule in rules {
708        if rule.cross_file_scope() != CrossFileScope::Workspace {
709            continue;
710        }
711
712        if ignored_rules_for_file
713            .as_ref()
714            .is_some_and(|ignored| ignored.contains(rule.name()))
715        {
716            continue;
717        }
718
719        match time_function!(
720            "workspace: cross-file rule check",
721            rule.cross_file_check(file_path, file_index, workspace_index)
722        ) {
723            Ok(rule_warnings) => {
724                // Filter cross-file warnings based on inline config stored in file_index
725                let filtered: Vec<_> = rule_warnings
726                    .into_iter()
727                    .filter(|w| !file_index.is_rule_disabled_at_line(rule.name(), w.line))
728                    .map(|mut warning| {
729                        // Apply severity override from config if present
730                        if let Some(cfg) = config
731                            && let Some(override_severity) = cfg.get_rule_severity(rule.name())
732                        {
733                            warning.severity = override_severity;
734                        }
735                        warning
736                    })
737                    .collect();
738                warnings.extend(filtered);
739            }
740            Err(e) => {
741                log::error!("Error in cross-file check for rule {}: {}", rule.name(), e);
742                return Err(e);
743            }
744        }
745    }
746
747    Ok(warnings)
748}
749
750/// Get the profiling report
751pub fn get_profiling_report() -> String {
752    profiling::get_report()
753}
754
755/// Reset the profiling data
756pub fn reset_profiling() {
757    profiling::reset()
758}
759
760/// Get regex cache statistics for performance monitoring
761pub fn get_regex_cache_stats() -> std::collections::HashMap<String, u64> {
762    crate::utils::regex_cache::get_cache_stats()
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use crate::rule::Rule;
769    use crate::rules::{MD001HeadingIncrement, MD009TrailingSpaces};
770
771    #[test]
772    fn test_content_characteristics_analyze() {
773        // Test empty content
774        let chars = ContentCharacteristics::analyze("");
775        assert!(!chars.has_headings);
776        assert!(!chars.has_lists);
777        assert!(!chars.has_links);
778        assert!(!chars.has_code);
779        assert!(!chars.has_emphasis);
780        assert!(!chars.has_html);
781        assert!(!chars.has_tables);
782        assert!(!chars.has_blockquotes);
783        assert!(!chars.has_images);
784
785        // Test content with headings
786        let chars = ContentCharacteristics::analyze("# Heading");
787        assert!(chars.has_headings);
788
789        // Test setext headings
790        let chars = ContentCharacteristics::analyze("Heading\n=======");
791        assert!(chars.has_headings);
792
793        // Blockquoted ATX headings emit fragment anchors, so Heading-category
794        // rules (MD051/MD080) must run for blockquote-only documents.
795        let chars = ContentCharacteristics::analyze("> ## Alpha\n>\n> ## Alpha");
796        assert!(chars.has_headings, "blockquoted ATX heading must set has_headings");
797        let chars = ContentCharacteristics::analyze(">> # Nested");
798        assert!(
799            chars.has_headings,
800            "nested-blockquote ATX heading must set has_headings"
801        );
802        // A tab after the blockquote marker is also a valid heading
803        // (`parse_blockquote_prefix` accepts it).
804        let chars = ContentCharacteristics::analyze(">\t## Tabbed");
805        assert!(
806            chars.has_headings,
807            "tab-separated blockquote ATX heading must set has_headings"
808        );
809
810        // Test lists
811        let chars = ContentCharacteristics::analyze("* Item\n- Item 2\n+ Item 3");
812        assert!(chars.has_lists);
813
814        // Test ordered lists
815        let chars = ContentCharacteristics::analyze("1. First\n2. Second");
816        assert!(chars.has_lists);
817
818        // Test links
819        let chars = ContentCharacteristics::analyze("[link](url)");
820        assert!(chars.has_links);
821
822        // Test URLs
823        let chars = ContentCharacteristics::analyze("Visit https://example.com");
824        assert!(chars.has_links);
825
826        // Test images
827        let chars = ContentCharacteristics::analyze("![alt text](image.png)");
828        assert!(chars.has_images);
829
830        // Test code
831        let chars = ContentCharacteristics::analyze("`inline code`");
832        assert!(chars.has_code);
833
834        let chars = ContentCharacteristics::analyze("~~~\ncode block\n~~~");
835        assert!(chars.has_code);
836
837        // Test indented code blocks (4 spaces)
838        let chars = ContentCharacteristics::analyze("Text\n\n    indented code\n\nMore text");
839        assert!(chars.has_code);
840
841        // Test tab-indented code blocks
842        let chars = ContentCharacteristics::analyze("Text\n\n\ttab indented code\n\nMore text");
843        assert!(chars.has_code);
844
845        // Test mixed whitespace indented code (2 spaces + tab = 4 columns)
846        let chars = ContentCharacteristics::analyze("Text\n\n  \tmixed indent code\n\nMore text");
847        assert!(chars.has_code);
848
849        // Test 1 space + tab (also 4 columns due to tab expansion)
850        let chars = ContentCharacteristics::analyze("Text\n\n \ttab after space\n\nMore text");
851        assert!(chars.has_code);
852
853        // Test emphasis
854        let chars = ContentCharacteristics::analyze("*emphasis* and _more_");
855        assert!(chars.has_emphasis);
856
857        // Test HTML
858        let chars = ContentCharacteristics::analyze("<div>HTML content</div>");
859        assert!(chars.has_html);
860
861        // Test tables
862        let chars = ContentCharacteristics::analyze("| Header | Header |\n|--------|--------|");
863        assert!(chars.has_tables);
864
865        // Test blockquotes
866        let chars = ContentCharacteristics::analyze("> Quote");
867        assert!(chars.has_blockquotes);
868
869        // Test mixed content
870        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)";
871        let chars = ContentCharacteristics::analyze(content);
872        assert!(chars.has_headings);
873        assert!(chars.has_lists);
874        assert!(chars.has_links);
875        assert!(chars.has_code);
876        assert!(chars.has_emphasis);
877        assert!(chars.has_html);
878        assert!(chars.has_tables);
879        assert!(chars.has_blockquotes);
880        assert!(chars.has_images);
881    }
882
883    #[test]
884    fn test_content_characteristics_parenthesized_ordered_list() {
885        assert!(ContentCharacteristics::analyze("1) first\n2) second").has_lists);
886        assert!(ContentCharacteristics::analyze("  1) indented first\n  2) second").has_lists);
887        assert!(ContentCharacteristics::analyze("> 1) quoted item").has_lists);
888    }
889
890    #[test]
891    fn test_content_characteristics_should_skip_rule() {
892        let chars = ContentCharacteristics {
893            has_headings: true,
894            has_lists: false,
895            has_links: true,
896            has_code: false,
897            has_emphasis: true,
898            has_html: false,
899            has_tables: true,
900            has_blockquotes: false,
901            has_images: false,
902        };
903
904        // Create test rules for different categories
905        let heading_rule = MD001HeadingIncrement::default();
906        assert!(!chars.should_skip_rule(&heading_rule));
907
908        let trailing_spaces_rule = MD009TrailingSpaces::new(2, false);
909        assert!(!chars.should_skip_rule(&trailing_spaces_rule)); // Whitespace rules always run
910
911        // Test skipping based on content
912        let chars_no_headings = ContentCharacteristics {
913            has_headings: false,
914            ..Default::default()
915        };
916        assert!(chars_no_headings.should_skip_rule(&heading_rule));
917    }
918
919    #[test]
920    fn test_lint_empty_content() {
921        let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement::default())];
922
923        let result = lint("", &rules, false, crate::config::MarkdownFlavor::Standard, None, None);
924        assert!(result.is_ok());
925        assert!(result.unwrap().is_empty());
926    }
927
928    #[test]
929    fn test_lint_with_violations() {
930        let content = "## Level 2\n#### Level 4"; // Skips level 3
931        let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement::default())];
932
933        let result = lint(
934            content,
935            &rules,
936            false,
937            crate::config::MarkdownFlavor::Standard,
938            None,
939            None,
940        );
941        assert!(result.is_ok());
942        let warnings = result.unwrap();
943        assert!(!warnings.is_empty());
944        // Check the rule field of LintWarning struct
945        assert_eq!(warnings[0].rule_name.as_deref(), Some("MD001"));
946    }
947
948    #[test]
949    fn test_lint_with_inline_disable() {
950        let content = "<!-- rumdl-disable MD001 -->\n## Level 2\n#### Level 4";
951        let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD001HeadingIncrement::default())];
952
953        let result = lint(
954            content,
955            &rules,
956            false,
957            crate::config::MarkdownFlavor::Standard,
958            None,
959            None,
960        );
961        assert!(result.is_ok());
962        let warnings = result.unwrap();
963        assert!(warnings.is_empty()); // Should be disabled by inline comment
964    }
965
966    #[test]
967    fn test_lint_rule_filtering() {
968        // Content with no lists
969        let content = "# Heading\nJust text";
970        let rules: Vec<Box<dyn Rule>> = vec![
971            Box::new(MD001HeadingIncrement::default()),
972            // A list-related rule would be skipped
973        ];
974
975        let result = lint(
976            content,
977            &rules,
978            false,
979            crate::config::MarkdownFlavor::Standard,
980            None,
981            None,
982        );
983        assert!(result.is_ok());
984    }
985
986    #[test]
987    fn test_get_profiling_report() {
988        // Just test that it returns a string without panicking
989        let report = get_profiling_report();
990        assert!(!report.is_empty());
991        assert!(report.contains("Profiling"));
992    }
993
994    #[test]
995    fn test_reset_profiling() {
996        // Test that reset_profiling doesn't panic
997        reset_profiling();
998
999        // After reset, report should indicate no measurements or profiling disabled
1000        let report = get_profiling_report();
1001        assert!(report.contains("disabled") || report.contains("no measurements"));
1002    }
1003
1004    #[test]
1005    fn test_get_regex_cache_stats() {
1006        let stats = get_regex_cache_stats();
1007        // Stats should be a valid HashMap (might be empty)
1008        assert!(stats.is_empty() || !stats.is_empty());
1009
1010        // If not empty, all values should be positive
1011        for count in stats.values() {
1012            assert!(*count > 0);
1013        }
1014    }
1015
1016    #[test]
1017    fn test_content_characteristics_edge_cases() {
1018        // Test setext heading edge case
1019        let chars = ContentCharacteristics::analyze("-"); // Single dash, not a heading
1020        assert!(!chars.has_headings);
1021
1022        let chars = ContentCharacteristics::analyze("--"); // Two dashes, valid setext
1023        assert!(chars.has_headings);
1024
1025        // Test list detection - we now include potential list patterns (with or without space)
1026        // to support user-intention detection in MD030
1027        let chars = ContentCharacteristics::analyze("*emphasis*"); // Could be list or emphasis
1028        assert!(chars.has_lists); // Run list rules to be safe
1029
1030        let chars = ContentCharacteristics::analyze("1.Item"); // Could be list without space
1031        assert!(chars.has_lists); // Run list rules for user-intention detection
1032
1033        // Test blockquote must be at start of line
1034        let chars = ContentCharacteristics::analyze("text > not a quote");
1035        assert!(!chars.has_blockquotes);
1036    }
1037
1038    /// One document that draws a line-inserting fix from every rule known to
1039    /// write a bare `\n` into its replacement: MD071 (blank line after front
1040    /// matter), MD022 (blank lines around headings), MD032 (around lists),
1041    /// MD031 (around fences), MD014 (dollar prompts), MD058 (around tables) and
1042    /// MD047 (final newline).
1043    const LINE_INSERTING_FIXES: &str = "---\ntitle: x\n---\n# Heading\ntext\n## Sub\n- item\ntext\n```sh\n$ ls\n```\ntext\n| a | b |\n|---|---|\n| 1 | 2 |\ntext";
1044
1045    /// Every fix replacement (and additional edit) of every warning, keyed by rule.
1046    fn fix_replacements(content: &str) -> Vec<(String, String)> {
1047        let config = crate::config::Config::default();
1048        let rules = crate::rules::all_rules(&config);
1049        let warnings = lint(
1050            content,
1051            &rules,
1052            false,
1053            crate::config::MarkdownFlavor::Standard,
1054            None,
1055            Some(&config),
1056        )
1057        .unwrap();
1058        let mut out = Vec::new();
1059        for warning in warnings {
1060            let Some(fix) = warning.fix else { continue };
1061            let rule = warning.rule_name.clone().unwrap_or_default();
1062            let mut stack = vec![fix];
1063            while let Some(fix) = stack.pop() {
1064                out.push((rule.clone(), fix.replacement.clone()));
1065                stack.extend(fix.additional_edits);
1066            }
1067        }
1068        out
1069    }
1070
1071    fn has_bare_lf(text: &str) -> bool {
1072        let bytes = text.as_bytes();
1073        bytes
1074            .iter()
1075            .enumerate()
1076            .any(|(i, b)| *b == b'\n' && (i == 0 || bytes[i - 1] != b'\r'))
1077    }
1078
1079    #[test]
1080    fn fix_replacements_use_the_documents_crlf_line_ending() {
1081        let crlf = LINE_INSERTING_FIXES.replace('\n', "\r\n");
1082        let replacements = fix_replacements(&crlf);
1083
1084        let bare: Vec<_> = replacements.iter().filter(|(_, r)| has_bare_lf(r)).collect();
1085        assert!(bare.is_empty(), "bare LF in a fix for a CRLF document: {bare:?}");
1086
1087        // Positive control: the rules this document was written for did fire and
1088        // did insert a line ending, so the assertion above examined real fixes.
1089        let mut crlf_rules: Vec<_> = replacements
1090            .iter()
1091            .filter(|(_, r)| r.contains("\r\n"))
1092            .map(|(rule, _)| rule.as_str())
1093            .collect();
1094        crlf_rules.sort_unstable();
1095        crlf_rules.dedup();
1096        for rule in ["MD014", "MD022", "MD031", "MD032", "MD047", "MD058", "MD071"] {
1097            assert!(
1098                crlf_rules.contains(&rule),
1099                "{rule} inserted no CRLF line ending; got {crlf_rules:?}"
1100            );
1101        }
1102    }
1103
1104    #[test]
1105    fn fix_replacements_stay_lf_for_lf_and_mixed_documents() {
1106        // The same rules on the LF document write `\n`, untouched.
1107        let lf = fix_replacements(LINE_INSERTING_FIXES);
1108        assert!(lf.iter().any(|(_, r)| has_bare_lf(r)));
1109        assert!(!lf.iter().any(|(_, r)| r.contains('\r')));
1110
1111        // A document with mixed endings has no convention to conform to, so
1112        // its fixes are left exactly as the rules wrote them.
1113        let mixed = LINE_INSERTING_FIXES.replacen('\n', "\r\n", 1);
1114        assert_eq!(
1115            crate::utils::detect_line_ending_enum(&mixed),
1116            crate::utils::LineEnding::Mixed
1117        );
1118        let mixed = fix_replacements(&mixed);
1119        assert!(mixed.iter().any(|(_, r)| has_bare_lf(r)));
1120    }
1121}