Skip to main content

rumdl_lib/
embedded_lint.rs

1//! Linting of embedded markdown content inside fenced code blocks.
2//!
3//! This module provides functions for checking markdown content that appears
4//! inside fenced code blocks with `markdown` or `md` language tags. These
5//! functions are used by both the CLI and LSP to lint embedded markdown.
6
7use crate::code_block_tools::{CodeBlockToolsConfig, RUMDL_BUILTIN_TOOL};
8use crate::config as rumdl_config;
9use crate::inline_config::InlineConfig;
10use crate::lint_context::LintContext;
11use crate::rule::{LintWarning, Rule};
12use crate::utils::code_block_utils::CodeBlockUtils;
13
14/// Maximum recursion depth for linting nested markdown blocks.
15///
16/// Prevents stack overflow from deeply nested or maliciously crafted content.
17/// Real-world usage rarely exceeds 2-3 levels.
18pub const MAX_EMBEDDED_DEPTH: usize = 5;
19
20/// Check if embedded markdown linting is enabled via code-block-tools configuration.
21///
22/// Returns true if the special "rumdl" tool is configured for markdown/md language,
23/// indicating that rumdl's built-in markdown linting should be applied to markdown code blocks.
24pub fn should_lint_embedded_markdown(config: &CodeBlockToolsConfig) -> bool {
25    if !config.enabled {
26        return false;
27    }
28
29    // Check if markdown language is configured with the built-in rumdl tool
30    for lang_key in ["markdown", "md"] {
31        if let Some(lang_config) = config.languages.get(lang_key)
32            && lang_config.enabled
33            && lang_config.lint.iter().any(|tool| tool == RUMDL_BUILTIN_TOOL)
34        {
35            return true;
36        }
37    }
38
39    false
40}
41
42/// Check if content contains fenced code block markers.
43pub fn has_fenced_code_blocks(content: &str) -> bool {
44    content.contains("```") || content.contains("~~~")
45}
46
47/// Check markdown content embedded in fenced code blocks with `markdown` or `md` language.
48///
49/// Detects markdown code blocks and runs lint checks on their content,
50/// returning warnings with adjusted line numbers that point to the correct location
51/// in the parent file.
52pub fn check_embedded_markdown_blocks(
53    content: &str,
54    rules: &[Box<dyn Rule>],
55    config: &rumdl_config::Config,
56) -> Vec<LintWarning> {
57    check_embedded_markdown_blocks_recursive(content, rules, config, 0)
58}
59
60/// Internal recursive implementation with depth tracking.
61fn check_embedded_markdown_blocks_recursive(
62    content: &str,
63    rules: &[Box<dyn Rule>],
64    config: &rumdl_config::Config,
65    depth: usize,
66) -> Vec<LintWarning> {
67    if depth >= MAX_EMBEDDED_DEPTH {
68        return Vec::new();
69    }
70    if !has_fenced_code_blocks(content) {
71        return Vec::new();
72    }
73
74    let blocks = CodeBlockUtils::detect_markdown_code_blocks(content);
75
76    if blocks.is_empty() {
77        return Vec::new();
78    }
79
80    let inline_config = InlineConfig::from_content(content);
81    let mut all_warnings = Vec::new();
82
83    for block in blocks {
84        let block_content = &content[block.content_start..block.content_end];
85
86        if block_content.trim().is_empty() {
87            continue;
88        }
89
90        // Calculate the line offset for this block
91        let line_offset = content[..block.content_start].matches('\n').count();
92
93        // Compute the 1-indexed line number of the opening fence
94        let block_line = line_offset + 1;
95
96        // Filter rules based on inline config at this block's location
97        let block_rules: Vec<&Box<dyn Rule>> = rules
98            .iter()
99            .filter(|rule| !inline_config.is_rule_disabled(rule.name(), block_line))
100            .collect();
101
102        let (stripped_content, _common_indent) = strip_common_indent(block_content);
103
104        // Recursively check nested markdown blocks
105        let block_rules_owned: Vec<Box<dyn Rule>> = block_rules.iter().map(|r| dyn_clone::clone_box(&***r)).collect();
106        let nested_warnings =
107            check_embedded_markdown_blocks_recursive(&stripped_content, &block_rules_owned, config, depth + 1);
108
109        // Adjust nested warning line numbers
110        for mut warning in nested_warnings {
111            warning.line += line_offset;
112            warning.end_line += line_offset;
113            warning.fix = None;
114            all_warnings.push(warning);
115        }
116
117        // Lint the embedded content, skipping file-scoped rules
118        let ctx = LintContext::new(&stripped_content, config.markdown_flavor(), None);
119        for rule in &block_rules {
120            match rule.name() {
121                "MD041" => continue, // "First line in file should be heading" - not a file
122                "MD047" => continue, // "File should end with newline" - not a file
123                _ => {}
124            }
125
126            if let Ok(rule_warnings) = rule.check(&ctx) {
127                for warning in rule_warnings {
128                    let adjusted_warning = LintWarning {
129                        message: warning.message.clone(),
130                        line: warning.line + line_offset,
131                        column: warning.column,
132                        end_line: warning.end_line + line_offset,
133                        end_column: warning.end_column,
134                        severity: warning.severity,
135                        fix: None,
136                        rule_name: warning.rule_name,
137                    };
138                    all_warnings.push(adjusted_warning);
139                }
140            }
141        }
142    }
143
144    all_warnings
145}
146
147/// Strip common leading indentation from all non-empty lines.
148/// Returns the stripped content and the common indent string.
149pub fn strip_common_indent(content: &str) -> (String, String) {
150    let lines: Vec<&str> = content.lines().collect();
151    let has_trailing_newline = content.ends_with('\n');
152
153    let min_indent = lines
154        .iter()
155        .filter(|line| !line.trim().is_empty())
156        .map(|line| line.len() - line.trim_start().len())
157        .min()
158        .unwrap_or(0);
159
160    let mut stripped: String = lines
161        .iter()
162        .map(|line| {
163            if line.trim().is_empty() {
164                ""
165            } else if line.len() >= min_indent {
166                &line[min_indent..]
167            } else {
168                line.trim_start()
169            }
170        })
171        .collect::<Vec<_>>()
172        .join("\n");
173
174    if has_trailing_newline && !stripped.ends_with('\n') {
175        stripped.push('\n');
176    }
177
178    let indent_str = " ".repeat(min_indent);
179    (stripped, indent_str)
180}