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