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 native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
206 (!self.config.fix).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(0))
207 }
208
209 fn name(&self) -> &'static str {
210 "MD036"
211 }
212
213 fn description(&self) -> &'static str {
214 "Emphasis should not be used instead of a heading"
215 }
216
217 fn category(&self) -> RuleCategory {
218 RuleCategory::Emphasis
219 }
220
221 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
222 let content = ctx.content;
223 if content.is_empty() || (!ctx.has_char('*') && !ctx.has_char('_')) {
225 return Ok(Vec::new());
226 }
227
228 let mut warnings = Vec::new();
229
230 let lines = ctx.raw_lines();
231 let line_count = lines.len();
232
233 for &i in ctx.candidate_line_indices(crate::lint_context::LineCandidateKind::Emphasis) {
234 let line = lines[i];
235 if line.trim().is_empty() {
237 continue;
238 }
239
240 let prev_blank = i == 0 || lines[i - 1].trim().is_empty();
244 let next_blank = i + 1 >= line_count || lines[i + 1].trim().is_empty();
245 if !prev_blank || !next_blank {
246 continue;
247 }
248
249 if let Some((_level, text, start_pos, end_pos)) = self.is_entire_line_emphasized(line, ctx, i) {
250 let (start_line, start_col, end_line, end_col) =
251 calculate_emphasis_range(i + 1, line, start_pos, end_pos);
252
253 let fix = if self.config.fix {
255 let prefix = self.atx_prefix();
256 let range = ctx.line_content_byte_range(i + 1);
258 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
260 Some(Fix::new(range, format!("{leading_ws}{prefix}{text}")))
261 } else {
262 None
263 };
264
265 warnings.push(LintWarning {
266 rule_name: Some(self.name().to_string()),
267 line: start_line,
268 column: start_col,
269 end_line,
270 end_column: end_col,
271 message: format!("Emphasis used instead of a heading: '{text}'"),
272 severity: Severity::Warning,
273 fix,
274 });
275 }
276 }
277
278 Ok(warnings)
279 }
280
281 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
282 if !self.config.fix {
285 return Ok(ctx.content.to_string());
286 }
287
288 let warnings = self.check(ctx)?;
290 let warnings =
291 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
292
293 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
295 return Ok(ctx.content.to_string());
296 }
297
298 let mut fixes: Vec<_> = warnings
300 .iter()
301 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
302 .collect();
303 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
304
305 let mut result = ctx.content.to_string();
307 for (start, end, replacement) in fixes {
308 if start < result.len() && end <= result.len() && start <= end {
309 result.replace_range(start..end, replacement);
310 }
311 }
312
313 Ok(result)
314 }
315
316 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
318 ctx.content.is_empty() || !ctx.likely_has_emphasis()
320 }
321
322 fn as_any(&self) -> &dyn std::any::Any {
323 self
324 }
325
326 crate::impl_rule_config_sections!(MD036Config);
327
328 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
329 where
330 Self: Sized,
331 {
332 let punctuation = crate::config::get_rule_config_value::<String>(config, "MD036", "punctuation")
333 .unwrap_or_else(|| ".,;:!?".to_string());
334
335 let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(false);
343
344 let heading_style = HeadingStyle::Atx;
346
347 let heading_level = crate::config::get_rule_config_value::<u8>(config, "MD036", "heading-level")
349 .or_else(|| crate::config::get_rule_config_value::<u8>(config, "MD036", "heading_level"))
350 .unwrap_or(2);
351
352 Box::new(MD036NoEmphasisAsHeading::new_with_fix(
353 punctuation,
354 fix,
355 heading_style,
356 heading_level,
357 ))
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::lint_context::LintContext;
365
366 #[test]
367 fn test_single_asterisk_emphasis() {
368 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
369 let content = "*This is emphasized*\n\nRegular text";
370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
371 let result = rule.check(&ctx).unwrap();
372
373 assert_eq!(result.len(), 1);
374 assert_eq!(result[0].line, 1);
375 assert!(
376 result[0]
377 .message
378 .contains("Emphasis used instead of a heading: 'This is emphasized'")
379 );
380 }
381
382 #[test]
383 fn test_single_underscore_emphasis() {
384 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
385 let content = "_This is emphasized_\n\nRegular text";
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387 let result = rule.check(&ctx).unwrap();
388
389 assert_eq!(result.len(), 1);
390 assert_eq!(result[0].line, 1);
391 assert!(
392 result[0]
393 .message
394 .contains("Emphasis used instead of a heading: 'This is emphasized'")
395 );
396 }
397
398 #[test]
399 fn test_double_asterisk_strong() {
400 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
401 let content = "**This is strong**\n\nRegular text";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403 let result = rule.check(&ctx).unwrap();
404
405 assert_eq!(result.len(), 1);
406 assert_eq!(result[0].line, 1);
407 assert!(
408 result[0]
409 .message
410 .contains("Emphasis used instead of a heading: 'This is strong'")
411 );
412 }
413
414 #[test]
415 fn test_double_underscore_strong() {
416 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
417 let content = "__This is strong__\n\nRegular text";
418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
419 let result = rule.check(&ctx).unwrap();
420
421 assert_eq!(result.len(), 1);
422 assert_eq!(result[0].line, 1);
423 assert!(
424 result[0]
425 .message
426 .contains("Emphasis used instead of a heading: 'This is strong'")
427 );
428 }
429
430 #[test]
431 fn test_emphasis_with_punctuation() {
432 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
433 let content = "**Important Note:**\n\nRegular text";
434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435 let result = rule.check(&ctx).unwrap();
436
437 assert_eq!(result.len(), 0);
439 }
440
441 #[test]
442 fn test_emphasis_in_paragraph() {
443 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
444 let content = "This is a paragraph with *emphasis* in the middle.";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447
448 assert_eq!(result.len(), 0);
450 }
451
452 #[test]
453 fn test_emphasis_in_list() {
454 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
455 let content = "- *List item with emphasis*\n- Another item";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457 let result = rule.check(&ctx).unwrap();
458
459 assert_eq!(result.len(), 0);
461 }
462
463 #[test]
464 fn test_emphasis_in_blockquote() {
465 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
466 let content = "> *Quote with emphasis*\n> Another line";
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
468 let result = rule.check(&ctx).unwrap();
469
470 assert_eq!(result.len(), 0);
472 }
473
474 #[test]
475 fn test_emphasis_in_code_block() {
476 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
477 let content = "```\n*Not emphasis in code*\n```";
478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479 let result = rule.check(&ctx).unwrap();
480
481 assert_eq!(result.len(), 0);
483 }
484
485 #[test]
486 fn test_emphasis_in_html_comment() {
487 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
488 let content = "<!--\n**bigger**\ncomment\n-->";
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490 let result = rule.check(&ctx).unwrap();
491
492 assert_eq!(
494 result.len(),
495 0,
496 "Expected no warnings for emphasis in HTML comment, got: {result:?}"
497 );
498 }
499
500 #[test]
501 fn test_toc_label() {
502 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
503 let content = "**Table of Contents**\n\n- Item 1\n- Item 2";
504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
505 let result = rule.check(&ctx).unwrap();
506
507 assert_eq!(result.len(), 0);
509 }
510
511 #[test]
512 fn test_already_heading() {
513 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
514 let content = "# **Bold in heading**\n\nRegular text";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516 let result = rule.check(&ctx).unwrap();
517
518 assert_eq!(result.len(), 0);
520 }
521
522 #[test]
523 fn test_fix_disabled_by_default() {
524 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
526 let content = "*Convert to heading*\n\nRegular text";
527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528 let fixed = rule.fix(&ctx).unwrap();
529
530 assert_eq!(fixed, content);
532 }
533
534 #[test]
535 fn test_fix_disabled_preserves_content() {
536 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
538 let content = "**Convert to heading**\n\nRegular text";
539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540 let fixed = rule.fix(&ctx).unwrap();
541
542 assert_eq!(fixed, content);
544 }
545
546 #[test]
547 fn test_fix_enabled_single_asterisk() {
548 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
550 let content = "*Section Title*\n\nBody text.";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let fixed = rule.fix(&ctx).unwrap();
553
554 assert_eq!(fixed, "## Section Title\n\nBody text.");
555 }
556
557 #[test]
558 fn test_fix_enabled_double_asterisk() {
559 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
561 let content = "**Section Title**\n\nBody text.";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563 let fixed = rule.fix(&ctx).unwrap();
564
565 assert_eq!(fixed, "## Section Title\n\nBody text.");
566 }
567
568 #[test]
569 fn test_fix_enabled_single_underscore() {
570 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 3);
572 let content = "_Section Title_\n\nBody text.";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let fixed = rule.fix(&ctx).unwrap();
575
576 assert_eq!(fixed, "### Section Title\n\nBody text.");
577 }
578
579 #[test]
580 fn test_fix_enabled_double_underscore() {
581 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 4);
583 let content = "__Section Title__\n\nBody text.";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let fixed = rule.fix(&ctx).unwrap();
586
587 assert_eq!(fixed, "#### Section Title\n\nBody text.");
588 }
589
590 #[test]
591 fn test_fix_enabled_multiple_lines() {
592 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
594 let content = "**First Section**\n\nSome text.\n\n**Second Section**\n\nMore text.";
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let fixed = rule.fix(&ctx).unwrap();
597
598 assert_eq!(
599 fixed,
600 "## First Section\n\nSome text.\n\n## Second Section\n\nMore text."
601 );
602 }
603
604 #[test]
605 fn test_fix_enabled_skips_punctuation() {
606 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
608 let content = "**Important Note:**\n\nBody text.";
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let fixed = rule.fix(&ctx).unwrap();
611
612 assert_eq!(fixed, content);
614 }
615
616 #[test]
617 fn test_fix_enabled_heading_level_1() {
618 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 1);
619 let content = "**Title**";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let fixed = rule.fix(&ctx).unwrap();
622
623 assert_eq!(fixed, "# Title");
624 }
625
626 #[test]
627 fn test_fix_enabled_heading_level_6() {
628 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 6);
629 let content = "**Subsubsubheading**";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let fixed = rule.fix(&ctx).unwrap();
632
633 assert_eq!(fixed, "###### Subsubsubheading");
634 }
635
636 #[test]
637 fn test_fix_preserves_trailing_newline_enabled() {
638 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
639 let content = "**Heading**\n";
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 let fixed = rule.fix(&ctx).unwrap();
642
643 assert_eq!(fixed, "## Heading\n");
644 }
645
646 #[test]
647 fn test_fix_idempotent() {
648 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
650 let content = "**Section Title**\n\nBody text.";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let fixed1 = rule.fix(&ctx).unwrap();
653 assert_eq!(fixed1, "## Section Title\n\nBody text.");
654
655 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
657 let fixed2 = rule.fix(&ctx2).unwrap();
658 assert_eq!(fixed2, fixed1, "Fix should be idempotent");
659 }
660
661 #[test]
662 fn test_fix_skips_lists() {
663 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
664 let content = "- *List item*\n- Another item";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let fixed = rule.fix(&ctx).unwrap();
667
668 assert_eq!(fixed, content);
670 }
671
672 #[test]
673 fn test_fix_skips_blockquotes() {
674 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
675 let content = "> **Quoted text**\n> More quote";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let fixed = rule.fix(&ctx).unwrap();
678
679 assert_eq!(fixed, content);
681 }
682
683 #[test]
684 fn test_fix_skips_code_blocks() {
685 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
686 let content = "```\n**Not a heading**\n```";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let fixed = rule.fix(&ctx).unwrap();
689
690 assert_eq!(fixed, content);
692 }
693
694 #[test]
695 fn test_empty_punctuation_config() {
696 let rule = MD036NoEmphasisAsHeading::new("".to_string());
697 let content = "**Important Note:**\n\nRegular text";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.check(&ctx).unwrap();
700
701 assert_eq!(result.len(), 1);
703
704 let fixed = rule.fix(&ctx).unwrap();
705 assert_eq!(fixed, content);
707 }
708
709 #[test]
710 fn test_empty_punctuation_config_with_fix() {
711 let rule = MD036NoEmphasisAsHeading::new_with_fix("".to_string(), true, HeadingStyle::Atx, 2);
713 let content = "**Important Note:**\n\nRegular text";
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715 let fixed = rule.fix(&ctx).unwrap();
716
717 assert_eq!(fixed, "## Important Note:\n\nRegular text");
719 }
720
721 #[test]
722 fn test_multiple_emphasized_lines() {
723 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
724 let content = "*First heading*\n\nSome text\n\n**Second heading**\n\nMore text";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727
728 assert_eq!(result.len(), 2);
729 assert_eq!(result[0].line, 1);
730 assert_eq!(result[1].line, 5);
731 }
732
733 #[test]
734 fn test_whitespace_handling() {
735 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
736 let content = " **Indented emphasis** \n\nRegular text";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739
740 assert_eq!(result.len(), 1);
741 assert_eq!(result[0].line, 1);
742 }
743
744 #[test]
745 fn test_nested_emphasis() {
746 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
747 let content = "***Not a simple emphasis***\n\nRegular text";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750
751 assert_eq!(result.len(), 0);
753 }
754
755 #[test]
756 fn test_emphasis_with_newlines() {
757 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
758 let content = "*First line\nSecond line*\n\nRegular text";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761
762 assert_eq!(result.len(), 0);
764 }
765
766 #[test]
767 fn test_fix_preserves_trailing_newline_disabled() {
768 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
770 let content = "*Convert to heading*\n";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let fixed = rule.fix(&ctx).unwrap();
773
774 assert_eq!(fixed, content);
776 }
777
778 #[test]
779 fn test_default_config() {
780 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
781 let (name, config) = rule.default_config_section().unwrap();
782 assert_eq!(name, "MD036");
783
784 let table = config.as_table().unwrap();
785 assert_eq!(table.get("punctuation").unwrap().as_str().unwrap(), ".,;:!?");
786 assert!(!table.get("fix").unwrap().as_bool().unwrap());
789 assert_eq!(table.get("heading-style").unwrap().as_str().unwrap(), "atx");
790 assert_eq!(table.get("heading-level").unwrap().as_integer().unwrap(), 2);
791 }
792
793 #[test]
794 fn test_default_warns_but_does_not_autoconvert() {
795 let rule = MD036NoEmphasisAsHeading::from_config(&crate::config::Config::default());
801 let content = "**Michael Rose**\n\nProfile text.";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803
804 let warnings = rule.check(&ctx).unwrap();
805 assert_eq!(warnings.len(), 1, "detection should still fire by default");
806 assert!(warnings[0].fix.is_none(), "default warnings must not carry a fix");
807
808 let fixed = rule.fix(&ctx).unwrap();
809 assert_eq!(
810 fixed, content,
811 "default fmt must not auto-convert emphasis to a heading"
812 );
813 }
814
815 #[test]
816 fn test_default_preserves_real_world_emphasis() {
817 let rule = MD036NoEmphasisAsHeading::from_config(&crate::config::Config::default());
822 for content in [
823 "Intro.\n\n*Note: without the links setup, we can't demonstrate the behavior*\n\nMore.",
824 "**index.md**\n\nA configuration file.",
825 "*New in v4.26.0*\n\nA new feature.",
826 ] {
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 assert_eq!(
829 rule.fix(&ctx).unwrap(),
830 content,
831 "default fmt must preserve: {content:?}"
832 );
833 }
834 }
835
836 #[test]
837 fn test_image_caption_scenario() {
838 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
840 let content = "#### Métriques\n\n**commits par année : rumdl**\n\n";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843
844 assert_eq!(result.len(), 1);
846 assert_eq!(result[0].line, 3);
847 assert!(result[0].message.contains("commits par année : rumdl"));
848
849 assert!(result[0].fix.is_none());
851
852 let fixed = rule.fix(&ctx).unwrap();
854 assert_eq!(fixed, content);
855 }
856
857 #[test]
858 fn test_bold_with_colon_no_punctuation_config() {
859 let rule = MD036NoEmphasisAsHeading::new("".to_string());
861 let content = "**commits par année : rumdl**\n\nSome text";
862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
863 let result = rule.check(&ctx).unwrap();
864
865 assert_eq!(result.len(), 1);
867 assert!(result[0].fix.is_none());
868 }
869
870 #[test]
871 fn test_bold_with_colon_default_config() {
872 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
874 let content = "**Important Note:**\n\nSome text";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877
878 assert_eq!(result.len(), 0);
880 }
881
882 #[test]
883 fn test_mkdocs_admonition_body_not_flagged() {
884 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
888 let content = "!!! note\n\n _Foo_";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
890 let result = rule.check(&ctx).unwrap();
891
892 assert_eq!(
893 result.len(),
894 0,
895 "emphasis inside an admonition body should not be flagged, got: {result:?}"
896 );
897 }
898
899 #[test]
900 fn test_mkdocs_content_tab_body_not_flagged() {
901 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
903 let content = "=== \"Tab A\"\n\n _Foo_";
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
905 let result = rule.check(&ctx).unwrap();
906
907 assert_eq!(
908 result.len(),
909 0,
910 "emphasis inside a content tab body should not be flagged, got: {result:?}"
911 );
912 }
913
914 #[test]
915 fn test_mkdocs_top_level_emphasis_still_flagged() {
916 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
919 let content = "_Foo_\n\nRegular text";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
921 let result = rule.check(&ctx).unwrap();
922
923 assert_eq!(
924 result.len(),
925 1,
926 "top-level emphasis should still be flagged under mkdocs flavor, got: {result:?}"
927 );
928 }
929
930 #[test]
931 fn test_standard_flavor_indented_emphasis_unchanged() {
932 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
936 let content = "Intro\n\n _Foo_\n\nMore text";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule.check(&ctx).unwrap();
939
940 assert_eq!(
941 result.len(),
942 0,
943 "indented emphasis is indented code under standard flavor, got: {result:?}"
944 );
945 }
946
947 #[test]
948 fn test_mkdocs_cascade_fix_does_not_corrupt_admonition() {
949 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
953 let content = "!!! note\n\n _Foo_";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
955 let fixed = rule.fix(&ctx).unwrap();
956
957 assert_eq!(
958 fixed, content,
959 "admonition-nested emphasis must not be converted to a heading"
960 );
961 }
962
963 #[test]
964 fn test_html_markdown_div_emphasis_still_flagged() {
965 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
969 let content = "<div markdown=\"1\">\n\n_Foo_\n\n</div>";
970 for flavor in [
971 crate::config::MarkdownFlavor::Standard,
972 crate::config::MarkdownFlavor::MkDocs,
973 ] {
974 let ctx = LintContext::new(content, flavor, None);
975 let result = rule.check(&ctx).unwrap();
976 assert_eq!(
977 result.len(),
978 1,
979 "emphasis inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
980 );
981 }
982 }
983}