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