rumdl_lib/
embedded_lint.rs1use 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
14pub const MAX_EMBEDDED_DEPTH: usize = 5;
19
20pub fn should_lint_embedded_markdown(config: &CodeBlockToolsConfig) -> bool {
25 if !config.enabled {
26 return false;
27 }
28
29 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
42pub 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
58pub fn has_fenced_code_blocks(content: &str) -> bool {
60 content.contains("```") || content.contains("~~~")
61}
62
63pub 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
76fn 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 let line_offset = content[..block.content_start].matches('\n').count();
108
109 let block_line = line_offset + 1;
111
112 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 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 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 let ctx = LintContext::new(&stripped_content, config.markdown_flavor(), None);
135 for rule in &block_rules {
136 match rule.name() {
137 "MD041" => continue, "MD047" => continue, _ => {}
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
163pub 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}