rumdl_lib/rules/
md048_code_fence_style.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rules::code_fence_utils::CodeFenceStyle;
3use crate::utils::range_utils::calculate_match_range;
4use toml;
5
6mod md048_config;
7use md048_config::MD048Config;
8
9/// Rule MD048: Code fence style
10///
11/// See [docs/md048.md](../../docs/md048.md) for full documentation, configuration, and examples.
12#[derive(Clone)]
13pub struct MD048CodeFenceStyle {
14    config: MD048Config,
15}
16
17impl MD048CodeFenceStyle {
18    pub fn new(style: CodeFenceStyle) -> Self {
19        Self {
20            config: MD048Config { style },
21        }
22    }
23
24    pub fn from_config_struct(config: MD048Config) -> Self {
25        Self { config }
26    }
27
28    fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<CodeFenceStyle> {
29        // Count occurrences of each fence style (prevalence-based approach)
30        let mut backtick_count = 0;
31        let mut tilde_count = 0;
32        let mut in_code_block = false;
33
34        for line in ctx.content.lines() {
35            let trimmed = line.trim_start();
36
37            // Check for code fence markers
38            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
39                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
40
41                if !in_code_block {
42                    // Opening fence - count it
43                    if fence_char == '`' {
44                        backtick_count += 1;
45                    } else {
46                        tilde_count += 1;
47                    }
48                    in_code_block = true;
49                } else {
50                    // Potential closing fence - exit code block
51                    in_code_block = false;
52                }
53            }
54        }
55
56        // Use the most prevalent style
57        // In case of a tie, prefer backticks (more common, widely supported)
58        if backtick_count >= tilde_count && backtick_count > 0 {
59            Some(CodeFenceStyle::Backtick)
60        } else if tilde_count > 0 {
61            Some(CodeFenceStyle::Tilde)
62        } else {
63            None
64        }
65    }
66}
67
68impl Rule for MD048CodeFenceStyle {
69    fn name(&self) -> &'static str {
70        "MD048"
71    }
72
73    fn description(&self) -> &'static str {
74        "Code fence style should be consistent"
75    }
76
77    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
78        let content = ctx.content;
79        let _line_index = &ctx.line_index;
80
81        let mut warnings = Vec::new();
82
83        let target_style = match self.config.style {
84            CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
85            _ => self.config.style,
86        };
87
88        // Track if we're inside a code block
89        let mut in_code_block = false;
90        let mut code_block_fence = String::new();
91
92        for (line_num, line) in content.lines().enumerate() {
93            let trimmed = line.trim_start();
94
95            // Check for code fence markers
96            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
97                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
98                let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
99                let current_fence = fence_char.to_string().repeat(fence_length);
100
101                if !in_code_block {
102                    // Entering a code block
103                    in_code_block = true;
104                    code_block_fence = current_fence.clone();
105
106                    // Check this opening fence
107                    if trimmed.starts_with("```") && target_style == CodeFenceStyle::Tilde {
108                        // Find the position and length of the backtick fence
109                        let fence_start = line.len() - trimmed.len();
110                        let fence_end = fence_start + trimmed.find(|c: char| c != '`').unwrap_or(trimmed.len());
111
112                        // Calculate precise character range for the entire fence
113                        let (start_line, start_col, end_line, end_col) =
114                            calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
115
116                        warnings.push(LintWarning {
117                            rule_name: Some(self.name().to_string()),
118                            message: "Code fence style: use ~~~ instead of ```".to_string(),
119                            line: start_line,
120                            column: start_col,
121                            end_line,
122                            end_column: end_col,
123                            severity: Severity::Warning,
124                            fix: Some(Fix {
125                                range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
126                                replacement: line.replace("```", "~~~"),
127                            }),
128                        });
129                    } else if trimmed.starts_with("~~~") && target_style == CodeFenceStyle::Backtick {
130                        // Find the position and length of the tilde fence
131                        let fence_start = line.len() - trimmed.len();
132                        let fence_end = fence_start + trimmed.find(|c: char| c != '~').unwrap_or(trimmed.len());
133
134                        // Calculate precise character range for the entire fence
135                        let (start_line, start_col, end_line, end_col) =
136                            calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
137
138                        warnings.push(LintWarning {
139                            rule_name: Some(self.name().to_string()),
140                            message: "Code fence style: use ``` instead of ~~~".to_string(),
141                            line: start_line,
142                            column: start_col,
143                            end_line,
144                            end_column: end_col,
145                            severity: Severity::Warning,
146                            fix: Some(Fix {
147                                range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
148                                replacement: line.replace("~~~", "```"),
149                            }),
150                        });
151                    }
152                } else if trimmed.starts_with(&code_block_fence) && trimmed[code_block_fence.len()..].trim().is_empty()
153                {
154                    // Exiting the code block - check this closing fence too
155                    if trimmed.starts_with("```") && target_style == CodeFenceStyle::Tilde {
156                        // Find the position and length of the backtick fence
157                        let fence_start = line.len() - trimmed.len();
158                        let fence_end = fence_start + trimmed.find(|c: char| c != '`').unwrap_or(trimmed.len());
159
160                        // Calculate precise character range for the entire fence
161                        let (start_line, start_col, end_line, end_col) =
162                            calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
163
164                        warnings.push(LintWarning {
165                            rule_name: Some(self.name().to_string()),
166                            message: "Code fence style: use ~~~ instead of ```".to_string(),
167                            line: start_line,
168                            column: start_col,
169                            end_line,
170                            end_column: end_col,
171                            severity: Severity::Warning,
172                            fix: Some(Fix {
173                                range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
174                                replacement: line.replace("```", "~~~"),
175                            }),
176                        });
177                    } else if trimmed.starts_with("~~~") && target_style == CodeFenceStyle::Backtick {
178                        // Find the position and length of the tilde fence
179                        let fence_start = line.len() - trimmed.len();
180                        let fence_end = fence_start + trimmed.find(|c: char| c != '~').unwrap_or(trimmed.len());
181
182                        // Calculate precise character range for the entire fence
183                        let (start_line, start_col, end_line, end_col) =
184                            calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
185
186                        warnings.push(LintWarning {
187                            rule_name: Some(self.name().to_string()),
188                            message: "Code fence style: use ``` instead of ~~~".to_string(),
189                            line: start_line,
190                            column: start_col,
191                            end_line,
192                            end_column: end_col,
193                            severity: Severity::Warning,
194                            fix: Some(Fix {
195                                range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
196                                replacement: line.replace("~~~", "```"),
197                            }),
198                        });
199                    }
200
201                    in_code_block = false;
202                    code_block_fence.clear();
203                }
204                // If it's a fence inside a code block, skip it
205            }
206        }
207
208        Ok(warnings)
209    }
210
211    /// Check if this rule should be skipped for performance
212    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
213        // Skip if content is empty or has no code fence markers
214        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
215    }
216
217    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
218        let content = ctx.content;
219
220        let target_style = match self.config.style {
221            CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
222            _ => self.config.style,
223        };
224
225        let mut result = String::new();
226        let mut in_code_block = false;
227        let mut code_block_fence = String::new();
228
229        for line in content.lines() {
230            let trimmed = line.trim_start();
231
232            // Check for code fence markers
233            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
234                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
235                let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
236                let current_fence = fence_char.to_string().repeat(fence_length);
237
238                if !in_code_block {
239                    // Entering a code block
240                    in_code_block = true;
241                    code_block_fence = current_fence.clone();
242
243                    // Fix this opening fence
244                    if trimmed.starts_with("```") && target_style == CodeFenceStyle::Tilde {
245                        // Replace all backticks with tildes, preserving the count
246                        let prefix = &line[..line.len() - trimmed.len()];
247                        let rest = &trimmed[fence_length..];
248                        result.push_str(prefix);
249                        result.push_str(&"~".repeat(fence_length));
250                        result.push_str(rest);
251                    } else if trimmed.starts_with("~~~") && target_style == CodeFenceStyle::Backtick {
252                        // Replace all tildes with backticks, preserving the count
253                        let prefix = &line[..line.len() - trimmed.len()];
254                        let rest = &trimmed[fence_length..];
255                        result.push_str(prefix);
256                        result.push_str(&"`".repeat(fence_length));
257                        result.push_str(rest);
258                    } else {
259                        result.push_str(line);
260                    }
261                } else if trimmed.starts_with(&code_block_fence) && trimmed[code_block_fence.len()..].trim().is_empty()
262                {
263                    // Exiting the code block - fix this closing fence too
264                    if trimmed.starts_with("```") && target_style == CodeFenceStyle::Tilde {
265                        // Replace all backticks with tildes, preserving the count
266                        let prefix = &line[..line.len() - trimmed.len()];
267                        let fence_length = trimmed.chars().take_while(|&c| c == '`').count();
268                        let rest = &trimmed[fence_length..];
269                        result.push_str(prefix);
270                        result.push_str(&"~".repeat(fence_length));
271                        result.push_str(rest);
272                    } else if trimmed.starts_with("~~~") && target_style == CodeFenceStyle::Backtick {
273                        // Replace all tildes with backticks, preserving the count
274                        let prefix = &line[..line.len() - trimmed.len()];
275                        let fence_length = trimmed.chars().take_while(|&c| c == '~').count();
276                        let rest = &trimmed[fence_length..];
277                        result.push_str(prefix);
278                        result.push_str(&"`".repeat(fence_length));
279                        result.push_str(rest);
280                    } else {
281                        result.push_str(line);
282                    }
283
284                    in_code_block = false;
285                    code_block_fence.clear();
286                } else {
287                    // Inside a code block - don't fix nested fences
288                    result.push_str(line);
289                }
290            } else {
291                result.push_str(line);
292            }
293            result.push('\n');
294        }
295
296        // Remove the last newline if the original content didn't end with one
297        if !content.ends_with('\n') && result.ends_with('\n') {
298            result.pop();
299        }
300
301        Ok(result)
302    }
303
304    fn as_any(&self) -> &dyn std::any::Any {
305        self
306    }
307
308    fn default_config_section(&self) -> Option<(String, toml::Value)> {
309        let json_value = serde_json::to_value(&self.config).ok()?;
310        Some((
311            self.name().to_string(),
312            crate::rule_config_serde::json_to_toml_value(&json_value)?,
313        ))
314    }
315
316    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
317    where
318        Self: Sized,
319    {
320        let rule_config = crate::rule_config_serde::load_rule_config::<MD048Config>(config);
321        Box::new(Self::from_config_struct(rule_config))
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::lint_context::LintContext;
329
330    #[test]
331    fn test_backtick_style_with_backticks() {
332        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
333        let content = "```\ncode\n```";
334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
335        let result = rule.check(&ctx).unwrap();
336
337        assert_eq!(result.len(), 0);
338    }
339
340    #[test]
341    fn test_backtick_style_with_tildes() {
342        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
343        let content = "~~~\ncode\n~~~";
344        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
345        let result = rule.check(&ctx).unwrap();
346
347        assert_eq!(result.len(), 2); // Opening and closing fence
348        assert!(result[0].message.contains("use ``` instead of ~~~"));
349        assert_eq!(result[0].line, 1);
350        assert_eq!(result[1].line, 3);
351    }
352
353    #[test]
354    fn test_tilde_style_with_tildes() {
355        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
356        let content = "~~~\ncode\n~~~";
357        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
358        let result = rule.check(&ctx).unwrap();
359
360        assert_eq!(result.len(), 0);
361    }
362
363    #[test]
364    fn test_tilde_style_with_backticks() {
365        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
366        let content = "```\ncode\n```";
367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
368        let result = rule.check(&ctx).unwrap();
369
370        assert_eq!(result.len(), 2); // Opening and closing fence
371        assert!(result[0].message.contains("use ~~~ instead of ```"));
372    }
373
374    #[test]
375    fn test_consistent_style_tie_prefers_backtick() {
376        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
377        // One backtick fence and one tilde fence - tie should prefer backticks
378        let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
380        let result = rule.check(&ctx).unwrap();
381
382        // Backticks win due to tie-breaker, so tildes should be flagged
383        assert_eq!(result.len(), 2);
384        assert_eq!(result[0].line, 5);
385        assert_eq!(result[1].line, 7);
386    }
387
388    #[test]
389    fn test_consistent_style_tilde_most_prevalent() {
390        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
391        // Two tilde fences and one backtick fence - tildes are most prevalent
392        let content = "~~~\ncode\n~~~\n\n```\nmore code\n```\n\n~~~\neven more\n~~~";
393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
394        let result = rule.check(&ctx).unwrap();
395
396        // Tildes are most prevalent, so backticks should be flagged
397        assert_eq!(result.len(), 2);
398        assert_eq!(result[0].line, 5);
399        assert_eq!(result[1].line, 7);
400    }
401
402    #[test]
403    fn test_detect_style_backtick() {
404        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
405        let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard);
406        let style = rule.detect_style(&ctx);
407
408        assert_eq!(style, Some(CodeFenceStyle::Backtick));
409    }
410
411    #[test]
412    fn test_detect_style_tilde() {
413        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
414        let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard);
415        let style = rule.detect_style(&ctx);
416
417        assert_eq!(style, Some(CodeFenceStyle::Tilde));
418    }
419
420    #[test]
421    fn test_detect_style_none() {
422        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
423        let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard);
424        let style = rule.detect_style(&ctx);
425
426        assert_eq!(style, None);
427    }
428
429    #[test]
430    fn test_fix_backticks_to_tildes() {
431        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
432        let content = "```\ncode\n```";
433        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
434        let fixed = rule.fix(&ctx).unwrap();
435
436        assert_eq!(fixed, "~~~\ncode\n~~~");
437    }
438
439    #[test]
440    fn test_fix_tildes_to_backticks() {
441        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
442        let content = "~~~\ncode\n~~~";
443        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
444        let fixed = rule.fix(&ctx).unwrap();
445
446        assert_eq!(fixed, "```\ncode\n```");
447    }
448
449    #[test]
450    fn test_fix_preserves_fence_length() {
451        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
452        let content = "````\ncode with backtick\n```\ncode\n````";
453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
454        let fixed = rule.fix(&ctx).unwrap();
455
456        assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
457    }
458
459    #[test]
460    fn test_fix_preserves_language_info() {
461        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
462        let content = "~~~rust\nfn main() {}\n~~~";
463        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464        let fixed = rule.fix(&ctx).unwrap();
465
466        assert_eq!(fixed, "```rust\nfn main() {}\n```");
467    }
468
469    #[test]
470    fn test_indented_code_fences() {
471        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
472        let content = "  ```\n  code\n  ```";
473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
474        let result = rule.check(&ctx).unwrap();
475
476        assert_eq!(result.len(), 2);
477    }
478
479    #[test]
480    fn test_fix_indented_fences() {
481        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
482        let content = "  ```\n  code\n  ```";
483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
484        let fixed = rule.fix(&ctx).unwrap();
485
486        assert_eq!(fixed, "  ~~~\n  code\n  ~~~");
487    }
488
489    #[test]
490    fn test_nested_fences_not_changed() {
491        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
492        let content = "```\ncode with ``` inside\n```";
493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
494        let fixed = rule.fix(&ctx).unwrap();
495
496        assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
497    }
498
499    #[test]
500    fn test_multiple_code_blocks() {
501        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
502        let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
504        let result = rule.check(&ctx).unwrap();
505
506        assert_eq!(result.len(), 4); // 2 opening + 2 closing fences
507    }
508
509    #[test]
510    fn test_empty_content() {
511        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
512        let content = "";
513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
514        let result = rule.check(&ctx).unwrap();
515
516        assert_eq!(result.len(), 0);
517    }
518
519    #[test]
520    fn test_preserve_trailing_newline() {
521        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
522        let content = "~~~\ncode\n~~~\n";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
524        let fixed = rule.fix(&ctx).unwrap();
525
526        assert_eq!(fixed, "```\ncode\n```\n");
527    }
528
529    #[test]
530    fn test_no_trailing_newline() {
531        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
532        let content = "~~~\ncode\n~~~";
533        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
534        let fixed = rule.fix(&ctx).unwrap();
535
536        assert_eq!(fixed, "```\ncode\n```");
537    }
538
539    #[test]
540    fn test_default_config() {
541        let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
542        let (name, _config) = rule.default_config_section().unwrap();
543        assert_eq!(name, "MD048");
544    }
545}