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