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, is_rumdl_builtin};
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 a built-in rumdl tool is in the lint slot for markdown/md,
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| is_rumdl_builtin(tool))
34        {
35            return true;
36        }
37    }
38
39    false
40}
41
42/// Enable formatting independently of linting for explicit tool variants.
43/// The legacy `lint = ["rumdl"]` setting continues to enable both phases.
44pub fn should_format_embedded_markdown(config: &CodeBlockToolsConfig) -> bool {
45    config.enabled
46        && ["markdown", "md"].iter().any(|lang| {
47            config.languages.get(*lang).is_some_and(|language| {
48                language.enabled
49                    && (language
50                        .format
51                        .iter()
52                        .any(|tool| matches!(tool.as_str(), "rumdl" | "rumdl:format"))
53                        || language.lint.iter().any(|tool| tool == RUMDL_BUILTIN_TOOL))
54            })
55        })
56}
57
58/// Check if content contains fenced code block markers.
59pub fn has_fenced_code_blocks(content: &str) -> bool {
60    content.contains("```") || content.contains("~~~")
61}
62
63/// Check markdown content embedded in fenced code blocks with `markdown` or `md` language.
64///
65/// Detects markdown code blocks and runs lint checks on their content,
66/// returning warnings with adjusted line numbers that point to the correct location
67/// in the parent file.
68pub fn check_embedded_markdown_blocks(
69    content: &str,
70    rules: &[Box<dyn Rule>],
71    config: &rumdl_config::Config,
72) -> Vec<LintWarning> {
73    check_embedded_markdown_blocks_recursive(content, rules, config, 0)
74}
75
76/// Internal recursive implementation with depth tracking.
77fn check_embedded_markdown_blocks_recursive(
78    content: &str,
79    rules: &[Box<dyn Rule>],
80    config: &rumdl_config::Config,
81    depth: usize,
82) -> Vec<LintWarning> {
83    if depth >= MAX_EMBEDDED_DEPTH {
84        return Vec::new();
85    }
86    if !has_fenced_code_blocks(content) {
87        return Vec::new();
88    }
89
90    let blocks = CodeBlockUtils::detect_markdown_code_blocks(content);
91
92    if blocks.is_empty() {
93        return Vec::new();
94    }
95
96    let inline_config = InlineConfig::from_content(content);
97    let mut all_warnings = Vec::new();
98
99    for block in blocks {
100        let block_content = &content[block.content_start..block.content_end];
101
102        if block_content.trim().is_empty() {
103            continue;
104        }
105
106        // Calculate the line offset for this block
107        let line_offset = content[..block.content_start].matches('\n').count();
108
109        // Compute the 1-indexed line number of the opening fence
110        let block_line = line_offset + 1;
111
112        // Filter rules based on inline config at this block's location
113        let block_rules: Vec<&Box<dyn Rule>> = rules
114            .iter()
115            .filter(|rule| !inline_config.is_rule_disabled(rule.name(), block_line))
116            .collect();
117
118        let (stripped_content, _common_indent) = strip_common_indent(block_content);
119
120        // Recursively check nested markdown blocks
121        let block_rules_owned: Vec<Box<dyn Rule>> = block_rules.iter().map(|r| dyn_clone::clone_box(&***r)).collect();
122        let nested_warnings =
123            check_embedded_markdown_blocks_recursive(&stripped_content, &block_rules_owned, config, depth + 1);
124
125        // Adjust nested warning line numbers
126        for mut warning in nested_warnings {
127            warning.line += line_offset;
128            warning.end_line += line_offset;
129            warning.fix = None;
130            all_warnings.push(warning);
131        }
132
133        // Lint the embedded content, skipping file-scoped rules
134        let ctx = LintContext::new(&stripped_content, config.markdown_flavor(), None);
135        for rule in &block_rules {
136            match rule.name() {
137                "MD041" => continue, // "First line in file should be heading" - not a file
138                "MD047" => continue, // "File should end with newline" - not a file
139                _ => {}
140            }
141
142            if let Ok(rule_warnings) = rule.check(&ctx) {
143                for warning in rule_warnings {
144                    let adjusted_warning = LintWarning {
145                        message: warning.message.clone(),
146                        line: warning.line + line_offset,
147                        column: warning.column,
148                        end_line: warning.end_line + line_offset,
149                        end_column: warning.end_column,
150                        severity: warning.severity,
151                        fix: None,
152                        rule_name: warning.rule_name,
153                    };
154                    all_warnings.push(adjusted_warning);
155                }
156            }
157        }
158    }
159
160    all_warnings
161}
162
163/// Strip common leading indentation from all non-empty lines.
164/// Returns the stripped content and the common indent string.
165pub fn strip_common_indent(content: &str) -> (String, String) {
166    let lines: Vec<&str> = content.lines().collect();
167    let has_trailing_newline = content.ends_with('\n');
168
169    let min_indent = lines
170        .iter()
171        .filter(|line| !line.trim().is_empty())
172        .map(|line| line.len() - line.trim_start().len())
173        .min()
174        .unwrap_or(0);
175
176    let mut stripped: String = lines
177        .iter()
178        .map(|line| {
179            if line.trim().is_empty() {
180                ""
181            } else if line.len() >= min_indent {
182                &line[min_indent..]
183            } else {
184                line.trim_start()
185            }
186        })
187        .collect::<Vec<_>>()
188        .join("\n");
189
190    if has_trailing_newline && !stripped.ends_with('\n') {
191        stripped.push('\n');
192    }
193
194    let indent_str = " ".repeat(min_indent);
195    (stripped, indent_str)
196}
197
198#[cfg(test)]
199mod activation_tests {
200    use super::*;
201    use crate::code_block_tools::LanguageToolConfig;
202
203    #[test]
204    fn explicit_variants_respect_master_and_language_switches() {
205        for language in ["markdown", "md"] {
206            for master in [false, true] {
207                for enabled in [false, true] {
208                    let mut config = CodeBlockToolsConfig {
209                        enabled: master,
210                        ..Default::default()
211                    };
212                    config.languages.insert(
213                        language.to_string(),
214                        LanguageToolConfig {
215                            enabled,
216                            lint: vec!["rumdl:lint".to_string()],
217                            format: vec!["rumdl:format".to_string()],
218                            ..Default::default()
219                        },
220                    );
221                    assert_eq!(should_lint_embedded_markdown(&config), master && enabled);
222                    assert_eq!(should_format_embedded_markdown(&config), master && enabled);
223                }
224            }
225        }
226    }
227}