1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rules::code_fence_utils::CodeFenceStyle;
3use crate::utils::range_utils::calculate_match_range;
4use toml;
5
6mod md048_config;
7use md048_config::MD048Config;
8
9#[derive(Clone)]
13pub struct MD048CodeFenceStyle {
14 config: MD048Config,
15}
16
17impl MD048CodeFenceStyle {
18 pub fn new(style: CodeFenceStyle) -> Self {
19 Self {
20 config: MD048Config { style },
21 }
22 }
23
24 pub fn from_config_struct(config: MD048Config) -> Self {
25 Self { config }
26 }
27
28 fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<CodeFenceStyle> {
29 for line in ctx.content.lines() {
32 let trimmed = line.trim_start();
33
34 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
36 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
37
38 if fence_char == '`' {
40 return Some(CodeFenceStyle::Backtick);
41 } else {
42 return Some(CodeFenceStyle::Tilde);
43 }
44 }
45 }
46 None
47 }
48}
49
50impl Rule for MD048CodeFenceStyle {
51 fn name(&self) -> &'static str {
52 "MD048"
53 }
54
55 fn description(&self) -> &'static str {
56 "Code fence style should be consistent"
57 }
58
59 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
60 let content = ctx.content;
61 let _line_index = &ctx.line_index;
62
63 let mut warnings = Vec::new();
64
65 let target_style = match self.config.style {
66 CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
67 _ => self.config.style,
68 };
69
70 let mut in_code_block = false;
72 let mut code_block_fence = String::new();
73
74 for (line_num, line) in content.lines().enumerate() {
75 let trimmed = line.trim_start();
76
77 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
79 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
80 let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
81 let current_fence = fence_char.to_string().repeat(fence_length);
82
83 if !in_code_block {
84 in_code_block = true;
86 code_block_fence = current_fence.clone();
87
88 if trimmed.starts_with("```") && target_style == CodeFenceStyle::Tilde {
90 let fence_start = line.len() - trimmed.len();
92 let fence_end = fence_start + trimmed.find(|c: char| c != '`').unwrap_or(trimmed.len());
93
94 let (start_line, start_col, end_line, end_col) =
96 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
97
98 warnings.push(LintWarning {
99 rule_name: Some(self.name().to_string()),
100 message: "Code fence style: use ~~~ instead of ```".to_string(),
101 line: start_line,
102 column: start_col,
103 end_line,
104 end_column: end_col,
105 severity: Severity::Warning,
106 fix: Some(Fix {
107 range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
108 replacement: line.replace("```", "~~~"),
109 }),
110 });
111 } else if trimmed.starts_with("~~~") && target_style == CodeFenceStyle::Backtick {
112 let fence_start = line.len() - trimmed.len();
114 let fence_end = fence_start + trimmed.find(|c: char| c != '~').unwrap_or(trimmed.len());
115
116 let (start_line, start_col, end_line, end_col) =
118 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
119
120 warnings.push(LintWarning {
121 rule_name: Some(self.name().to_string()),
122 message: "Code fence style: use ``` instead of ~~~".to_string(),
123 line: start_line,
124 column: start_col,
125 end_line,
126 end_column: end_col,
127 severity: Severity::Warning,
128 fix: Some(Fix {
129 range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
130 replacement: line.replace("~~~", "```"),
131 }),
132 });
133 }
134 } else if trimmed.starts_with(&code_block_fence) && trimmed[code_block_fence.len()..].trim().is_empty()
135 {
136 if trimmed.starts_with("```") && target_style == CodeFenceStyle::Tilde {
138 let fence_start = line.len() - trimmed.len();
140 let fence_end = fence_start + trimmed.find(|c: char| c != '`').unwrap_or(trimmed.len());
141
142 let (start_line, start_col, end_line, end_col) =
144 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
145
146 warnings.push(LintWarning {
147 rule_name: Some(self.name().to_string()),
148 message: "Code fence style: use ~~~ instead of ```".to_string(),
149 line: start_line,
150 column: start_col,
151 end_line,
152 end_column: end_col,
153 severity: Severity::Warning,
154 fix: Some(Fix {
155 range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
156 replacement: line.replace("```", "~~~"),
157 }),
158 });
159 } else if trimmed.starts_with("~~~") && target_style == CodeFenceStyle::Backtick {
160 let fence_start = line.len() - trimmed.len();
162 let fence_end = fence_start + trimmed.find(|c: char| c != '~').unwrap_or(trimmed.len());
163
164 let (start_line, start_col, end_line, end_col) =
166 calculate_match_range(line_num + 1, line, fence_start, fence_end - fence_start);
167
168 warnings.push(LintWarning {
169 rule_name: Some(self.name().to_string()),
170 message: "Code fence style: use ``` instead of ~~~".to_string(),
171 line: start_line,
172 column: start_col,
173 end_line,
174 end_column: end_col,
175 severity: Severity::Warning,
176 fix: Some(Fix {
177 range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
178 replacement: line.replace("~~~", "```"),
179 }),
180 });
181 }
182
183 in_code_block = false;
184 code_block_fence.clear();
185 }
186 }
188 }
189
190 Ok(warnings)
191 }
192
193 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
195 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
197 }
198
199 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
200 let content = ctx.content;
201
202 let target_style = match self.config.style {
203 CodeFenceStyle::Consistent => self.detect_style(ctx).unwrap_or(CodeFenceStyle::Backtick),
204 _ => self.config.style,
205 };
206
207 let mut result = String::new();
208 let mut in_code_block = false;
209 let mut code_block_fence = String::new();
210
211 for line in content.lines() {
212 let trimmed = line.trim_start();
213
214 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
216 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
217 let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
218 let current_fence = fence_char.to_string().repeat(fence_length);
219
220 if !in_code_block {
221 in_code_block = true;
223 code_block_fence = current_fence.clone();
224
225 if trimmed.starts_with("```") && target_style == CodeFenceStyle::Tilde {
227 let prefix = &line[..line.len() - trimmed.len()];
229 let rest = &trimmed[fence_length..];
230 result.push_str(prefix);
231 result.push_str(&"~".repeat(fence_length));
232 result.push_str(rest);
233 } else if trimmed.starts_with("~~~") && target_style == CodeFenceStyle::Backtick {
234 let prefix = &line[..line.len() - trimmed.len()];
236 let rest = &trimmed[fence_length..];
237 result.push_str(prefix);
238 result.push_str(&"`".repeat(fence_length));
239 result.push_str(rest);
240 } else {
241 result.push_str(line);
242 }
243 } else if trimmed.starts_with(&code_block_fence) && trimmed[code_block_fence.len()..].trim().is_empty()
244 {
245 if trimmed.starts_with("```") && target_style == CodeFenceStyle::Tilde {
247 let prefix = &line[..line.len() - trimmed.len()];
249 let fence_length = trimmed.chars().take_while(|&c| c == '`').count();
250 let rest = &trimmed[fence_length..];
251 result.push_str(prefix);
252 result.push_str(&"~".repeat(fence_length));
253 result.push_str(rest);
254 } else if trimmed.starts_with("~~~") && target_style == CodeFenceStyle::Backtick {
255 let prefix = &line[..line.len() - trimmed.len()];
257 let fence_length = trimmed.chars().take_while(|&c| c == '~').count();
258 let rest = &trimmed[fence_length..];
259 result.push_str(prefix);
260 result.push_str(&"`".repeat(fence_length));
261 result.push_str(rest);
262 } else {
263 result.push_str(line);
264 }
265
266 in_code_block = false;
267 code_block_fence.clear();
268 } else {
269 result.push_str(line);
271 }
272 } else {
273 result.push_str(line);
274 }
275 result.push('\n');
276 }
277
278 if !content.ends_with('\n') && result.ends_with('\n') {
280 result.pop();
281 }
282
283 Ok(result)
284 }
285
286 fn as_any(&self) -> &dyn std::any::Any {
287 self
288 }
289
290 fn default_config_section(&self) -> Option<(String, toml::Value)> {
291 let json_value = serde_json::to_value(&self.config).ok()?;
292 Some((
293 self.name().to_string(),
294 crate::rule_config_serde::json_to_toml_value(&json_value)?,
295 ))
296 }
297
298 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
299 where
300 Self: Sized,
301 {
302 let rule_config = crate::rule_config_serde::load_rule_config::<MD048Config>(config);
303 Box::new(Self::from_config_struct(rule_config))
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::lint_context::LintContext;
311
312 #[test]
313 fn test_backtick_style_with_backticks() {
314 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
315 let content = "```\ncode\n```";
316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
317 let result = rule.check(&ctx).unwrap();
318
319 assert_eq!(result.len(), 0);
320 }
321
322 #[test]
323 fn test_backtick_style_with_tildes() {
324 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
325 let content = "~~~\ncode\n~~~";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
327 let result = rule.check(&ctx).unwrap();
328
329 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ``` instead of ~~~"));
331 assert_eq!(result[0].line, 1);
332 assert_eq!(result[1].line, 3);
333 }
334
335 #[test]
336 fn test_tilde_style_with_tildes() {
337 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
338 let content = "~~~\ncode\n~~~";
339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
340 let result = rule.check(&ctx).unwrap();
341
342 assert_eq!(result.len(), 0);
343 }
344
345 #[test]
346 fn test_tilde_style_with_backticks() {
347 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
348 let content = "```\ncode\n```";
349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
350 let result = rule.check(&ctx).unwrap();
351
352 assert_eq!(result.len(), 2); assert!(result[0].message.contains("use ~~~ instead of ```"));
354 }
355
356 #[test]
357 fn test_consistent_style_first_backtick() {
358 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
359 let content = "```\ncode\n```\n\n~~~\nmore code\n~~~";
360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
361 let result = rule.check(&ctx).unwrap();
362
363 assert_eq!(result.len(), 2);
365 assert_eq!(result[0].line, 5);
366 assert_eq!(result[1].line, 7);
367 }
368
369 #[test]
370 fn test_consistent_style_first_tilde() {
371 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
372 let content = "~~~\ncode\n~~~\n\n```\nmore code\n```";
373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
374 let result = rule.check(&ctx).unwrap();
375
376 assert_eq!(result.len(), 2);
378 assert_eq!(result[0].line, 5);
379 assert_eq!(result[1].line, 7);
380 }
381
382 #[test]
383 fn test_detect_style_backtick() {
384 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
385 let ctx = LintContext::new("```\ncode\n```", crate::config::MarkdownFlavor::Standard);
386 let style = rule.detect_style(&ctx);
387
388 assert_eq!(style, Some(CodeFenceStyle::Backtick));
389 }
390
391 #[test]
392 fn test_detect_style_tilde() {
393 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
394 let ctx = LintContext::new("~~~\ncode\n~~~", crate::config::MarkdownFlavor::Standard);
395 let style = rule.detect_style(&ctx);
396
397 assert_eq!(style, Some(CodeFenceStyle::Tilde));
398 }
399
400 #[test]
401 fn test_detect_style_none() {
402 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
403 let ctx = LintContext::new("No code fences here", crate::config::MarkdownFlavor::Standard);
404 let style = rule.detect_style(&ctx);
405
406 assert_eq!(style, None);
407 }
408
409 #[test]
410 fn test_fix_backticks_to_tildes() {
411 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
412 let content = "```\ncode\n```";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414 let fixed = rule.fix(&ctx).unwrap();
415
416 assert_eq!(fixed, "~~~\ncode\n~~~");
417 }
418
419 #[test]
420 fn test_fix_tildes_to_backticks() {
421 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
422 let content = "~~~\ncode\n~~~";
423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
424 let fixed = rule.fix(&ctx).unwrap();
425
426 assert_eq!(fixed, "```\ncode\n```");
427 }
428
429 #[test]
430 fn test_fix_preserves_fence_length() {
431 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
432 let content = "````\ncode with backtick\n```\ncode\n````";
433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
434 let fixed = rule.fix(&ctx).unwrap();
435
436 assert_eq!(fixed, "~~~~\ncode with backtick\n```\ncode\n~~~~");
437 }
438
439 #[test]
440 fn test_fix_preserves_language_info() {
441 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
442 let content = "~~~rust\nfn main() {}\n~~~";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
444 let fixed = rule.fix(&ctx).unwrap();
445
446 assert_eq!(fixed, "```rust\nfn main() {}\n```");
447 }
448
449 #[test]
450 fn test_indented_code_fences() {
451 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
452 let content = " ```\n code\n ```";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
454 let result = rule.check(&ctx).unwrap();
455
456 assert_eq!(result.len(), 2);
457 }
458
459 #[test]
460 fn test_fix_indented_fences() {
461 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
462 let content = " ```\n code\n ```";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464 let fixed = rule.fix(&ctx).unwrap();
465
466 assert_eq!(fixed, " ~~~\n code\n ~~~");
467 }
468
469 #[test]
470 fn test_nested_fences_not_changed() {
471 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Tilde);
472 let content = "```\ncode with ``` inside\n```";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
474 let fixed = rule.fix(&ctx).unwrap();
475
476 assert_eq!(fixed, "~~~\ncode with ``` inside\n~~~");
477 }
478
479 #[test]
480 fn test_multiple_code_blocks() {
481 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
482 let content = "~~~\ncode1\n~~~\n\nText\n\n~~~python\ncode2\n~~~";
483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
484 let result = rule.check(&ctx).unwrap();
485
486 assert_eq!(result.len(), 4); }
488
489 #[test]
490 fn test_empty_content() {
491 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
492 let content = "";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
494 let result = rule.check(&ctx).unwrap();
495
496 assert_eq!(result.len(), 0);
497 }
498
499 #[test]
500 fn test_preserve_trailing_newline() {
501 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
502 let content = "~~~\ncode\n~~~\n";
503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
504 let fixed = rule.fix(&ctx).unwrap();
505
506 assert_eq!(fixed, "```\ncode\n```\n");
507 }
508
509 #[test]
510 fn test_no_trailing_newline() {
511 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Backtick);
512 let content = "~~~\ncode\n~~~";
513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
514 let fixed = rule.fix(&ctx).unwrap();
515
516 assert_eq!(fixed, "```\ncode\n```");
517 }
518
519 #[test]
520 fn test_default_config() {
521 let rule = MD048CodeFenceStyle::new(CodeFenceStyle::Consistent);
522 let (name, _config) = rule.default_config_section().unwrap();
523 assert_eq!(name, "MD048");
524 }
525}