mdbook_lint_core/rules/standard/
md046.rs

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