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#[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 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, },
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 }
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); }
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); }
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); }
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 assert_eq!(2, violations.len());
349 assert_eq!(violations[0].location().range.start.line, 8); assert_eq!(violations[1].location().range.start.line, 18); }
353}