Skip to main content

mdlint/lint/rules/
md048.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use serde_json::Value;
5
6pub struct MD048;
7
8impl Rule for MD048 {
9    fn name(&self) -> &'static str {
10        "MD048"
11    }
12
13    fn description(&self) -> &'static str {
14        "Code fence style"
15    }
16
17    fn tags(&self) -> &[&str] {
18        &["code"]
19    }
20
21    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
22        let style = config
23            .and_then(|c| c.get("style"))
24            .and_then(|v| v.as_str())
25            .unwrap_or("backtick");
26
27        let mut violations = Vec::new();
28        let mut first_style: Option<char> = None;
29        let mut in_code_block = false;
30
31        for (line_num, line) in parser.lines().iter().enumerate() {
32            let line_number = line_num + 1;
33            let trimmed = line.trim();
34
35            // Check if line is a code fence (opening or closing)
36            if trimmed.starts_with("```") {
37                // Only check opening fence, not closing
38                if !in_code_block {
39                    let fence_char = '`';
40                    if style == "consistent" {
41                        if let Some(first) = first_style {
42                            if fence_char != first {
43                                violations.push(Violation {
44                                    line: line_number,
45                                    column: Some(1),
46                                    rule: self.name().to_owned(),
47                                    message: format!(
48                                        "Code fence style should be consistent: expected '{first}', found '{fence_char}'"
49                                    ),
50                                    fix: None,
51                                });
52                            }
53                        } else {
54                            first_style = Some(fence_char);
55                        }
56                    } else if style == "tilde" {
57                        violations.push(Violation {
58                            line: line_number,
59                            column: Some(1),
60                            rule: self.name().to_owned(),
61                            message: "Code fence style should be 'tilde' (~), found backtick (`)"
62                                .to_owned(),
63                            fix: None,
64                        });
65                    }
66                }
67                in_code_block = !in_code_block;
68            } else if trimmed.starts_with("~~~") {
69                // Only check opening fence, not closing
70                if !in_code_block {
71                    let fence_char = '~';
72                    if style == "consistent" {
73                        if let Some(first) = first_style {
74                            if fence_char != first {
75                                violations.push(Violation {
76                                    line: line_number,
77                                    column: Some(1),
78                                    rule: self.name().to_owned(),
79                                    message: format!(
80                                        "Code fence style should be consistent: expected '{first}', found '{fence_char}'"
81                                    ),
82                                    fix: None,
83                                });
84                            }
85                        } else {
86                            first_style = Some(fence_char);
87                        }
88                    } else if style == "backtick" {
89                        violations.push(Violation {
90                            line: line_number,
91                            column: Some(1),
92                            rule: self.name().to_owned(),
93                            message: "Code fence style should be 'backtick' (`), found tilde (~)"
94                                .to_owned(),
95                            fix: None,
96                        });
97                    }
98                }
99                in_code_block = !in_code_block;
100            }
101        }
102
103        violations
104    }
105
106    fn fixable(&self) -> bool {
107        false
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_consistent_backtick() {
117        let content = "```\ncode1\n```\n\n```\ncode2\n```";
118        let parser = MarkdownParser::new(content);
119        let rule = MD048;
120        let violations = rule.check(&parser, None);
121
122        assert_eq!(violations.len(), 0);
123    }
124
125    #[test]
126    fn test_consistent_tilde() {
127        let content = "~~~\ncode1\n~~~\n\n~~~\ncode2\n~~~";
128        let parser = MarkdownParser::new(content);
129        let rule = MD048;
130        let config = serde_json::json!({ "style": "consistent" });
131        let violations = rule.check(&parser, Some(&config));
132
133        assert_eq!(violations.len(), 0);
134    }
135
136    #[test]
137    fn test_inconsistent() {
138        let content = "```\ncode1\n```\n\n~~~\ncode2\n~~~";
139        let parser = MarkdownParser::new(content);
140        let rule = MD048;
141        let violations = rule.check(&parser, None);
142
143        assert_eq!(violations.len(), 1); // Only opening of second block
144    }
145
146    #[test]
147    fn test_enforced_backtick() {
148        let content = "~~~\ncode\n~~~";
149        let parser = MarkdownParser::new(content);
150        let rule = MD048;
151        let config = serde_json::json!({ "style": "backtick" });
152        let violations = rule.check(&parser, Some(&config));
153
154        assert_eq!(violations.len(), 1); // Only opening
155    }
156}