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::new(range, format!("{leading_ws}{prefix}{text}")))
250 } else {
251 None
252 };
253
254 warnings.push(LintWarning {
255 rule_name: Some(self.name().to_string()),
256 line: start_line,
257 column: start_col,
258 end_line,
259 end_column: end_col,
260 message: format!("Emphasis used instead of a heading: '{text}'"),
261 severity: Severity::Warning,
262 fix,
263 });
264 }
265 }
266
267 Ok(warnings)
268 }
269
270 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
271 if !self.config.fix {
274 return Ok(ctx.content.to_string());
275 }
276
277 let warnings = self.check(ctx)?;
279 let warnings =
280 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
281
282 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
284 return Ok(ctx.content.to_string());
285 }
286
287 let mut fixes: Vec<_> = warnings
289 .iter()
290 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
291 .collect();
292 fixes.sort_by(|a, b| b.0.cmp(&a.0));
293
294 let mut result = ctx.content.to_string();
296 for (start, end, replacement) in fixes {
297 if start < result.len() && end <= result.len() && start <= end {
298 result.replace_range(start..end, replacement);
299 }
300 }
301
302 Ok(result)
303 }
304
305 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
307 ctx.content.is_empty() || !ctx.likely_has_emphasis()
309 }
310
311 fn as_any(&self) -> &dyn std::any::Any {
312 self
313 }
314
315 fn default_config_section(&self) -> Option<(String, toml::Value)> {
316 let mut map = toml::map::Map::new();
317 map.insert(
318 "punctuation".to_string(),
319 toml::Value::String(self.config.punctuation.clone()),
320 );
321 map.insert("fix".to_string(), toml::Value::Boolean(true));
326 map.insert("heading-style".to_string(), toml::Value::String("atx".to_string()));
327 map.insert(
328 "heading-level".to_string(),
329 toml::Value::Integer(i64::from(self.config.heading_level.get())),
330 );
331 Some((self.name().to_string(), toml::Value::Table(map)))
332 }
333
334 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
335 where
336 Self: Sized,
337 {
338 let punctuation = crate::config::get_rule_config_value::<String>(config, "MD036", "punctuation")
339 .unwrap_or_else(|| ".,;:!?".to_string());
340
341 let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(true);
348
349 let heading_style = HeadingStyle::Atx;
351
352 let heading_level = crate::config::get_rule_config_value::<u8>(config, "MD036", "heading-level")
354 .or_else(|| crate::config::get_rule_config_value::<u8>(config, "MD036", "heading_level"))
355 .unwrap_or(2);
356
357 Box::new(MD036NoEmphasisAsHeading::new_with_fix(
358 punctuation,
359 fix,
360 heading_style,
361 heading_level,
362 ))
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::lint_context::LintContext;
370
371 #[test]
372 fn test_single_asterisk_emphasis() {
373 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
374 let content = "*This is emphasized*\n\nRegular text";
375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
376 let result = rule.check(&ctx).unwrap();
377
378 assert_eq!(result.len(), 1);
379 assert_eq!(result[0].line, 1);
380 assert!(
381 result[0]
382 .message
383 .contains("Emphasis used instead of a heading: 'This is emphasized'")
384 );
385 }
386
387 #[test]
388 fn test_single_underscore_emphasis() {
389 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
390 let content = "_This is emphasized_\n\nRegular text";
391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
392 let result = rule.check(&ctx).unwrap();
393
394 assert_eq!(result.len(), 1);
395 assert_eq!(result[0].line, 1);
396 assert!(
397 result[0]
398 .message
399 .contains("Emphasis used instead of a heading: 'This is emphasized'")
400 );
401 }
402
403 #[test]
404 fn test_double_asterisk_strong() {
405 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
406 let content = "**This is strong**\n\nRegular text";
407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
408 let result = rule.check(&ctx).unwrap();
409
410 assert_eq!(result.len(), 1);
411 assert_eq!(result[0].line, 1);
412 assert!(
413 result[0]
414 .message
415 .contains("Emphasis used instead of a heading: 'This is strong'")
416 );
417 }
418
419 #[test]
420 fn test_double_underscore_strong() {
421 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
422 let content = "__This is strong__\n\nRegular text";
423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424 let result = rule.check(&ctx).unwrap();
425
426 assert_eq!(result.len(), 1);
427 assert_eq!(result[0].line, 1);
428 assert!(
429 result[0]
430 .message
431 .contains("Emphasis used instead of a heading: 'This is strong'")
432 );
433 }
434
435 #[test]
436 fn test_emphasis_with_punctuation() {
437 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
438 let content = "**Important Note:**\n\nRegular text";
439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
440 let result = rule.check(&ctx).unwrap();
441
442 assert_eq!(result.len(), 0);
444 }
445
446 #[test]
447 fn test_emphasis_in_paragraph() {
448 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
449 let content = "This is a paragraph with *emphasis* in the middle.";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
451 let result = rule.check(&ctx).unwrap();
452
453 assert_eq!(result.len(), 0);
455 }
456
457 #[test]
458 fn test_emphasis_in_list() {
459 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
460 let content = "- *List item with emphasis*\n- Another item";
461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462 let result = rule.check(&ctx).unwrap();
463
464 assert_eq!(result.len(), 0);
466 }
467
468 #[test]
469 fn test_emphasis_in_blockquote() {
470 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
471 let content = "> *Quote with emphasis*\n> Another line";
472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473 let result = rule.check(&ctx).unwrap();
474
475 assert_eq!(result.len(), 0);
477 }
478
479 #[test]
480 fn test_emphasis_in_code_block() {
481 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
482 let content = "```\n*Not emphasis in code*\n```";
483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484 let result = rule.check(&ctx).unwrap();
485
486 assert_eq!(result.len(), 0);
488 }
489
490 #[test]
491 fn test_emphasis_in_html_comment() {
492 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
493 let content = "<!--\n**bigger**\ncomment\n-->";
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495 let result = rule.check(&ctx).unwrap();
496
497 assert_eq!(
499 result.len(),
500 0,
501 "Expected no warnings for emphasis in HTML comment, got: {result:?}"
502 );
503 }
504
505 #[test]
506 fn test_toc_label() {
507 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
508 let content = "**Table of Contents**\n\n- Item 1\n- Item 2";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let result = rule.check(&ctx).unwrap();
511
512 assert_eq!(result.len(), 0);
514 }
515
516 #[test]
517 fn test_already_heading() {
518 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
519 let content = "# **Bold in heading**\n\nRegular text";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522
523 assert_eq!(result.len(), 0);
525 }
526
527 #[test]
528 fn test_fix_disabled_by_default() {
529 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
531 let content = "*Convert to heading*\n\nRegular text";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let fixed = rule.fix(&ctx).unwrap();
534
535 assert_eq!(fixed, content);
537 }
538
539 #[test]
540 fn test_fix_disabled_preserves_content() {
541 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
543 let content = "**Convert to heading**\n\nRegular text";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let fixed = rule.fix(&ctx).unwrap();
546
547 assert_eq!(fixed, content);
549 }
550
551 #[test]
552 fn test_fix_enabled_single_asterisk() {
553 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
555 let content = "*Section Title*\n\nBody text.";
556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
557 let fixed = rule.fix(&ctx).unwrap();
558
559 assert_eq!(fixed, "## Section Title\n\nBody text.");
560 }
561
562 #[test]
563 fn test_fix_enabled_double_asterisk() {
564 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
566 let content = "**Section Title**\n\nBody text.";
567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568 let fixed = rule.fix(&ctx).unwrap();
569
570 assert_eq!(fixed, "## Section Title\n\nBody text.");
571 }
572
573 #[test]
574 fn test_fix_enabled_single_underscore() {
575 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 3);
577 let content = "_Section Title_\n\nBody text.";
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 let fixed = rule.fix(&ctx).unwrap();
580
581 assert_eq!(fixed, "### Section Title\n\nBody text.");
582 }
583
584 #[test]
585 fn test_fix_enabled_double_underscore() {
586 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 4);
588 let content = "__Section Title__\n\nBody text.";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 let fixed = rule.fix(&ctx).unwrap();
591
592 assert_eq!(fixed, "#### Section Title\n\nBody text.");
593 }
594
595 #[test]
596 fn test_fix_enabled_multiple_lines() {
597 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
599 let content = "**First Section**\n\nSome text.\n\n**Second Section**\n\nMore text.";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let fixed = rule.fix(&ctx).unwrap();
602
603 assert_eq!(
604 fixed,
605 "## First Section\n\nSome text.\n\n## Second Section\n\nMore text."
606 );
607 }
608
609 #[test]
610 fn test_fix_enabled_skips_punctuation() {
611 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
613 let content = "**Important Note:**\n\nBody text.";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let fixed = rule.fix(&ctx).unwrap();
616
617 assert_eq!(fixed, content);
619 }
620
621 #[test]
622 fn test_fix_enabled_heading_level_1() {
623 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 1);
624 let content = "**Title**";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626 let fixed = rule.fix(&ctx).unwrap();
627
628 assert_eq!(fixed, "# Title");
629 }
630
631 #[test]
632 fn test_fix_enabled_heading_level_6() {
633 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 6);
634 let content = "**Subsubsubheading**";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let fixed = rule.fix(&ctx).unwrap();
637
638 assert_eq!(fixed, "###### Subsubsubheading");
639 }
640
641 #[test]
642 fn test_fix_preserves_trailing_newline_enabled() {
643 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
644 let content = "**Heading**\n";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let fixed = rule.fix(&ctx).unwrap();
647
648 assert_eq!(fixed, "## Heading\n");
649 }
650
651 #[test]
652 fn test_fix_idempotent() {
653 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
655 let content = "**Section Title**\n\nBody text.";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let fixed1 = rule.fix(&ctx).unwrap();
658 assert_eq!(fixed1, "## Section Title\n\nBody text.");
659
660 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
662 let fixed2 = rule.fix(&ctx2).unwrap();
663 assert_eq!(fixed2, fixed1, "Fix should be idempotent");
664 }
665
666 #[test]
667 fn test_fix_skips_lists() {
668 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
669 let content = "- *List item*\n- Another item";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let fixed = rule.fix(&ctx).unwrap();
672
673 assert_eq!(fixed, content);
675 }
676
677 #[test]
678 fn test_fix_skips_blockquotes() {
679 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
680 let content = "> **Quoted text**\n> More quote";
681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
682 let fixed = rule.fix(&ctx).unwrap();
683
684 assert_eq!(fixed, content);
686 }
687
688 #[test]
689 fn test_fix_skips_code_blocks() {
690 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
691 let content = "```\n**Not a heading**\n```";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let fixed = rule.fix(&ctx).unwrap();
694
695 assert_eq!(fixed, content);
697 }
698
699 #[test]
700 fn test_empty_punctuation_config() {
701 let rule = MD036NoEmphasisAsHeading::new("".to_string());
702 let content = "**Important Note:**\n\nRegular text";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let result = rule.check(&ctx).unwrap();
705
706 assert_eq!(result.len(), 1);
708
709 let fixed = rule.fix(&ctx).unwrap();
710 assert_eq!(fixed, content);
712 }
713
714 #[test]
715 fn test_empty_punctuation_config_with_fix() {
716 let rule = MD036NoEmphasisAsHeading::new_with_fix("".to_string(), true, HeadingStyle::Atx, 2);
718 let content = "**Important Note:**\n\nRegular text";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let fixed = rule.fix(&ctx).unwrap();
721
722 assert_eq!(fixed, "## Important Note:\n\nRegular text");
724 }
725
726 #[test]
727 fn test_multiple_emphasized_lines() {
728 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
729 let content = "*First heading*\n\nSome text\n\n**Second heading**\n\nMore text";
730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731 let result = rule.check(&ctx).unwrap();
732
733 assert_eq!(result.len(), 2);
734 assert_eq!(result[0].line, 1);
735 assert_eq!(result[1].line, 5);
736 }
737
738 #[test]
739 fn test_whitespace_handling() {
740 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
741 let content = " **Indented emphasis** \n\nRegular text";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let result = rule.check(&ctx).unwrap();
744
745 assert_eq!(result.len(), 1);
746 assert_eq!(result[0].line, 1);
747 }
748
749 #[test]
750 fn test_nested_emphasis() {
751 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
752 let content = "***Not a simple emphasis***\n\nRegular text";
753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754 let result = rule.check(&ctx).unwrap();
755
756 assert_eq!(result.len(), 0);
758 }
759
760 #[test]
761 fn test_emphasis_with_newlines() {
762 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
763 let content = "*First line\nSecond line*\n\nRegular text";
764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765 let result = rule.check(&ctx).unwrap();
766
767 assert_eq!(result.len(), 0);
769 }
770
771 #[test]
772 fn test_fix_preserves_trailing_newline_disabled() {
773 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
775 let content = "*Convert to heading*\n";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let fixed = rule.fix(&ctx).unwrap();
778
779 assert_eq!(fixed, content);
781 }
782
783 #[test]
784 fn test_default_config() {
785 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
786 let (name, config) = rule.default_config_section().unwrap();
787 assert_eq!(name, "MD036");
788
789 let table = config.as_table().unwrap();
790 assert_eq!(table.get("punctuation").unwrap().as_str().unwrap(), ".,;:!?");
791 assert!(table.get("fix").unwrap().as_bool().unwrap());
794 assert_eq!(table.get("heading-style").unwrap().as_str().unwrap(), "atx");
795 assert_eq!(table.get("heading-level").unwrap().as_integer().unwrap(), 2);
796 }
797
798 #[test]
799 fn test_image_caption_scenario() {
800 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
802 let content = "#### Métriques\n\n**commits par année : rumdl**\n\n";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804 let result = rule.check(&ctx).unwrap();
805
806 assert_eq!(result.len(), 1);
808 assert_eq!(result[0].line, 3);
809 assert!(result[0].message.contains("commits par année : rumdl"));
810
811 assert!(result[0].fix.is_none());
813
814 let fixed = rule.fix(&ctx).unwrap();
816 assert_eq!(fixed, content);
817 }
818
819 #[test]
820 fn test_bold_with_colon_no_punctuation_config() {
821 let rule = MD036NoEmphasisAsHeading::new("".to_string());
823 let content = "**commits par année : rumdl**\n\nSome text";
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826
827 assert_eq!(result.len(), 1);
829 assert!(result[0].fix.is_none());
830 }
831
832 #[test]
833 fn test_bold_with_colon_default_config() {
834 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
836 let content = "**Important Note:**\n\nSome text";
837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
838 let result = rule.check(&ctx).unwrap();
839
840 assert_eq!(result.len(), 0);
842 }
843}