1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::range_utils::calculate_emphasis_range;
8use regex::Regex;
9use std::sync::LazyLock;
10use toml;
11
12mod md036_config;
13pub use md036_config::HeadingStyle;
14pub use md036_config::MD036Config;
15
16static RE_ASTERISK_SINGLE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\*([^*_\n]+)\*\s*$").unwrap());
20static RE_UNDERSCORE_SINGLE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*_([^*_\n]+)_\s*$").unwrap());
21static RE_ASTERISK_DOUBLE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\*\*([^*_\n]+)\*\*\s*$").unwrap());
22static RE_UNDERSCORE_DOUBLE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*__([^*_\n]+)__\s*$").unwrap());
23static LIST_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*(?:[*+-]|\d+\.)\s+").unwrap());
24static BLOCKQUOTE_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
25static HEADING_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^#+\s").unwrap());
26static HEADING_WITH_EMPHASIS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#+\s+).*(?:\*\*|\*|__|_)").unwrap());
27static TOC_LABEL_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(r"^\s*(?:\*\*|\*|__|_)(?:Table of Contents|Contents|TOC|Index)(?:\*\*|\*|__|_)\s*$").unwrap()
30});
31
32#[derive(Clone, Default)]
34pub struct MD036NoEmphasisAsHeading {
35 config: MD036Config,
36}
37
38impl MD036NoEmphasisAsHeading {
39 pub fn new(punctuation: String) -> Self {
40 Self {
41 config: MD036Config {
42 punctuation,
43 fix: false,
44 heading_style: HeadingStyle::default(),
45 heading_level: crate::types::HeadingLevel::new(2).unwrap(),
46 },
47 }
48 }
49
50 pub fn new_with_fix(punctuation: String, fix: bool, heading_style: HeadingStyle, heading_level: u8) -> Self {
51 let validated_level = crate::types::HeadingLevel::new(heading_level)
53 .unwrap_or_else(|_| crate::types::HeadingLevel::new(2).unwrap());
54 Self {
55 config: MD036Config {
56 punctuation,
57 fix,
58 heading_style,
59 heading_level: validated_level,
60 },
61 }
62 }
63
64 fn atx_prefix(&self) -> String {
66 let level = self.config.heading_level.get();
68 format!("{} ", "#".repeat(level as usize))
69 }
70
71 fn ends_with_punctuation(&self, text: &str) -> bool {
72 if text.is_empty() {
73 return false;
74 }
75 let trimmed = text.trim();
76 if trimmed.is_empty() {
77 return false;
78 }
79 trimmed
81 .chars()
82 .last()
83 .is_some_and(|ch| self.config.punctuation.contains(ch))
84 }
85
86 fn contains_link_or_code(&self, text: &str) -> bool {
87 if text.contains('`') {
91 return true;
92 }
93
94 if text.contains('[') && text.contains(']') {
98 if text.contains("](") {
100 return true;
101 }
102 if text.contains("][") || text.ends_with(']') {
104 return true;
105 }
106 }
107
108 false
109 }
110
111 fn is_entire_line_emphasized(
112 &self,
113 line: &str,
114 ctx: &crate::lint_context::LintContext,
115 line_num: usize,
116 ) -> Option<(usize, String, usize, usize)> {
117 let original_line = line;
118 let line = line.trim();
119
120 if line.is_empty() || (!line.contains('*') && !line.contains('_')) {
122 return None;
123 }
124
125 if HEADING_MARKER.is_match(line) && !HEADING_WITH_EMPHASIS.is_match(line) {
127 return None;
128 }
129
130 if TOC_LABEL_PATTERN.is_match(line) {
132 return None;
133 }
134
135 if LIST_MARKER.is_match(line)
137 || BLOCKQUOTE_MARKER.is_match(line)
138 || ctx.line_info(line_num + 1).is_some_and(|info| {
139 info.in_code_block
140 || info.in_html_comment
141 || info.in_mdx_comment
142 || info.in_pymdown_block
143 || info.in_mkdocstrings
144 })
145 {
146 return None;
147 }
148
149 let check_emphasis = |text: &str, level: usize, pattern: String| -> Option<(usize, String, usize, usize)> {
151 if !self.config.punctuation.is_empty() && self.ends_with_punctuation(text) {
153 return None;
154 }
155 if self.contains_link_or_code(text) {
158 return None;
159 }
160 let start_pos = original_line.find(&pattern).unwrap_or(0);
162 let end_pos = start_pos + pattern.len();
163 Some((level, text.to_string(), start_pos, end_pos))
164 };
165
166 if let Some(caps) = RE_ASTERISK_SINGLE.captures(line) {
168 let text = caps.get(1).unwrap().as_str();
169 let pattern = format!("*{text}*");
170 return check_emphasis(text, 1, pattern);
171 }
172
173 if let Some(caps) = RE_UNDERSCORE_SINGLE.captures(line) {
175 let text = caps.get(1).unwrap().as_str();
176 let pattern = format!("_{text}_");
177 return check_emphasis(text, 1, pattern);
178 }
179
180 if let Some(caps) = RE_ASTERISK_DOUBLE.captures(line) {
182 let text = caps.get(1).unwrap().as_str();
183 let pattern = format!("**{text}**");
184 return check_emphasis(text, 2, pattern);
185 }
186
187 if let Some(caps) = RE_UNDERSCORE_DOUBLE.captures(line) {
189 let text = caps.get(1).unwrap().as_str();
190 let pattern = format!("__{text}__");
191 return check_emphasis(text, 2, pattern);
192 }
193
194 None
195 }
196}
197
198impl Rule for MD036NoEmphasisAsHeading {
199 fn name(&self) -> &'static str {
200 "MD036"
201 }
202
203 fn description(&self) -> &'static str {
204 "Emphasis should not be used instead of a heading"
205 }
206
207 fn category(&self) -> RuleCategory {
208 RuleCategory::Emphasis
209 }
210
211 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
212 let content = ctx.content;
213 if content.is_empty() || (!content.contains('*') && !content.contains('_')) {
215 return Ok(Vec::new());
216 }
217
218 let mut warnings = Vec::new();
219
220 let lines: Vec<&str> = content.lines().collect();
221 let line_count = lines.len();
222
223 for (i, line) in lines.iter().enumerate() {
224 if line.trim().is_empty() || (!line.contains('*') && !line.contains('_')) {
226 continue;
227 }
228
229 let prev_blank = i == 0 || lines[i - 1].trim().is_empty();
233 let next_blank = i + 1 >= line_count || lines[i + 1].trim().is_empty();
234 if !prev_blank || !next_blank {
235 continue;
236 }
237
238 if let Some((_level, text, start_pos, end_pos)) = self.is_entire_line_emphasized(line, ctx, i) {
239 let (start_line, start_col, end_line, end_col) =
240 calculate_emphasis_range(i + 1, line, start_pos, end_pos);
241
242 let fix = if self.config.fix {
244 let prefix = self.atx_prefix();
245 let range = ctx.line_index.line_content_range(i + 1);
247 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
249 Some(Fix {
250 range,
251 replacement: format!("{leading_ws}{prefix}{text}"),
252 })
253 } else {
254 None
255 };
256
257 warnings.push(LintWarning {
258 rule_name: Some(self.name().to_string()),
259 line: start_line,
260 column: start_col,
261 end_line,
262 end_column: end_col,
263 message: format!("Emphasis used instead of a heading: '{text}'"),
264 severity: Severity::Warning,
265 fix,
266 });
267 }
268 }
269
270 Ok(warnings)
271 }
272
273 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
274 if !self.config.fix {
277 return Ok(ctx.content.to_string());
278 }
279
280 let warnings = self.check(ctx)?;
282 let warnings =
283 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
284
285 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
287 return Ok(ctx.content.to_string());
288 }
289
290 let mut fixes: Vec<_> = warnings
292 .iter()
293 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
294 .collect();
295 fixes.sort_by(|a, b| b.0.cmp(&a.0));
296
297 let mut result = ctx.content.to_string();
299 for (start, end, replacement) in fixes {
300 if start < result.len() && end <= result.len() && start <= end {
301 result.replace_range(start..end, replacement);
302 }
303 }
304
305 Ok(result)
306 }
307
308 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
310 ctx.content.is_empty() || !ctx.likely_has_emphasis()
312 }
313
314 fn as_any(&self) -> &dyn std::any::Any {
315 self
316 }
317
318 fn default_config_section(&self) -> Option<(String, toml::Value)> {
319 let mut map = toml::map::Map::new();
320 map.insert(
321 "punctuation".to_string(),
322 toml::Value::String(self.config.punctuation.clone()),
323 );
324 map.insert("fix".to_string(), toml::Value::Boolean(self.config.fix));
325 map.insert("heading-style".to_string(), toml::Value::String("atx".to_string()));
326 map.insert(
327 "heading-level".to_string(),
328 toml::Value::Integer(i64::from(self.config.heading_level.get())),
329 );
330 Some((self.name().to_string(), toml::Value::Table(map)))
331 }
332
333 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
334 where
335 Self: Sized,
336 {
337 let punctuation = crate::config::get_rule_config_value::<String>(config, "MD036", "punctuation")
338 .unwrap_or_else(|| ".,;:!?".to_string());
339
340 let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(false);
341
342 let heading_style = HeadingStyle::Atx;
344
345 let heading_level = crate::config::get_rule_config_value::<u8>(config, "MD036", "heading-level")
347 .or_else(|| crate::config::get_rule_config_value::<u8>(config, "MD036", "heading_level"))
348 .unwrap_or(2);
349
350 Box::new(MD036NoEmphasisAsHeading::new_with_fix(
351 punctuation,
352 fix,
353 heading_style,
354 heading_level,
355 ))
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::lint_context::LintContext;
363
364 #[test]
365 fn test_single_asterisk_emphasis() {
366 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
367 let content = "*This is emphasized*\n\nRegular text";
368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
369 let result = rule.check(&ctx).unwrap();
370
371 assert_eq!(result.len(), 1);
372 assert_eq!(result[0].line, 1);
373 assert!(
374 result[0]
375 .message
376 .contains("Emphasis used instead of a heading: 'This is emphasized'")
377 );
378 }
379
380 #[test]
381 fn test_single_underscore_emphasis() {
382 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
383 let content = "_This is emphasized_\n\nRegular text";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385 let result = rule.check(&ctx).unwrap();
386
387 assert_eq!(result.len(), 1);
388 assert_eq!(result[0].line, 1);
389 assert!(
390 result[0]
391 .message
392 .contains("Emphasis used instead of a heading: 'This is emphasized'")
393 );
394 }
395
396 #[test]
397 fn test_double_asterisk_strong() {
398 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
399 let content = "**This is strong**\n\nRegular text";
400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
401 let result = rule.check(&ctx).unwrap();
402
403 assert_eq!(result.len(), 1);
404 assert_eq!(result[0].line, 1);
405 assert!(
406 result[0]
407 .message
408 .contains("Emphasis used instead of a heading: 'This is strong'")
409 );
410 }
411
412 #[test]
413 fn test_double_underscore_strong() {
414 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
415 let content = "__This is strong__\n\nRegular text";
416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417 let result = rule.check(&ctx).unwrap();
418
419 assert_eq!(result.len(), 1);
420 assert_eq!(result[0].line, 1);
421 assert!(
422 result[0]
423 .message
424 .contains("Emphasis used instead of a heading: 'This is strong'")
425 );
426 }
427
428 #[test]
429 fn test_emphasis_with_punctuation() {
430 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
431 let content = "**Important Note:**\n\nRegular text";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434
435 assert_eq!(result.len(), 0);
437 }
438
439 #[test]
440 fn test_emphasis_in_paragraph() {
441 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
442 let content = "This is a paragraph with *emphasis* in the middle.";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445
446 assert_eq!(result.len(), 0);
448 }
449
450 #[test]
451 fn test_emphasis_in_list() {
452 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
453 let content = "- *List item with emphasis*\n- Another item";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455 let result = rule.check(&ctx).unwrap();
456
457 assert_eq!(result.len(), 0);
459 }
460
461 #[test]
462 fn test_emphasis_in_blockquote() {
463 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
464 let content = "> *Quote with emphasis*\n> Another line";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466 let result = rule.check(&ctx).unwrap();
467
468 assert_eq!(result.len(), 0);
470 }
471
472 #[test]
473 fn test_emphasis_in_code_block() {
474 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
475 let content = "```\n*Not emphasis in code*\n```";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478
479 assert_eq!(result.len(), 0);
481 }
482
483 #[test]
484 fn test_emphasis_in_html_comment() {
485 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
486 let content = "<!--\n**bigger**\ncomment\n-->";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488 let result = rule.check(&ctx).unwrap();
489
490 assert_eq!(
492 result.len(),
493 0,
494 "Expected no warnings for emphasis in HTML comment, got: {result:?}"
495 );
496 }
497
498 #[test]
499 fn test_toc_label() {
500 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
501 let content = "**Table of Contents**\n\n- Item 1\n- Item 2";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let result = rule.check(&ctx).unwrap();
504
505 assert_eq!(result.len(), 0);
507 }
508
509 #[test]
510 fn test_already_heading() {
511 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
512 let content = "# **Bold in heading**\n\nRegular text";
513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.check(&ctx).unwrap();
515
516 assert_eq!(result.len(), 0);
518 }
519
520 #[test]
521 fn test_fix_disabled_by_default() {
522 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
524 let content = "*Convert to heading*\n\nRegular text";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let fixed = rule.fix(&ctx).unwrap();
527
528 assert_eq!(fixed, content);
530 }
531
532 #[test]
533 fn test_fix_disabled_preserves_content() {
534 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
536 let content = "**Convert to heading**\n\nRegular text";
537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538 let fixed = rule.fix(&ctx).unwrap();
539
540 assert_eq!(fixed, content);
542 }
543
544 #[test]
545 fn test_fix_enabled_single_asterisk() {
546 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
548 let content = "*Section Title*\n\nBody text.";
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550 let fixed = rule.fix(&ctx).unwrap();
551
552 assert_eq!(fixed, "## Section Title\n\nBody text.");
553 }
554
555 #[test]
556 fn test_fix_enabled_double_asterisk() {
557 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
559 let content = "**Section Title**\n\nBody text.";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let fixed = rule.fix(&ctx).unwrap();
562
563 assert_eq!(fixed, "## Section Title\n\nBody text.");
564 }
565
566 #[test]
567 fn test_fix_enabled_single_underscore() {
568 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 3);
570 let content = "_Section Title_\n\nBody text.";
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 let fixed = rule.fix(&ctx).unwrap();
573
574 assert_eq!(fixed, "### Section Title\n\nBody text.");
575 }
576
577 #[test]
578 fn test_fix_enabled_double_underscore() {
579 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 4);
581 let content = "__Section Title__\n\nBody text.";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let fixed = rule.fix(&ctx).unwrap();
584
585 assert_eq!(fixed, "#### Section Title\n\nBody text.");
586 }
587
588 #[test]
589 fn test_fix_enabled_multiple_lines() {
590 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
592 let content = "**First Section**\n\nSome text.\n\n**Second Section**\n\nMore text.";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let fixed = rule.fix(&ctx).unwrap();
595
596 assert_eq!(
597 fixed,
598 "## First Section\n\nSome text.\n\n## Second Section\n\nMore text."
599 );
600 }
601
602 #[test]
603 fn test_fix_enabled_skips_punctuation() {
604 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
606 let content = "**Important Note:**\n\nBody text.";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let fixed = rule.fix(&ctx).unwrap();
609
610 assert_eq!(fixed, content);
612 }
613
614 #[test]
615 fn test_fix_enabled_heading_level_1() {
616 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 1);
617 let content = "**Title**";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let fixed = rule.fix(&ctx).unwrap();
620
621 assert_eq!(fixed, "# Title");
622 }
623
624 #[test]
625 fn test_fix_enabled_heading_level_6() {
626 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 6);
627 let content = "**Subsubsubheading**";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let fixed = rule.fix(&ctx).unwrap();
630
631 assert_eq!(fixed, "###### Subsubsubheading");
632 }
633
634 #[test]
635 fn test_fix_preserves_trailing_newline_enabled() {
636 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
637 let content = "**Heading**\n";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639 let fixed = rule.fix(&ctx).unwrap();
640
641 assert_eq!(fixed, "## Heading\n");
642 }
643
644 #[test]
645 fn test_fix_idempotent() {
646 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
648 let content = "**Section Title**\n\nBody text.";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let fixed1 = rule.fix(&ctx).unwrap();
651 assert_eq!(fixed1, "## Section Title\n\nBody text.");
652
653 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
655 let fixed2 = rule.fix(&ctx2).unwrap();
656 assert_eq!(fixed2, fixed1, "Fix should be idempotent");
657 }
658
659 #[test]
660 fn test_fix_skips_lists() {
661 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
662 let content = "- *List item*\n- Another item";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let fixed = rule.fix(&ctx).unwrap();
665
666 assert_eq!(fixed, content);
668 }
669
670 #[test]
671 fn test_fix_skips_blockquotes() {
672 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
673 let content = "> **Quoted text**\n> More quote";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let fixed = rule.fix(&ctx).unwrap();
676
677 assert_eq!(fixed, content);
679 }
680
681 #[test]
682 fn test_fix_skips_code_blocks() {
683 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
684 let content = "```\n**Not a heading**\n```";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let fixed = rule.fix(&ctx).unwrap();
687
688 assert_eq!(fixed, content);
690 }
691
692 #[test]
693 fn test_empty_punctuation_config() {
694 let rule = MD036NoEmphasisAsHeading::new("".to_string());
695 let content = "**Important Note:**\n\nRegular text";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
697 let result = rule.check(&ctx).unwrap();
698
699 assert_eq!(result.len(), 1);
701
702 let fixed = rule.fix(&ctx).unwrap();
703 assert_eq!(fixed, content);
705 }
706
707 #[test]
708 fn test_empty_punctuation_config_with_fix() {
709 let rule = MD036NoEmphasisAsHeading::new_with_fix("".to_string(), true, HeadingStyle::Atx, 2);
711 let content = "**Important Note:**\n\nRegular text";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let fixed = rule.fix(&ctx).unwrap();
714
715 assert_eq!(fixed, "## Important Note:\n\nRegular text");
717 }
718
719 #[test]
720 fn test_multiple_emphasized_lines() {
721 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
722 let content = "*First heading*\n\nSome text\n\n**Second heading**\n\nMore text";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let result = rule.check(&ctx).unwrap();
725
726 assert_eq!(result.len(), 2);
727 assert_eq!(result[0].line, 1);
728 assert_eq!(result[1].line, 5);
729 }
730
731 #[test]
732 fn test_whitespace_handling() {
733 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
734 let content = " **Indented emphasis** \n\nRegular text";
735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
736 let result = rule.check(&ctx).unwrap();
737
738 assert_eq!(result.len(), 1);
739 assert_eq!(result[0].line, 1);
740 }
741
742 #[test]
743 fn test_nested_emphasis() {
744 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
745 let content = "***Not a simple emphasis***\n\nRegular text";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748
749 assert_eq!(result.len(), 0);
751 }
752
753 #[test]
754 fn test_emphasis_with_newlines() {
755 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
756 let content = "*First line\nSecond line*\n\nRegular text";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759
760 assert_eq!(result.len(), 0);
762 }
763
764 #[test]
765 fn test_fix_preserves_trailing_newline_disabled() {
766 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
768 let content = "*Convert to heading*\n";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let fixed = rule.fix(&ctx).unwrap();
771
772 assert_eq!(fixed, content);
774 }
775
776 #[test]
777 fn test_default_config() {
778 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
779 let (name, config) = rule.default_config_section().unwrap();
780 assert_eq!(name, "MD036");
781
782 let table = config.as_table().unwrap();
783 assert_eq!(table.get("punctuation").unwrap().as_str().unwrap(), ".,;:!?");
784 assert!(!table.get("fix").unwrap().as_bool().unwrap());
785 assert_eq!(table.get("heading-style").unwrap().as_str().unwrap(), "atx");
786 assert_eq!(table.get("heading-level").unwrap().as_integer().unwrap(), 2);
787 }
788
789 #[test]
790 fn test_image_caption_scenario() {
791 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
793 let content = "#### Métriques\n\n**commits par année : rumdl**\n\n";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let result = rule.check(&ctx).unwrap();
796
797 assert_eq!(result.len(), 1);
799 assert_eq!(result[0].line, 3);
800 assert!(result[0].message.contains("commits par année : rumdl"));
801
802 assert!(result[0].fix.is_none());
804
805 let fixed = rule.fix(&ctx).unwrap();
807 assert_eq!(fixed, content);
808 }
809
810 #[test]
811 fn test_bold_with_colon_no_punctuation_config() {
812 let rule = MD036NoEmphasisAsHeading::new("".to_string());
814 let content = "**commits par année : rumdl**\n\nSome text";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817
818 assert_eq!(result.len(), 1);
820 assert!(result[0].fix.is_none());
821 }
822
823 #[test]
824 fn test_bold_with_colon_default_config() {
825 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
827 let content = "**Important Note:**\n\nSome text";
828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829 let result = rule.check(&ctx).unwrap();
830
831 assert_eq!(result.len(), 0);
833 }
834}