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)
141 || BLOCKQUOTE_MARKER.is_match(line)
142 || ctx.line_info(line_num + 1).is_some_and(|info| {
143 info.in_code_block
144 || info.in_html_comment
145 || info.in_mdx_comment
146 || info.in_pymdown_block
147 || info.in_mkdocstrings
148 || info.in_admonition
149 || info.in_content_tab
150 })
151 {
152 return None;
153 }
154
155 let check_emphasis = |text: &str, level: usize, pattern: String| -> Option<(usize, String, usize, usize)> {
157 if !self.config.punctuation.is_empty() && self.ends_with_punctuation(text) {
159 return None;
160 }
161 if self.contains_link_or_code(text) {
164 return None;
165 }
166 let start_pos = original_line.find(&pattern).unwrap_or(0);
168 let end_pos = start_pos + pattern.len();
169 Some((level, text.to_string(), start_pos, end_pos))
170 };
171
172 if let Some(caps) = RE_ASTERISK_SINGLE.captures(line) {
174 let text = caps.get(1).unwrap().as_str();
175 let pattern = format!("*{text}*");
176 return check_emphasis(text, 1, pattern);
177 }
178
179 if let Some(caps) = RE_UNDERSCORE_SINGLE.captures(line) {
181 let text = caps.get(1).unwrap().as_str();
182 let pattern = format!("_{text}_");
183 return check_emphasis(text, 1, pattern);
184 }
185
186 if let Some(caps) = RE_ASTERISK_DOUBLE.captures(line) {
188 let text = caps.get(1).unwrap().as_str();
189 let pattern = format!("**{text}**");
190 return check_emphasis(text, 2, pattern);
191 }
192
193 if let Some(caps) = RE_UNDERSCORE_DOUBLE.captures(line) {
195 let text = caps.get(1).unwrap().as_str();
196 let pattern = format!("__{text}__");
197 return check_emphasis(text, 2, pattern);
198 }
199
200 None
201 }
202}
203
204impl Rule for MD036NoEmphasisAsHeading {
205 fn name(&self) -> &'static str {
206 "MD036"
207 }
208
209 fn description(&self) -> &'static str {
210 "Emphasis should not be used instead of a heading"
211 }
212
213 fn category(&self) -> RuleCategory {
214 RuleCategory::Emphasis
215 }
216
217 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
218 let content = ctx.content;
219 if content.is_empty() || (!content.contains('*') && !content.contains('_')) {
221 return Ok(Vec::new());
222 }
223
224 let mut warnings = Vec::new();
225
226 let lines: Vec<&str> = content.lines().collect();
227 let line_count = lines.len();
228
229 for (i, line) in lines.iter().enumerate() {
230 if line.trim().is_empty() || (!line.contains('*') && !line.contains('_')) {
232 continue;
233 }
234
235 let prev_blank = i == 0 || lines[i - 1].trim().is_empty();
239 let next_blank = i + 1 >= line_count || lines[i + 1].trim().is_empty();
240 if !prev_blank || !next_blank {
241 continue;
242 }
243
244 if let Some((_level, text, start_pos, end_pos)) = self.is_entire_line_emphasized(line, ctx, i) {
245 let (start_line, start_col, end_line, end_col) =
246 calculate_emphasis_range(i + 1, line, start_pos, end_pos);
247
248 let fix = if self.config.fix {
250 let prefix = self.atx_prefix();
251 let range = ctx.line_content_byte_range(i + 1);
253 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
255 Some(Fix::new(range, format!("{leading_ws}{prefix}{text}")))
256 } else {
257 None
258 };
259
260 warnings.push(LintWarning {
261 rule_name: Some(self.name().to_string()),
262 line: start_line,
263 column: start_col,
264 end_line,
265 end_column: end_col,
266 message: format!("Emphasis used instead of a heading: '{text}'"),
267 severity: Severity::Warning,
268 fix,
269 });
270 }
271 }
272
273 Ok(warnings)
274 }
275
276 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
277 if !self.config.fix {
280 return Ok(ctx.content.to_string());
281 }
282
283 let warnings = self.check(ctx)?;
285 let warnings =
286 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
287
288 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
290 return Ok(ctx.content.to_string());
291 }
292
293 let mut fixes: Vec<_> = warnings
295 .iter()
296 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
297 .collect();
298 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
299
300 let mut result = ctx.content.to_string();
302 for (start, end, replacement) in fixes {
303 if start < result.len() && end <= result.len() && start <= end {
304 result.replace_range(start..end, replacement);
305 }
306 }
307
308 Ok(result)
309 }
310
311 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
313 ctx.content.is_empty() || !ctx.likely_has_emphasis()
315 }
316
317 fn as_any(&self) -> &dyn std::any::Any {
318 self
319 }
320
321 crate::impl_rule_config_sections!(MD036Config);
322
323 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
324 where
325 Self: Sized,
326 {
327 let punctuation = crate::config::get_rule_config_value::<String>(config, "MD036", "punctuation")
328 .unwrap_or_else(|| ".,;:!?".to_string());
329
330 let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(false);
338
339 let heading_style = HeadingStyle::Atx;
341
342 let heading_level = crate::config::get_rule_config_value::<u8>(config, "MD036", "heading-level")
344 .or_else(|| crate::config::get_rule_config_value::<u8>(config, "MD036", "heading_level"))
345 .unwrap_or(2);
346
347 Box::new(MD036NoEmphasisAsHeading::new_with_fix(
348 punctuation,
349 fix,
350 heading_style,
351 heading_level,
352 ))
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::lint_context::LintContext;
360
361 #[test]
362 fn test_single_asterisk_emphasis() {
363 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
364 let content = "*This is emphasized*\n\nRegular text";
365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
366 let result = rule.check(&ctx).unwrap();
367
368 assert_eq!(result.len(), 1);
369 assert_eq!(result[0].line, 1);
370 assert!(
371 result[0]
372 .message
373 .contains("Emphasis used instead of a heading: 'This is emphasized'")
374 );
375 }
376
377 #[test]
378 fn test_single_underscore_emphasis() {
379 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
380 let content = "_This is emphasized_\n\nRegular text";
381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
382 let result = rule.check(&ctx).unwrap();
383
384 assert_eq!(result.len(), 1);
385 assert_eq!(result[0].line, 1);
386 assert!(
387 result[0]
388 .message
389 .contains("Emphasis used instead of a heading: 'This is emphasized'")
390 );
391 }
392
393 #[test]
394 fn test_double_asterisk_strong() {
395 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
396 let content = "**This is strong**\n\nRegular text";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399
400 assert_eq!(result.len(), 1);
401 assert_eq!(result[0].line, 1);
402 assert!(
403 result[0]
404 .message
405 .contains("Emphasis used instead of a heading: 'This is strong'")
406 );
407 }
408
409 #[test]
410 fn test_double_underscore_strong() {
411 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
412 let content = "__This is strong__\n\nRegular text";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414 let result = rule.check(&ctx).unwrap();
415
416 assert_eq!(result.len(), 1);
417 assert_eq!(result[0].line, 1);
418 assert!(
419 result[0]
420 .message
421 .contains("Emphasis used instead of a heading: 'This is strong'")
422 );
423 }
424
425 #[test]
426 fn test_emphasis_with_punctuation() {
427 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
428 let content = "**Important Note:**\n\nRegular text";
429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430 let result = rule.check(&ctx).unwrap();
431
432 assert_eq!(result.len(), 0);
434 }
435
436 #[test]
437 fn test_emphasis_in_paragraph() {
438 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
439 let content = "This is a paragraph with *emphasis* in the middle.";
440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441 let result = rule.check(&ctx).unwrap();
442
443 assert_eq!(result.len(), 0);
445 }
446
447 #[test]
448 fn test_emphasis_in_list() {
449 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
450 let content = "- *List item with emphasis*\n- Another item";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453
454 assert_eq!(result.len(), 0);
456 }
457
458 #[test]
459 fn test_emphasis_in_blockquote() {
460 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
461 let content = "> *Quote with emphasis*\n> Another line";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464
465 assert_eq!(result.len(), 0);
467 }
468
469 #[test]
470 fn test_emphasis_in_code_block() {
471 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
472 let content = "```\n*Not emphasis in code*\n```";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475
476 assert_eq!(result.len(), 0);
478 }
479
480 #[test]
481 fn test_emphasis_in_html_comment() {
482 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
483 let content = "<!--\n**bigger**\ncomment\n-->";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486
487 assert_eq!(
489 result.len(),
490 0,
491 "Expected no warnings for emphasis in HTML comment, got: {result:?}"
492 );
493 }
494
495 #[test]
496 fn test_toc_label() {
497 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
498 let content = "**Table of Contents**\n\n- Item 1\n- Item 2";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let result = rule.check(&ctx).unwrap();
501
502 assert_eq!(result.len(), 0);
504 }
505
506 #[test]
507 fn test_already_heading() {
508 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
509 let content = "# **Bold in heading**\n\nRegular text";
510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511 let result = rule.check(&ctx).unwrap();
512
513 assert_eq!(result.len(), 0);
515 }
516
517 #[test]
518 fn test_fix_disabled_by_default() {
519 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
521 let content = "*Convert to heading*\n\nRegular text";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let fixed = rule.fix(&ctx).unwrap();
524
525 assert_eq!(fixed, content);
527 }
528
529 #[test]
530 fn test_fix_disabled_preserves_content() {
531 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
533 let content = "**Convert to heading**\n\nRegular text";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535 let fixed = rule.fix(&ctx).unwrap();
536
537 assert_eq!(fixed, content);
539 }
540
541 #[test]
542 fn test_fix_enabled_single_asterisk() {
543 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
545 let content = "*Section Title*\n\nBody text.";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547 let fixed = rule.fix(&ctx).unwrap();
548
549 assert_eq!(fixed, "## Section Title\n\nBody text.");
550 }
551
552 #[test]
553 fn test_fix_enabled_double_asterisk() {
554 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
556 let content = "**Section Title**\n\nBody text.";
557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558 let fixed = rule.fix(&ctx).unwrap();
559
560 assert_eq!(fixed, "## Section Title\n\nBody text.");
561 }
562
563 #[test]
564 fn test_fix_enabled_single_underscore() {
565 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 3);
567 let content = "_Section Title_\n\nBody text.";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let fixed = rule.fix(&ctx).unwrap();
570
571 assert_eq!(fixed, "### Section Title\n\nBody text.");
572 }
573
574 #[test]
575 fn test_fix_enabled_double_underscore() {
576 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 4);
578 let content = "__Section Title__\n\nBody text.";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let fixed = rule.fix(&ctx).unwrap();
581
582 assert_eq!(fixed, "#### Section Title\n\nBody text.");
583 }
584
585 #[test]
586 fn test_fix_enabled_multiple_lines() {
587 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
589 let content = "**First Section**\n\nSome text.\n\n**Second Section**\n\nMore text.";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let fixed = rule.fix(&ctx).unwrap();
592
593 assert_eq!(
594 fixed,
595 "## First Section\n\nSome text.\n\n## Second Section\n\nMore text."
596 );
597 }
598
599 #[test]
600 fn test_fix_enabled_skips_punctuation() {
601 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
603 let content = "**Important Note:**\n\nBody text.";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let fixed = rule.fix(&ctx).unwrap();
606
607 assert_eq!(fixed, content);
609 }
610
611 #[test]
612 fn test_fix_enabled_heading_level_1() {
613 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 1);
614 let content = "**Title**";
615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616 let fixed = rule.fix(&ctx).unwrap();
617
618 assert_eq!(fixed, "# Title");
619 }
620
621 #[test]
622 fn test_fix_enabled_heading_level_6() {
623 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 6);
624 let content = "**Subsubsubheading**";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626 let fixed = rule.fix(&ctx).unwrap();
627
628 assert_eq!(fixed, "###### Subsubsubheading");
629 }
630
631 #[test]
632 fn test_fix_preserves_trailing_newline_enabled() {
633 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
634 let content = "**Heading**\n";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let fixed = rule.fix(&ctx).unwrap();
637
638 assert_eq!(fixed, "## Heading\n");
639 }
640
641 #[test]
642 fn test_fix_idempotent() {
643 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
645 let content = "**Section Title**\n\nBody text.";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let fixed1 = rule.fix(&ctx).unwrap();
648 assert_eq!(fixed1, "## Section Title\n\nBody text.");
649
650 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
652 let fixed2 = rule.fix(&ctx2).unwrap();
653 assert_eq!(fixed2, fixed1, "Fix should be idempotent");
654 }
655
656 #[test]
657 fn test_fix_skips_lists() {
658 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
659 let content = "- *List item*\n- Another item";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let fixed = rule.fix(&ctx).unwrap();
662
663 assert_eq!(fixed, content);
665 }
666
667 #[test]
668 fn test_fix_skips_blockquotes() {
669 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
670 let content = "> **Quoted text**\n> More quote";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let fixed = rule.fix(&ctx).unwrap();
673
674 assert_eq!(fixed, content);
676 }
677
678 #[test]
679 fn test_fix_skips_code_blocks() {
680 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
681 let content = "```\n**Not a heading**\n```";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let fixed = rule.fix(&ctx).unwrap();
684
685 assert_eq!(fixed, content);
687 }
688
689 #[test]
690 fn test_empty_punctuation_config() {
691 let rule = MD036NoEmphasisAsHeading::new("".to_string());
692 let content = "**Important Note:**\n\nRegular text";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let result = rule.check(&ctx).unwrap();
695
696 assert_eq!(result.len(), 1);
698
699 let fixed = rule.fix(&ctx).unwrap();
700 assert_eq!(fixed, content);
702 }
703
704 #[test]
705 fn test_empty_punctuation_config_with_fix() {
706 let rule = MD036NoEmphasisAsHeading::new_with_fix("".to_string(), true, HeadingStyle::Atx, 2);
708 let content = "**Important Note:**\n\nRegular text";
709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710 let fixed = rule.fix(&ctx).unwrap();
711
712 assert_eq!(fixed, "## Important Note:\n\nRegular text");
714 }
715
716 #[test]
717 fn test_multiple_emphasized_lines() {
718 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
719 let content = "*First heading*\n\nSome text\n\n**Second heading**\n\nMore text";
720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721 let result = rule.check(&ctx).unwrap();
722
723 assert_eq!(result.len(), 2);
724 assert_eq!(result[0].line, 1);
725 assert_eq!(result[1].line, 5);
726 }
727
728 #[test]
729 fn test_whitespace_handling() {
730 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
731 let content = " **Indented emphasis** \n\nRegular text";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733 let result = rule.check(&ctx).unwrap();
734
735 assert_eq!(result.len(), 1);
736 assert_eq!(result[0].line, 1);
737 }
738
739 #[test]
740 fn test_nested_emphasis() {
741 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
742 let content = "***Not a simple emphasis***\n\nRegular text";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let result = rule.check(&ctx).unwrap();
745
746 assert_eq!(result.len(), 0);
748 }
749
750 #[test]
751 fn test_emphasis_with_newlines() {
752 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
753 let content = "*First line\nSecond line*\n\nRegular text";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let result = rule.check(&ctx).unwrap();
756
757 assert_eq!(result.len(), 0);
759 }
760
761 #[test]
762 fn test_fix_preserves_trailing_newline_disabled() {
763 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
765 let content = "*Convert to heading*\n";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 let fixed = rule.fix(&ctx).unwrap();
768
769 assert_eq!(fixed, content);
771 }
772
773 #[test]
774 fn test_default_config() {
775 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
776 let (name, config) = rule.default_config_section().unwrap();
777 assert_eq!(name, "MD036");
778
779 let table = config.as_table().unwrap();
780 assert_eq!(table.get("punctuation").unwrap().as_str().unwrap(), ".,;:!?");
781 assert!(!table.get("fix").unwrap().as_bool().unwrap());
784 assert_eq!(table.get("heading-style").unwrap().as_str().unwrap(), "atx");
785 assert_eq!(table.get("heading-level").unwrap().as_integer().unwrap(), 2);
786 }
787
788 #[test]
789 fn test_default_warns_but_does_not_autoconvert() {
790 let rule = MD036NoEmphasisAsHeading::from_config(&crate::config::Config::default());
796 let content = "**Michael Rose**\n\nProfile text.";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798
799 let warnings = rule.check(&ctx).unwrap();
800 assert_eq!(warnings.len(), 1, "detection should still fire by default");
801 assert!(warnings[0].fix.is_none(), "default warnings must not carry a fix");
802
803 let fixed = rule.fix(&ctx).unwrap();
804 assert_eq!(
805 fixed, content,
806 "default fmt must not auto-convert emphasis to a heading"
807 );
808 }
809
810 #[test]
811 fn test_default_preserves_real_world_emphasis() {
812 let rule = MD036NoEmphasisAsHeading::from_config(&crate::config::Config::default());
817 for content in [
818 "Intro.\n\n*Note: without the links setup, we can't demonstrate the behavior*\n\nMore.",
819 "**index.md**\n\nA configuration file.",
820 "*New in v4.26.0*\n\nA new feature.",
821 ] {
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 assert_eq!(
824 rule.fix(&ctx).unwrap(),
825 content,
826 "default fmt must preserve: {content:?}"
827 );
828 }
829 }
830
831 #[test]
832 fn test_image_caption_scenario() {
833 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
835 let content = "#### Métriques\n\n**commits par année : rumdl**\n\n";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838
839 assert_eq!(result.len(), 1);
841 assert_eq!(result[0].line, 3);
842 assert!(result[0].message.contains("commits par année : rumdl"));
843
844 assert!(result[0].fix.is_none());
846
847 let fixed = rule.fix(&ctx).unwrap();
849 assert_eq!(fixed, content);
850 }
851
852 #[test]
853 fn test_bold_with_colon_no_punctuation_config() {
854 let rule = MD036NoEmphasisAsHeading::new("".to_string());
856 let content = "**commits par année : rumdl**\n\nSome text";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858 let result = rule.check(&ctx).unwrap();
859
860 assert_eq!(result.len(), 1);
862 assert!(result[0].fix.is_none());
863 }
864
865 #[test]
866 fn test_bold_with_colon_default_config() {
867 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
869 let content = "**Important Note:**\n\nSome text";
870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
871 let result = rule.check(&ctx).unwrap();
872
873 assert_eq!(result.len(), 0);
875 }
876
877 #[test]
878 fn test_mkdocs_admonition_body_not_flagged() {
879 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
883 let content = "!!! note\n\n _Foo_";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
885 let result = rule.check(&ctx).unwrap();
886
887 assert_eq!(
888 result.len(),
889 0,
890 "emphasis inside an admonition body should not be flagged, got: {result:?}"
891 );
892 }
893
894 #[test]
895 fn test_mkdocs_content_tab_body_not_flagged() {
896 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
898 let content = "=== \"Tab A\"\n\n _Foo_";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
900 let result = rule.check(&ctx).unwrap();
901
902 assert_eq!(
903 result.len(),
904 0,
905 "emphasis inside a content tab body should not be flagged, got: {result:?}"
906 );
907 }
908
909 #[test]
910 fn test_mkdocs_top_level_emphasis_still_flagged() {
911 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
914 let content = "_Foo_\n\nRegular text";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
916 let result = rule.check(&ctx).unwrap();
917
918 assert_eq!(
919 result.len(),
920 1,
921 "top-level emphasis should still be flagged under mkdocs flavor, got: {result:?}"
922 );
923 }
924
925 #[test]
926 fn test_standard_flavor_indented_emphasis_unchanged() {
927 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
931 let content = "Intro\n\n _Foo_\n\nMore text";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934
935 assert_eq!(
936 result.len(),
937 0,
938 "indented emphasis is indented code under standard flavor, got: {result:?}"
939 );
940 }
941
942 #[test]
943 fn test_mkdocs_cascade_fix_does_not_corrupt_admonition() {
944 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
948 let content = "!!! note\n\n _Foo_";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
950 let fixed = rule.fix(&ctx).unwrap();
951
952 assert_eq!(
953 fixed, content,
954 "admonition-nested emphasis must not be converted to a heading"
955 );
956 }
957
958 #[test]
959 fn test_html_markdown_div_emphasis_still_flagged() {
960 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
964 let content = "<div markdown=\"1\">\n\n_Foo_\n\n</div>";
965 for flavor in [
966 crate::config::MarkdownFlavor::Standard,
967 crate::config::MarkdownFlavor::MkDocs,
968 ] {
969 let ctx = LintContext::new(content, flavor, None);
970 let result = rule.check(&ctx).unwrap();
971 assert_eq!(
972 result.len(),
973 1,
974 "emphasis inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
975 );
976 }
977 }
978}