rumdl 0.3.2

A fast Markdown linter and formatter written in Rust
Documentation
//! Linting of embedded markdown content inside fenced code blocks.
//!
//! This module provides functions for checking markdown content that appears
//! inside fenced code blocks with `markdown` or `md` language tags. These
//! functions are used by both the CLI and LSP to lint embedded markdown.

use crate::code_block_tools::{
    CodeBlockToolsConfig, FencedCodeBlockInfo, RUMDL_BUILTIN_TOOL, extract_fenced_code_blocks, is_rumdl_builtin,
};
use crate::config as rumdl_config;
use crate::lint_context::LintContext;
use crate::rule::{LintWarning, Rule};

/// Maximum recursion depth for linting nested markdown blocks.
///
/// Prevents stack overflow from deeply nested or maliciously crafted content.
/// Real-world usage rarely exceeds 2-3 levels.
pub const MAX_EMBEDDED_DEPTH: usize = 5;

/// Check if embedded markdown linting is enabled via code-block-tools configuration.
///
/// Returns true if a built-in rumdl tool is in the lint slot for markdown/md,
/// indicating that rumdl's built-in markdown linting should be applied to markdown code blocks.
pub fn should_lint_embedded_markdown(config: &CodeBlockToolsConfig) -> bool {
    if !config.enabled {
        return false;
    }

    // Check if markdown language is configured with the built-in rumdl tool
    for lang_key in ["markdown", "md"] {
        if let Some(lang_config) = config.languages.get(lang_key)
            && lang_config.enabled
            && lang_config.lint.iter().any(|tool| is_rumdl_builtin(tool))
        {
            return true;
        }
    }

    false
}

/// Enable formatting independently of linting for explicit tool variants.
/// The legacy `lint = ["rumdl"]` setting continues to enable both phases.
pub fn should_format_embedded_markdown(config: &CodeBlockToolsConfig) -> bool {
    config.enabled
        && ["markdown", "md"].iter().any(|lang| {
            config.languages.get(*lang).is_some_and(|language| {
                language.enabled
                    && (language
                        .format
                        .iter()
                        .any(|tool| matches!(tool.as_str(), "rumdl" | "rumdl:format"))
                        || language.lint.iter().any(|tool| tool == RUMDL_BUILTIN_TOOL))
            })
        })
}

/// Check if content contains fenced code block markers.
pub fn has_fenced_code_blocks(content: &str) -> bool {
    content.contains("```") || content.contains("~~~")
}

/// Check markdown content embedded in fenced code blocks with `markdown` or `md` language.
///
/// Detects markdown code blocks and runs lint checks on their content,
/// returning warnings with adjusted line numbers that point to the correct location
/// in the parent file.
pub fn check_embedded_markdown_blocks(
    content: &str,
    rules: &[Box<dyn Rule>],
    config: &rumdl_config::Config,
) -> Vec<LintWarning> {
    let warnings = check_embedded_markdown_blocks_raw(content, rules, config, 0);
    let ctx = LintContext::new(content, config.markdown_flavor(), None);
    crate::retain_reportable_warnings(&ctx, Some(config), "embedded-markdown", warnings, None)
}

/// Internal recursive implementation with depth tracking.
pub(crate) fn check_embedded_markdown_blocks_raw(
    content: &str,
    rules: &[Box<dyn Rule>],
    config: &rumdl_config::Config,
    depth: usize,
) -> Vec<LintWarning> {
    if depth >= MAX_EMBEDDED_DEPTH {
        return Vec::new();
    }
    if !has_fenced_code_blocks(content) {
        return Vec::new();
    }

    let blocks = markdown_code_blocks(content);

    if blocks.is_empty() {
        return Vec::new();
    }

    let mut all_warnings = Vec::new();

    for block in blocks {
        if block.code.trim().is_empty() {
            continue;
        }

        // Do not drop rules disabled by the parent: their findings are what
        // gives the parent's directive credit. This block handles its own
        // directives and nested blocks before its findings are mapped outward.
        let block_rules: Vec<Box<dyn Rule>> = rules
            .iter()
            .filter(|rule| !matches!(rule.name(), "MD041" | "MD047"))
            .map(|rule| dyn_clone::clone_box(&**rule))
            .collect();
        let paths = crate::DocumentPaths {
            embedded_rules: Some(&block_rules),
            embedded_depth: depth + 1,
            ..Default::default()
        };
        let block_warnings = crate::lint_and_index_with_paths(
            &block.code,
            &block_rules,
            false,
            config.markdown_flavor(),
            paths,
            Some(config),
        )
        .0
        .unwrap_or_default();

        for warning in block_warnings {
            all_warnings.push(place_in_document(warning, &block));
        }
    }

    all_warnings
}

/// The fenced code blocks in `content` whose language is `markdown` or `md`.
pub fn markdown_code_blocks(content: &str) -> Vec<FencedCodeBlockInfo> {
    let mut blocks = extract_fenced_code_blocks(content);
    blocks.retain(|block| block.language.eq_ignore_ascii_case("markdown") || block.language.eq_ignore_ascii_case("md"));
    blocks
}

/// Move a warning on a block's code to where that code sits in the document.
///
/// Line `n` of the code is the `n`th line after the opening fence, and each
/// column moves right by the container prefix the parser removed from its line.
/// The fix is dropped: its byte range is in the code, not the document.
fn place_in_document(warning: LintWarning, block: &FencedCodeBlockInfo) -> LintWarning {
    let line_offset = block.start_line + 1;
    LintWarning {
        line: warning.line + line_offset,
        column: block
            .document_column(warning.line, warning.column)
            .unwrap_or(warning.column),
        end_line: warning.end_line + line_offset,
        end_column: block
            .document_column(warning.end_line, warning.end_column)
            .unwrap_or(warning.end_column),
        fix: None,
        ..warning
    }
}

#[cfg(test)]
mod markdown_code_blocks_tests {
    use super::*;

    fn codes(content: &str) -> Vec<String> {
        markdown_code_blocks(content)
            .into_iter()
            .map(|block| block.code)
            .collect()
    }

    #[test]
    fn keeps_only_markdown_and_md_blocks() {
        let content = "# Example\n\n```markdown\n# Heading\nContent here\n```\n\n```md\nAnother heading\n```\n\n```rust\nfn main() {}\n```\n";
        assert_eq!(codes(content), ["# Heading\nContent here\n", "Another heading\n"]);
    }

    #[test]
    fn matches_the_language_case_insensitively() {
        assert_eq!(codes("```MARKDOWN\nContent\n```\n"), ["Content\n"]);
        assert_eq!(codes("~~~Md\nContent\n~~~\n"), ["Content\n"]);
    }

    #[test]
    fn finds_nothing_without_markdown_blocks() {
        assert!(codes("# Just a heading\n\nNo code blocks here\n").is_empty());
        assert!(codes("```\nplain\n```\n").is_empty());
        assert!(codes("").is_empty());
    }

    #[test]
    fn code_omits_container_prefixes_and_fence_indentation() {
        assert_eq!(codes("> ```markdown\n> # H\n>\n> text\n> ```\n"), ["# H\n\ntext\n"]);
        assert_eq!(codes("- ```md\n  # H\n      code\n  ```\n"), ["# H\n    code\n"]);
        assert_eq!(codes("   ```markdown\n   X\n   ```"), ["X\n"]);
    }

    #[test]
    fn content_range_covers_the_code_lines_in_the_document() {
        let cases = [
            ("```markdown\nX\n```\n", "X\n"),
            ("# Doc\n\n```markdown\nContent\n```", "Content\n"),
            ("> ```markdown\n> X\n> ```\n", "> X\n"),
            ("```markdown\n```\n", ""),
            ("```markdown\nX", "X"),
            ("```markdown", ""),
        ];
        for (content, source) in cases {
            let blocks = markdown_code_blocks(content);
            assert_eq!(blocks.len(), 1, "content: {content:?}");
            assert_eq!(
                &content[blocks[0].content_start..blocks[0].content_end],
                source,
                "content: {content:?}"
            );
        }
    }
}

#[cfg(test)]
mod activation_tests {
    use super::*;
    use crate::code_block_tools::LanguageToolConfig;

    #[test]
    fn explicit_variants_respect_master_and_language_switches() {
        for language in ["markdown", "md"] {
            for master in [false, true] {
                for enabled in [false, true] {
                    let mut config = CodeBlockToolsConfig {
                        enabled: master,
                        ..Default::default()
                    };
                    config.languages.insert(
                        language.to_string(),
                        LanguageToolConfig {
                            enabled,
                            lint: vec!["rumdl:lint".to_string()],
                            format: vec!["rumdl:format".to_string()],
                            ..Default::default()
                        },
                    );
                    assert_eq!(should_lint_embedded_markdown(&config), master && enabled);
                    assert_eq!(should_format_embedded_markdown(&config), master && enabled);
                }
            }
        }
    }
}

#[cfg(test)]
mod suppression_tests {
    use super::*;
    use crate::rules::{MD063HeadingCapitalization, MD087UnusedDisableComment};

    #[test]
    fn parent_observers_count_embedded_suppressions_at_each_depth() {
        let rules: Vec<Box<dyn Rule>> = vec![
            Box::new(MD063HeadingCapitalization::new()),
            Box::new(MD087UnusedDisableComment),
        ];
        let config = rumdl_config::Config::default();
        for directive in ["disable", "disable-file"] {
            for (opening, closing) in [("", ""), ("````markdown\n", "````\n")] {
                let source = format!("{opening}<!-- rumdl-{directive} MD063 -->\n\n```md\n# asdf\n```\n{closing}");
                let run = crate::document_run::DocumentRun::new(&source, &rules, &config).embedded_markdown(&rules);
                assert!(run.analyze().unwrap().warnings.is_empty(), "{source:?}");
                let unused = source.replace("asdf", "Asdf");
                let warnings = crate::document_run::DocumentRun::new(&unused, &rules, &config)
                    .embedded_markdown(&rules)
                    .analyze()
                    .unwrap()
                    .warnings;
                assert_eq!(warnings.len(), 1, "{unused:?}: {warnings:?}");
                assert_eq!(warnings[0].rule_name.as_deref(), Some("MD087"));
                assert_eq!(warnings[0].line, if opening.is_empty() { 1 } else { 2 });
            }
        }
    }

    #[test]
    fn embedded_markdown_accounts_for_its_own_disable_comments() {
        let rules: Vec<Box<dyn Rule>> = vec![
            Box::new(MD063HeadingCapitalization::new()),
            Box::new(MD087UnusedDisableComment),
        ];
        let config = rumdl_config::Config::default();
        for (opening, prefix, closing, line) in [
            ("```markdown\n", "", "```\n", 2),
            ("> ```markdown\n", "> ", "> ```\n", 2),
            ("````markdown\n```md\n", "", "```\n````\n", 3),
        ] {
            for (heading, expected) in [
                ("# asdf <!-- rumdl-disable-line MD063 -->", None),
                ("# Asdf <!-- rumdl-disable-line MD063 -->", Some("MD087")),
                ("# asdf", Some("MD063")),
            ] {
                let content = format!("{opening}{prefix}{heading}\n{closing}");
                let warnings = check_embedded_markdown_blocks(&content, &rules, &config);
                let parent = crate::document_run::DocumentRun::new(&content, &rules, &config)
                    .embedded_markdown(&rules)
                    .analyze()
                    .unwrap();
                assert_eq!(
                    parent.warnings, warnings,
                    "parent duplicated or lost an embedded finding"
                );
                if let Some(name) = expected {
                    assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
                    assert_eq!(warnings[0].rule_name.as_deref(), Some(name));
                    assert_eq!(warnings[0].line, line);
                    assert!(warnings[0].fix.is_none(), "embedded byte ranges must not escape");
                } else {
                    assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
                }
            }
        }
    }
}