mdbook_lint_core/rules/standard/
md048.rs

1//! MD048: Code fence style consistency
2//!
3//! This rule checks that fenced code blocks use a consistent fence style (backticks vs tildes) throughout the document.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13/// Rule to check code fence style consistency
14pub struct MD048 {
15    /// Preferred fence style
16    style: FenceStyle,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum FenceStyle {
21    /// Use backticks (```)
22    Backtick,
23    /// Use tildes (~~~)
24    Tilde,
25    /// Detect from first usage in document
26    Consistent,
27}
28
29impl MD048 {
30    /// Create a new MD048 rule with consistent style detection
31    pub fn new() -> Self {
32        Self {
33            style: FenceStyle::Consistent,
34        }
35    }
36
37    /// Create a new MD048 rule with specific style preference
38    #[allow(dead_code)]
39    pub fn with_style(style: FenceStyle) -> Self {
40        Self { style }
41    }
42
43    /// Determine the fence style of a code block
44    fn get_fence_style(&self, node: &AstNode) -> Option<FenceStyle> {
45        if let NodeValue::CodeBlock(code_block) = &node.data.borrow().value {
46            if code_block.fenced {
47                // Check the fence character - comrak stores the fence info
48                if code_block.fence_char as char == '`' {
49                    Some(FenceStyle::Backtick)
50                } else if code_block.fence_char as char == '~' {
51                    Some(FenceStyle::Tilde)
52                } else {
53                    None
54                }
55            } else {
56                // Not a fenced code block, ignore
57                None
58            }
59        } else {
60            None
61        }
62    }
63
64    /// Get line and column position for a node
65    fn get_position<'a>(&self, node: &'a AstNode<'a>) -> (usize, usize) {
66        let data = node.data.borrow();
67        let pos = data.sourcepos;
68        (pos.start.line, pos.start.column)
69    }
70
71    /// Walk AST and find all fence style violations
72    fn check_node<'a>(
73        &self,
74        node: &'a AstNode<'a>,
75        violations: &mut Vec<Violation>,
76        expected_style: &mut Option<FenceStyle>,
77    ) {
78        if let NodeValue::CodeBlock(_) = &node.data.borrow().value
79            && let Some(current_style) = self.get_fence_style(node)
80        {
81            if let Some(expected) = expected_style {
82                // Check consistency with established style
83                if *expected != current_style {
84                    let (line, column) = self.get_position(node);
85                    let expected_char = match expected {
86                        FenceStyle::Backtick => "`",
87                        FenceStyle::Tilde => "~",
88                        FenceStyle::Consistent => "consistent", // shouldn't happen
89                    };
90                    let found_char = match current_style {
91                        FenceStyle::Backtick => "`",
92                        FenceStyle::Tilde => "~",
93                        FenceStyle::Consistent => "consistent", // shouldn't happen
94                    };
95
96                    violations.push(self.create_violation(
97                            format!(
98                                "Code fence style inconsistent - expected '{expected_char}' but found '{found_char}'"
99                            ),
100                            line,
101                            column,
102                            Severity::Warning,
103                        ));
104                }
105            } else {
106                // First fenced code block found - establish the style
107                match self.style {
108                    FenceStyle::Backtick => *expected_style = Some(FenceStyle::Backtick),
109                    FenceStyle::Tilde => *expected_style = Some(FenceStyle::Tilde),
110                    FenceStyle::Consistent => *expected_style = Some(current_style),
111                }
112            }
113        }
114
115        // Recursively check children
116        for child in node.children() {
117            self.check_node(child, violations, expected_style);
118        }
119    }
120}
121
122impl Default for MD048 {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl AstRule for MD048 {
129    fn id(&self) -> &'static str {
130        "MD048"
131    }
132
133    fn name(&self) -> &'static str {
134        "code-fence-style"
135    }
136
137    fn description(&self) -> &'static str {
138        "Code fence style should be consistent"
139    }
140
141    fn metadata(&self) -> RuleMetadata {
142        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
143    }
144
145    fn check_ast<'a>(&self, _document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
146        let mut violations = Vec::new();
147        let mut expected_style = match self.style {
148            FenceStyle::Backtick => Some(FenceStyle::Backtick),
149            FenceStyle::Tilde => Some(FenceStyle::Tilde),
150            FenceStyle::Consistent => None, // Detect from first usage
151        };
152
153        self.check_node(ast, &mut violations, &mut expected_style);
154        Ok(violations)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::rule::Rule;
162    use std::path::PathBuf;
163
164    fn create_test_document(content: &str) -> Document {
165        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
166    }
167
168    #[test]
169    fn test_md048_consistent_backtick_style() {
170        let content = r#"Here is some backtick fenced code:
171
172```rust
173fn main() {
174    println!("Hello");
175}
176```
177
178And another backtick block:
179
180```python
181print("Hello")
182```
183"#;
184
185        let document = create_test_document(content);
186        let rule = MD048::new();
187        let violations = rule.check(&document).unwrap();
188        assert_eq!(violations.len(), 0);
189    }
190
191    #[test]
192    fn test_md048_consistent_tilde_style() {
193        let content = r#"Here is some tilde fenced code:
194
195~~~rust
196fn main() {
197    println!("Hello");
198}
199~~~
200
201And another tilde block:
202
203~~~python
204print("Hello")
205~~~
206"#;
207
208        let document = create_test_document(content);
209        let rule = MD048::new();
210        let violations = rule.check(&document).unwrap();
211        assert_eq!(violations.len(), 0);
212    }
213
214    #[test]
215    fn test_md048_mixed_fence_styles_violation() {
216        let content = r#"Here is backtick fenced code:
217
218```rust
219fn main() {
220    println!("Hello");
221}
222```
223
224And here is tilde fenced code:
225
226~~~python
227print("Hello")
228~~~
229"#;
230
231        let document = create_test_document(content);
232        let rule = MD048::new();
233        let violations = rule.check(&document).unwrap();
234        assert_eq!(violations.len(), 1);
235        assert_eq!(violations[0].rule_id, "MD048");
236        assert!(violations[0].message.contains("expected '`' but found '~'"));
237    }
238
239    #[test]
240    fn test_md048_preferred_backtick_style() {
241        let content = r#"Here is tilde fenced code:
242
243~~~rust
244fn main() {}
245~~~
246"#;
247
248        let document = create_test_document(content);
249        let rule = MD048::with_style(FenceStyle::Backtick);
250        let violations = rule.check(&document).unwrap();
251        assert_eq!(violations.len(), 1);
252        assert!(violations[0].message.contains("expected '`' but found '~'"));
253    }
254
255    #[test]
256    fn test_md048_preferred_tilde_style() {
257        let content = r#"Here is backtick fenced code:
258
259```rust
260fn main() {}
261```
262"#;
263
264        let document = create_test_document(content);
265        let rule = MD048::with_style(FenceStyle::Tilde);
266        let violations = rule.check(&document).unwrap();
267        assert_eq!(violations.len(), 1);
268        assert!(violations[0].message.contains("expected '~' but found '`'"));
269    }
270
271    #[test]
272    fn test_md048_indented_code_blocks_ignored() {
273        let content = r#"Backtick fenced:
274
275```rust
276fn main() {}
277```
278
279Indented code (should be ignored):
280
281    print("hello")
282
283Tilde fenced (should be flagged):
284
285~~~python
286print("world")
287~~~
288"#;
289
290        let document = create_test_document(content);
291        let rule = MD048::new();
292        let violations = rule.check(&document).unwrap();
293        assert_eq!(violations.len(), 1);
294        assert!(violations[0].message.contains("expected '`' but found '~'"));
295    }
296
297    #[test]
298    fn test_md048_multiple_backtick_blocks() {
299        let content = r#"First block:
300
301```rust
302fn main() {}
303```
304
305Second block:
306
307```python
308print("hello")
309```
310
311Third block:
312
313```javascript
314console.log("hello");
315```
316"#;
317
318        let document = create_test_document(content);
319        let rule = MD048::new();
320        let violations = rule.check(&document).unwrap();
321        assert_eq!(violations.len(), 0);
322    }
323
324    #[test]
325    fn test_md048_multiple_tilde_blocks() {
326        let content = r#"First block:
327
328~~~rust
329fn main() {}
330~~~
331
332Second block:
333
334~~~python
335print("hello")
336~~~
337
338Third block:
339
340~~~javascript
341console.log("hello");
342~~~
343"#;
344
345        let document = create_test_document(content);
346        let rule = MD048::new();
347        let violations = rule.check(&document).unwrap();
348        assert_eq!(violations.len(), 0);
349    }
350
351    #[test]
352    fn test_md048_mixed_multiple_violations() {
353        let content = r#"Start with backticks:
354
355```rust
356fn main() {}
357```
358
359Then tildes (violation):
360
361~~~python
362print("hello")
363~~~
364
365Then backticks again:
366
367```javascript
368console.log("hello");
369```
370
371And tildes again (violation):
372
373~~~bash
374echo "hello"
375~~~
376"#;
377
378        let document = create_test_document(content);
379        let rule = MD048::new();
380        let violations = rule.check(&document).unwrap();
381        assert_eq!(violations.len(), 2);
382        assert!(violations[0].message.contains("expected '`' but found '~'"));
383        assert!(violations[1].message.contains("expected '`' but found '~'"));
384    }
385
386    #[test]
387    fn test_md048_no_fenced_code_blocks() {
388        let content = r#"This document has no fenced code blocks.
389
390Just regular text and paragraphs.
391
392    This is indented code, not fenced.
393
394And maybe some `inline code` but no fenced blocks.
395"#;
396
397        let document = create_test_document(content);
398        let rule = MD048::new();
399        let violations = rule.check(&document).unwrap();
400        assert_eq!(violations.len(), 0);
401    }
402
403    #[test]
404    fn test_md048_tilde_first_determines_style() {
405        let content = r#"Start with tildes:
406
407~~~rust
408fn main() {}
409~~~
410
411Then backticks should be flagged:
412
413```python
414print("hello")
415```
416"#;
417
418        let document = create_test_document(content);
419        let rule = MD048::new();
420        let violations = rule.check(&document).unwrap();
421        assert_eq!(violations.len(), 1);
422        assert!(violations[0].message.contains("expected '~' but found '`'"));
423    }
424
425    #[test]
426    fn test_md048_with_languages() {
427        let content = r#"Different languages, same fence style:
428
429```rust
430fn main() {}
431```
432
433```python
434def hello():
435    pass
436```
437
438```javascript
439function hello() {}
440```
441"#;
442
443        let document = create_test_document(content);
444        let rule = MD048::new();
445        let violations = rule.check(&document).unwrap();
446        assert_eq!(violations.len(), 0);
447    }
448}