quickmark_core/rules/
md046.rs

1use serde::Deserialize;
2use std::rc::Rc;
3use tree_sitter::Node;
4
5use crate::linter::{CharPosition, Context, Range, RuleLinter, RuleViolation};
6
7use super::{Rule, RuleType};
8
9// MD046-specific configuration types
10#[derive(Debug, PartialEq, Clone, Deserialize)]
11pub enum CodeBlockStyle {
12    #[serde(rename = "consistent")]
13    Consistent,
14    #[serde(rename = "fenced")]
15    Fenced,
16    #[serde(rename = "indented")]
17    Indented,
18}
19
20impl Default for CodeBlockStyle {
21    fn default() -> Self {
22        Self::Consistent
23    }
24}
25
26#[derive(Debug, PartialEq, Clone, Deserialize)]
27pub struct MD046CodeBlockStyleTable {
28    #[serde(default)]
29    pub style: CodeBlockStyle,
30}
31
32impl Default for MD046CodeBlockStyleTable {
33    fn default() -> Self {
34        Self {
35            style: CodeBlockStyle::Consistent,
36        }
37    }
38}
39
40const VIOLATION_MESSAGE: &str = "Code block style";
41
42pub(crate) struct MD046Linter {
43    context: Rc<Context>,
44    violations: Vec<RuleViolation>,
45    expected_style: Option<CodeBlockStyle>,
46}
47
48impl MD046Linter {
49    pub fn new(context: Rc<Context>) -> Self {
50        Self {
51            context,
52            violations: Vec::new(),
53            expected_style: None,
54        }
55    }
56
57    fn analyze_all_code_blocks(&mut self) {
58        let configured_style = self
59            .context
60            .config
61            .linters
62            .settings
63            .code_block_style
64            .style
65            .clone();
66
67        let all_code_blocks = {
68            let node_cache = self.context.node_cache.borrow();
69            let mut all_code_blocks = Vec::new();
70
71            if let Some(fenced_blocks) = node_cache.get("fenced_code_block") {
72                all_code_blocks.extend(
73                    fenced_blocks
74                        .iter()
75                        .map(|n| (n.clone(), CodeBlockStyle::Fenced)),
76                );
77            }
78
79            if let Some(indented_blocks) = node_cache.get("indented_code_block") {
80                all_code_blocks.extend(
81                    indented_blocks
82                        .iter()
83                        .map(|n| (n.clone(), CodeBlockStyle::Indented)),
84                );
85            }
86
87            all_code_blocks.sort_by_key(|(node_info, _)| node_info.line_start);
88            all_code_blocks
89        };
90
91        for (node_info, block_style) in all_code_blocks {
92            self.check_code_block(&node_info, block_style, &configured_style);
93        }
94    }
95
96    fn check_code_block(
97        &mut self,
98        node_info: &crate::linter::NodeInfo,
99        block_style: CodeBlockStyle,
100        configured_style: &CodeBlockStyle,
101    ) {
102        let expected_style = if *configured_style == CodeBlockStyle::Consistent {
103            if self.expected_style.is_none() {
104                self.expected_style = Some(block_style.clone());
105            }
106            self.expected_style.as_ref().unwrap()
107        } else {
108            configured_style
109        };
110
111        if block_style != *expected_style {
112            let range = Range {
113                start: CharPosition {
114                    line: node_info.line_start,
115                    character: 0,
116                },
117                end: CharPosition {
118                    line: node_info.line_start,
119                    character: 0, // Will be updated with actual content
120                },
121            };
122
123            self.violations.push(RuleViolation::new(
124                &MD046,
125                VIOLATION_MESSAGE.to_string(),
126                self.context.file_path.clone(),
127                range,
128            ));
129        }
130    }
131}
132
133impl RuleLinter for MD046Linter {
134    fn feed(&mut self, _node: &Node) {
135        // This is a document-level rule. All processing is in `finalize`.
136    }
137
138    fn finalize(&mut self) -> Vec<RuleViolation> {
139        self.analyze_all_code_blocks();
140        std::mem::take(&mut self.violations)
141    }
142}
143
144pub const MD046: Rule = Rule {
145    id: "MD046",
146    alias: "code-block-style",
147    tags: &["code"],
148    description: "Code block style",
149    rule_type: RuleType::Document,
150    required_nodes: &["fenced_code_block", "indented_code_block"],
151    new_linter: |context| Box::new(MD046Linter::new(context)),
152};
153
154#[cfg(test)]
155mod test {
156    use std::path::PathBuf;
157
158    use crate::config::RuleSeverity;
159    use crate::linter::MultiRuleLinter;
160    use crate::test_utils::test_helpers::test_config_with_settings;
161
162    fn test_config() -> crate::config::QuickmarkConfig {
163        test_config_with_settings(
164            vec![
165                ("code-block-style", RuleSeverity::Error),
166                ("heading-style", RuleSeverity::Off),
167                ("heading-increment", RuleSeverity::Off),
168            ],
169            Default::default(),
170        )
171    }
172
173    #[test]
174    fn test_violation_consistent_style_mixed() {
175        let config = test_config();
176
177        let input = "Some text.
178
179    This is a
180    code block.
181
182And here is more text
183
184```text
185and here is a different
186code block
187```";
188        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
189        let violations = linter.analyze();
190        assert_eq!(1, violations.len());
191        assert!(violations[0].message().contains("Code block style"));
192    }
193
194    #[test]
195    fn test_no_violation_consistent_style_all_fenced() {
196        let config = test_config();
197
198        let input = "Some text.
199
200```text
201This is a fenced code block.
202```
203
204And here is more text
205
206```text
207and here is another fenced code block
208```";
209        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
210        let violations = linter.analyze();
211        assert_eq!(0, violations.len());
212    }
213
214    #[test]
215    fn test_no_violation_consistent_style_all_indented() {
216        let config = test_config();
217
218        let input = "Some text.
219
220    This is an indented
221    code block.
222
223And here is more text
224
225    And this is another
226    indented code block";
227        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
228        let violations = linter.analyze();
229        assert_eq!(0, violations.len());
230    }
231
232    #[test]
233    fn test_violation_fenced_style_with_indented() {
234        use crate::config::{CodeBlockStyle, MD046CodeBlockStyleTable};
235
236        let mut config = test_config();
237        config.linters.settings.code_block_style = MD046CodeBlockStyleTable {
238            style: CodeBlockStyle::Fenced,
239        };
240
241        let input = "Some text.
242
243    This is an indented
244    code block.
245
246And here is more text
247
248```text
249and here is a fenced code block
250```";
251        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
252        let violations = linter.analyze();
253        assert_eq!(1, violations.len());
254        assert!(violations[0].message().contains("Code block style"));
255        assert_eq!(violations[0].location().range.start.line, 2); // indented code block at line 3 (0-indexed)
256    }
257
258    #[test]
259    fn test_violation_indented_style_with_fenced() {
260        use crate::config::{CodeBlockStyle, MD046CodeBlockStyleTable};
261
262        let mut config = test_config();
263        config.linters.settings.code_block_style = MD046CodeBlockStyleTable {
264            style: CodeBlockStyle::Indented,
265        };
266
267        let input = "Some text.
268
269```text
270This is a fenced code block
271```
272
273And here is more text
274
275    This is an indented
276    code block";
277        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
278        let violations = linter.analyze();
279        assert_eq!(1, violations.len());
280        assert!(violations[0].message().contains("Code block style"));
281        assert_eq!(violations[0].location().range.start.line, 2); // fenced code block at line 3 (0-indexed)
282    }
283
284    #[test]
285    fn test_no_violation_single_code_block() {
286        let config = test_config();
287
288        let input = "Some text.
289
290    This is an indented
291    code block.
292
293No other code blocks.";
294        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
295        let violations = linter.analyze();
296        assert_eq!(0, violations.len());
297    }
298
299    #[test]
300    fn test_no_violation_no_code_blocks() {
301        let config = test_config();
302
303        let input = "Some text.
304
305Just regular paragraphs.
306
307No code blocks at all.";
308        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
309        let violations = linter.analyze();
310        assert_eq!(0, violations.len());
311    }
312
313    #[test]
314    fn test_violation_multiple_inconsistent_blocks() {
315        let config = test_config();
316
317        let input = "Some text.
318
319    First indented block
320
321Text between
322
323```text
324First fenced block
325```
326
327More text
328
329    Second indented block
330
331```javascript
332Second fenced block
333```";
334        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
335        let violations = linter.analyze();
336        // Should have violations for the fenced blocks since indented was first
337        assert_eq!(2, violations.len());
338        // Both violations should be for fenced blocks
339        assert_eq!(violations[0].location().range.start.line, 6); // first fenced block
340        assert_eq!(violations[1].location().range.start.line, 14); // second fenced block
341    }
342}