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