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 pub fn from_config_struct(config: MD036Config) -> Self {
65 Self { config }
66 }
67
68 fn atx_prefix(&self) -> String {
70 let level = self.config.heading_level.get();
72 format!("{} ", "#".repeat(level as usize))
73 }
74
75 fn ends_with_punctuation(&self, text: &str) -> bool {
76 if text.is_empty() {
77 return false;
78 }
79 let trimmed = text.trim();
80 if trimmed.is_empty() {
81 return false;
82 }
83 trimmed
85 .chars()
86 .last()
87 .is_some_and(|ch| self.config.punctuation.contains(ch))
88 }
89
90 fn contains_link_or_code(&self, text: &str) -> bool {
91 if text.contains('`') {
95 return true;
96 }
97
98 if text.contains('[') && text.contains(']') {
102 if text.contains("](") {
104 return true;
105 }
106 if text.contains("][") || text.ends_with(']') {
108 return true;
109 }
110 }
111
112 false
113 }
114
115 fn is_entire_line_emphasized(
116 &self,
117 line: &str,
118 ctx: &crate::lint_context::LintContext,
119 line_num: usize,
120 ) -> Option<(usize, String, usize, usize)> {
121 let original_line = line;
122 let line = line.trim();
123
124 if line.is_empty() || (!line.contains('*') && !line.contains('_')) {
126 return None;
127 }
128
129 if HEADING_MARKER.is_match(line) && !HEADING_WITH_EMPHASIS.is_match(line) {
131 return None;
132 }
133
134 if TOC_LABEL_PATTERN.is_match(line) {
136 return None;
137 }
138
139 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 })
149 {
150 return None;
151 }
152
153 let check_emphasis = |text: &str, level: usize, pattern: String| -> Option<(usize, String, usize, usize)> {
155 if !self.config.punctuation.is_empty() && self.ends_with_punctuation(text) {
157 return None;
158 }
159 if self.contains_link_or_code(text) {
162 return None;
163 }
164 let start_pos = original_line.find(&pattern).unwrap_or(0);
166 let end_pos = start_pos + pattern.len();
167 Some((level, text.to_string(), start_pos, end_pos))
168 };
169
170 if let Some(caps) = RE_ASTERISK_SINGLE.captures(line) {
172 let text = caps.get(1).unwrap().as_str();
173 let pattern = format!("*{text}*");
174 return check_emphasis(text, 1, pattern);
175 }
176
177 if let Some(caps) = RE_UNDERSCORE_SINGLE.captures(line) {
179 let text = caps.get(1).unwrap().as_str();
180 let pattern = format!("_{text}_");
181 return check_emphasis(text, 1, pattern);
182 }
183
184 if let Some(caps) = RE_ASTERISK_DOUBLE.captures(line) {
186 let text = caps.get(1).unwrap().as_str();
187 let pattern = format!("**{text}**");
188 return check_emphasis(text, 2, pattern);
189 }
190
191 if let Some(caps) = RE_UNDERSCORE_DOUBLE.captures(line) {
193 let text = caps.get(1).unwrap().as_str();
194 let pattern = format!("__{text}__");
195 return check_emphasis(text, 2, pattern);
196 }
197
198 None
199 }
200}
201
202impl Rule for MD036NoEmphasisAsHeading {
203 fn name(&self) -> &'static str {
204 "MD036"
205 }
206
207 fn description(&self) -> &'static str {
208 "Emphasis should not be used instead of a heading"
209 }
210
211 fn category(&self) -> RuleCategory {
212 RuleCategory::Emphasis
213 }
214
215 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
216 let content = ctx.content;
217 if content.is_empty() || (!content.contains('*') && !content.contains('_')) {
219 return Ok(Vec::new());
220 }
221
222 let mut warnings = Vec::new();
223
224 let lines: Vec<&str> = content.lines().collect();
225 let line_count = lines.len();
226
227 for (i, line) in lines.iter().enumerate() {
228 if line.trim().is_empty() || (!line.contains('*') && !line.contains('_')) {
230 continue;
231 }
232
233 let prev_blank = i == 0 || lines[i - 1].trim().is_empty();
237 let next_blank = i + 1 >= line_count || lines[i + 1].trim().is_empty();
238 if !prev_blank || !next_blank {
239 continue;
240 }
241
242 if let Some((_level, text, start_pos, end_pos)) = self.is_entire_line_emphasized(line, ctx, i) {
243 let (start_line, start_col, end_line, end_col) =
244 calculate_emphasis_range(i + 1, line, start_pos, end_pos);
245
246 let fix = if self.config.fix {
248 let prefix = self.atx_prefix();
249 let range = ctx.line_index.line_content_range(i + 1);
251 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
253 Some(Fix {
254 range,
255 replacement: format!("{leading_ws}{prefix}{text}"),
256 })
257 } else {
258 None
259 };
260
261 warnings.push(LintWarning {
262 rule_name: Some(self.name().to_string()),
263 line: start_line,
264 column: start_col,
265 end_line,
266 end_column: end_col,
267 message: format!("Emphasis used instead of a heading: '{text}'"),
268 severity: Severity::Warning,
269 fix,
270 });
271 }
272 }
273
274 Ok(warnings)
275 }
276
277 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
278 if !self.config.fix {
281 return Ok(ctx.content.to_string());
282 }
283
284 let warnings = self.check(ctx)?;
286 let warnings =
287 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
288
289 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
291 return Ok(ctx.content.to_string());
292 }
293
294 let mut fixes: Vec<_> = warnings
296 .iter()
297 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
298 .collect();
299 fixes.sort_by(|a, b| b.0.cmp(&a.0));
300
301 let mut result = ctx.content.to_string();
303 for (start, end, replacement) in fixes {
304 if start < result.len() && end <= result.len() && start <= end {
305 result.replace_range(start..end, replacement);
306 }
307 }
308
309 Ok(result)
310 }
311
312 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
314 ctx.content.is_empty() || !ctx.likely_has_emphasis()
316 }
317
318 fn as_any(&self) -> &dyn std::any::Any {
319 self
320 }
321
322 fn default_config_section(&self) -> Option<(String, toml::Value)> {
323 let mut map = toml::map::Map::new();
324 map.insert(
325 "punctuation".to_string(),
326 toml::Value::String(self.config.punctuation.clone()),
327 );
328 map.insert("fix".to_string(), toml::Value::Boolean(self.config.fix));
329 map.insert("heading-style".to_string(), toml::Value::String("atx".to_string()));
330 map.insert(
331 "heading-level".to_string(),
332 toml::Value::Integer(i64::from(self.config.heading_level.get())),
333 );
334 Some((self.name().to_string(), toml::Value::Table(map)))
335 }
336
337 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
338 where
339 Self: Sized,
340 {
341 let punctuation = crate::config::get_rule_config_value::<String>(config, "MD036", "punctuation")
342 .unwrap_or_else(|| ".,;:!?".to_string());
343
344 let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(false);
345
346 let heading_style = HeadingStyle::Atx;
348
349 let heading_level = crate::config::get_rule_config_value::<u8>(config, "MD036", "heading-level")
351 .or_else(|| crate::config::get_rule_config_value::<u8>(config, "MD036", "heading_level"))
352 .unwrap_or(2);
353
354 Box::new(MD036NoEmphasisAsHeading::new_with_fix(
355 punctuation,
356 fix,
357 heading_style,
358 heading_level,
359 ))
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::lint_context::LintContext;
367
368 #[test]
369 fn test_single_asterisk_emphasis() {
370 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
371 let content = "*This is emphasized*\n\nRegular text";
372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
373 let result = rule.check(&ctx).unwrap();
374
375 assert_eq!(result.len(), 1);
376 assert_eq!(result[0].line, 1);
377 assert!(
378 result[0]
379 .message
380 .contains("Emphasis used instead of a heading: 'This is emphasized'")
381 );
382 }
383
384 #[test]
385 fn test_single_underscore_emphasis() {
386 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
387 let content = "_This is emphasized_\n\nRegular text";
388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389 let result = rule.check(&ctx).unwrap();
390
391 assert_eq!(result.len(), 1);
392 assert_eq!(result[0].line, 1);
393 assert!(
394 result[0]
395 .message
396 .contains("Emphasis used instead of a heading: 'This is emphasized'")
397 );
398 }
399
400 #[test]
401 fn test_double_asterisk_strong() {
402 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
403 let content = "**This is strong**\n\nRegular text";
404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
405 let result = rule.check(&ctx).unwrap();
406
407 assert_eq!(result.len(), 1);
408 assert_eq!(result[0].line, 1);
409 assert!(
410 result[0]
411 .message
412 .contains("Emphasis used instead of a heading: 'This is strong'")
413 );
414 }
415
416 #[test]
417 fn test_double_underscore_strong() {
418 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
419 let content = "__This is strong__\n\nRegular text";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422
423 assert_eq!(result.len(), 1);
424 assert_eq!(result[0].line, 1);
425 assert!(
426 result[0]
427 .message
428 .contains("Emphasis used instead of a heading: 'This is strong'")
429 );
430 }
431
432 #[test]
433 fn test_emphasis_with_punctuation() {
434 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
435 let content = "**Important Note:**\n\nRegular text";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438
439 assert_eq!(result.len(), 0);
441 }
442
443 #[test]
444 fn test_emphasis_in_paragraph() {
445 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
446 let content = "This is a paragraph with *emphasis* in the middle.";
447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448 let result = rule.check(&ctx).unwrap();
449
450 assert_eq!(result.len(), 0);
452 }
453
454 #[test]
455 fn test_emphasis_in_list() {
456 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
457 let content = "- *List item with emphasis*\n- Another item";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
459 let result = rule.check(&ctx).unwrap();
460
461 assert_eq!(result.len(), 0);
463 }
464
465 #[test]
466 fn test_emphasis_in_blockquote() {
467 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
468 let content = "> *Quote with emphasis*\n> Another line";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470 let result = rule.check(&ctx).unwrap();
471
472 assert_eq!(result.len(), 0);
474 }
475
476 #[test]
477 fn test_emphasis_in_code_block() {
478 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
479 let content = "```\n*Not emphasis in code*\n```";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let result = rule.check(&ctx).unwrap();
482
483 assert_eq!(result.len(), 0);
485 }
486
487 #[test]
488 fn test_emphasis_in_html_comment() {
489 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
490 let content = "<!--\n**bigger**\ncomment\n-->";
491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492 let result = rule.check(&ctx).unwrap();
493
494 assert_eq!(
496 result.len(),
497 0,
498 "Expected no warnings for emphasis in HTML comment, got: {result:?}"
499 );
500 }
501
502 #[test]
503 fn test_toc_label() {
504 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
505 let content = "**Table of Contents**\n\n- Item 1\n- Item 2";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let result = rule.check(&ctx).unwrap();
508
509 assert_eq!(result.len(), 0);
511 }
512
513 #[test]
514 fn test_already_heading() {
515 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
516 let content = "# **Bold in heading**\n\nRegular text";
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518 let result = rule.check(&ctx).unwrap();
519
520 assert_eq!(result.len(), 0);
522 }
523
524 #[test]
525 fn test_fix_disabled_by_default() {
526 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
528 let content = "*Convert to heading*\n\nRegular text";
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let fixed = rule.fix(&ctx).unwrap();
531
532 assert_eq!(fixed, content);
534 }
535
536 #[test]
537 fn test_fix_disabled_preserves_content() {
538 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
540 let content = "**Convert to heading**\n\nRegular text";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let fixed = rule.fix(&ctx).unwrap();
543
544 assert_eq!(fixed, content);
546 }
547
548 #[test]
549 fn test_fix_enabled_single_asterisk() {
550 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
552 let content = "*Section Title*\n\nBody text.";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let fixed = rule.fix(&ctx).unwrap();
555
556 assert_eq!(fixed, "## Section Title\n\nBody text.");
557 }
558
559 #[test]
560 fn test_fix_enabled_double_asterisk() {
561 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
563 let content = "**Section Title**\n\nBody text.";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let fixed = rule.fix(&ctx).unwrap();
566
567 assert_eq!(fixed, "## Section Title\n\nBody text.");
568 }
569
570 #[test]
571 fn test_fix_enabled_single_underscore() {
572 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 3);
574 let content = "_Section Title_\n\nBody text.";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576 let fixed = rule.fix(&ctx).unwrap();
577
578 assert_eq!(fixed, "### Section Title\n\nBody text.");
579 }
580
581 #[test]
582 fn test_fix_enabled_double_underscore() {
583 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 4);
585 let content = "__Section Title__\n\nBody text.";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let fixed = rule.fix(&ctx).unwrap();
588
589 assert_eq!(fixed, "#### Section Title\n\nBody text.");
590 }
591
592 #[test]
593 fn test_fix_enabled_multiple_lines() {
594 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
596 let content = "**First Section**\n\nSome text.\n\n**Second Section**\n\nMore text.";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598 let fixed = rule.fix(&ctx).unwrap();
599
600 assert_eq!(
601 fixed,
602 "## First Section\n\nSome text.\n\n## Second Section\n\nMore text."
603 );
604 }
605
606 #[test]
607 fn test_fix_enabled_skips_punctuation() {
608 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
610 let content = "**Important Note:**\n\nBody text.";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let fixed = rule.fix(&ctx).unwrap();
613
614 assert_eq!(fixed, content);
616 }
617
618 #[test]
619 fn test_fix_enabled_heading_level_1() {
620 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 1);
621 let content = "**Title**";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let fixed = rule.fix(&ctx).unwrap();
624
625 assert_eq!(fixed, "# Title");
626 }
627
628 #[test]
629 fn test_fix_enabled_heading_level_6() {
630 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 6);
631 let content = "**Subsubsubheading**";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let fixed = rule.fix(&ctx).unwrap();
634
635 assert_eq!(fixed, "###### Subsubsubheading");
636 }
637
638 #[test]
639 fn test_fix_preserves_trailing_newline_enabled() {
640 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
641 let content = "**Heading**\n";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let fixed = rule.fix(&ctx).unwrap();
644
645 assert_eq!(fixed, "## Heading\n");
646 }
647
648 #[test]
649 fn test_fix_idempotent() {
650 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
652 let content = "**Section Title**\n\nBody text.";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let fixed1 = rule.fix(&ctx).unwrap();
655 assert_eq!(fixed1, "## Section Title\n\nBody text.");
656
657 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
659 let fixed2 = rule.fix(&ctx2).unwrap();
660 assert_eq!(fixed2, fixed1, "Fix should be idempotent");
661 }
662
663 #[test]
664 fn test_fix_skips_lists() {
665 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
666 let content = "- *List item*\n- Another item";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let fixed = rule.fix(&ctx).unwrap();
669
670 assert_eq!(fixed, content);
672 }
673
674 #[test]
675 fn test_fix_skips_blockquotes() {
676 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
677 let content = "> **Quoted text**\n> More quote";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let fixed = rule.fix(&ctx).unwrap();
680
681 assert_eq!(fixed, content);
683 }
684
685 #[test]
686 fn test_fix_skips_code_blocks() {
687 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
688 let content = "```\n**Not a heading**\n```";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let fixed = rule.fix(&ctx).unwrap();
691
692 assert_eq!(fixed, content);
694 }
695
696 #[test]
697 fn test_empty_punctuation_config() {
698 let rule = MD036NoEmphasisAsHeading::new("".to_string());
699 let content = "**Important Note:**\n\nRegular text";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
702
703 assert_eq!(result.len(), 1);
705
706 let fixed = rule.fix(&ctx).unwrap();
707 assert_eq!(fixed, content);
709 }
710
711 #[test]
712 fn test_empty_punctuation_config_with_fix() {
713 let rule = MD036NoEmphasisAsHeading::new_with_fix("".to_string(), true, HeadingStyle::Atx, 2);
715 let content = "**Important Note:**\n\nRegular text";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let fixed = rule.fix(&ctx).unwrap();
718
719 assert_eq!(fixed, "## Important Note:\n\nRegular text");
721 }
722
723 #[test]
724 fn test_multiple_emphasized_lines() {
725 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
726 let content = "*First heading*\n\nSome text\n\n**Second heading**\n\nMore text";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let result = rule.check(&ctx).unwrap();
729
730 assert_eq!(result.len(), 2);
731 assert_eq!(result[0].line, 1);
732 assert_eq!(result[1].line, 5);
733 }
734
735 #[test]
736 fn test_whitespace_handling() {
737 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
738 let content = " **Indented emphasis** \n\nRegular text";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741
742 assert_eq!(result.len(), 1);
743 assert_eq!(result[0].line, 1);
744 }
745
746 #[test]
747 fn test_nested_emphasis() {
748 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
749 let content = "***Not a simple emphasis***\n\nRegular text";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751 let result = rule.check(&ctx).unwrap();
752
753 assert_eq!(result.len(), 0);
755 }
756
757 #[test]
758 fn test_emphasis_with_newlines() {
759 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
760 let content = "*First line\nSecond line*\n\nRegular text";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763
764 assert_eq!(result.len(), 0);
766 }
767
768 #[test]
769 fn test_fix_preserves_trailing_newline_disabled() {
770 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
772 let content = "*Convert to heading*\n";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let fixed = rule.fix(&ctx).unwrap();
775
776 assert_eq!(fixed, content);
778 }
779
780 #[test]
781 fn test_default_config() {
782 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
783 let (name, config) = rule.default_config_section().unwrap();
784 assert_eq!(name, "MD036");
785
786 let table = config.as_table().unwrap();
787 assert_eq!(table.get("punctuation").unwrap().as_str().unwrap(), ".,;:!?");
788 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_image_caption_scenario() {
795 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
797 let content = "#### Métriques\n\n**commits par année : rumdl**\n\n";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let result = rule.check(&ctx).unwrap();
800
801 assert_eq!(result.len(), 1);
803 assert_eq!(result[0].line, 3);
804 assert!(result[0].message.contains("commits par année : rumdl"));
805
806 assert!(result[0].fix.is_none());
808
809 let fixed = rule.fix(&ctx).unwrap();
811 assert_eq!(fixed, content);
812 }
813
814 #[test]
815 fn test_bold_with_colon_no_punctuation_config() {
816 let rule = MD036NoEmphasisAsHeading::new("".to_string());
818 let content = "**commits par année : rumdl**\n\nSome text";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820 let result = rule.check(&ctx).unwrap();
821
822 assert_eq!(result.len(), 1);
824 assert!(result[0].fix.is_none());
825 }
826
827 #[test]
828 fn test_bold_with_colon_default_config() {
829 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
831 let content = "**Important Note:**\n\nSome text";
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let result = rule.check(&ctx).unwrap();
834
835 assert_eq!(result.len(), 0);
837 }
838}