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