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 for (i, line) in content.lines().enumerate() {
217 if line.trim().is_empty() || (!line.contains('*') && !line.contains('_')) {
219 continue;
220 }
221
222 if let Some((_level, text, start_pos, end_pos)) = self.is_entire_line_emphasized(line, ctx, i) {
223 let (start_line, start_col, end_line, end_col) =
224 calculate_emphasis_range(i + 1, line, start_pos, end_pos);
225
226 let fix = if self.config.fix {
228 let prefix = self.atx_prefix();
229 let range = ctx.line_index.line_content_range(i + 1);
231 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
233 Some(Fix {
234 range,
235 replacement: format!("{leading_ws}{prefix}{text}"),
236 })
237 } else {
238 None
239 };
240
241 warnings.push(LintWarning {
242 rule_name: Some(self.name().to_string()),
243 line: start_line,
244 column: start_col,
245 end_line,
246 end_column: end_col,
247 message: format!("Emphasis used instead of a heading: '{text}'"),
248 severity: Severity::Warning,
249 fix,
250 });
251 }
252 }
253
254 Ok(warnings)
255 }
256
257 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
258 if !self.config.fix {
261 return Ok(ctx.content.to_string());
262 }
263
264 let warnings = self.check(ctx)?;
266
267 if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
269 return Ok(ctx.content.to_string());
270 }
271
272 let mut fixes: Vec<_> = warnings
274 .iter()
275 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
276 .collect();
277 fixes.sort_by(|a, b| b.0.cmp(&a.0));
278
279 let mut result = ctx.content.to_string();
281 for (start, end, replacement) in fixes {
282 if start < result.len() && end <= result.len() && start <= end {
283 result.replace_range(start..end, replacement);
284 }
285 }
286
287 Ok(result)
288 }
289
290 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
292 ctx.content.is_empty() || !ctx.likely_has_emphasis()
294 }
295
296 fn as_any(&self) -> &dyn std::any::Any {
297 self
298 }
299
300 fn default_config_section(&self) -> Option<(String, toml::Value)> {
301 let mut map = toml::map::Map::new();
302 map.insert(
303 "punctuation".to_string(),
304 toml::Value::String(self.config.punctuation.clone()),
305 );
306 map.insert("fix".to_string(), toml::Value::Boolean(self.config.fix));
307 map.insert("heading-style".to_string(), toml::Value::String("atx".to_string()));
308 map.insert(
309 "heading-level".to_string(),
310 toml::Value::Integer(i64::from(self.config.heading_level.get())),
311 );
312 Some((self.name().to_string(), toml::Value::Table(map)))
313 }
314
315 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
316 where
317 Self: Sized,
318 {
319 let punctuation = crate::config::get_rule_config_value::<String>(config, "MD036", "punctuation")
320 .unwrap_or_else(|| ".,;:!?".to_string());
321
322 let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(false);
323
324 let heading_style = HeadingStyle::Atx;
326
327 let heading_level = crate::config::get_rule_config_value::<u8>(config, "MD036", "heading-level")
329 .or_else(|| crate::config::get_rule_config_value::<u8>(config, "MD036", "heading_level"))
330 .unwrap_or(2);
331
332 Box::new(MD036NoEmphasisAsHeading::new_with_fix(
333 punctuation,
334 fix,
335 heading_style,
336 heading_level,
337 ))
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::lint_context::LintContext;
345
346 #[test]
347 fn test_single_asterisk_emphasis() {
348 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
349 let content = "*This is emphasized*\n\nRegular text";
350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
351 let result = rule.check(&ctx).unwrap();
352
353 assert_eq!(result.len(), 1);
354 assert_eq!(result[0].line, 1);
355 assert!(
356 result[0]
357 .message
358 .contains("Emphasis used instead of a heading: 'This is emphasized'")
359 );
360 }
361
362 #[test]
363 fn test_single_underscore_emphasis() {
364 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
365 let content = "_This is emphasized_\n\nRegular text";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 let result = rule.check(&ctx).unwrap();
368
369 assert_eq!(result.len(), 1);
370 assert_eq!(result[0].line, 1);
371 assert!(
372 result[0]
373 .message
374 .contains("Emphasis used instead of a heading: 'This is emphasized'")
375 );
376 }
377
378 #[test]
379 fn test_double_asterisk_strong() {
380 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
381 let content = "**This is strong**\n\nRegular text";
382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
383 let result = rule.check(&ctx).unwrap();
384
385 assert_eq!(result.len(), 1);
386 assert_eq!(result[0].line, 1);
387 assert!(
388 result[0]
389 .message
390 .contains("Emphasis used instead of a heading: 'This is strong'")
391 );
392 }
393
394 #[test]
395 fn test_double_underscore_strong() {
396 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
397 let content = "__This is strong__\n\nRegular text";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let result = rule.check(&ctx).unwrap();
400
401 assert_eq!(result.len(), 1);
402 assert_eq!(result[0].line, 1);
403 assert!(
404 result[0]
405 .message
406 .contains("Emphasis used instead of a heading: 'This is strong'")
407 );
408 }
409
410 #[test]
411 fn test_emphasis_with_punctuation() {
412 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
413 let content = "**Important Note:**\n\nRegular text";
414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
415 let result = rule.check(&ctx).unwrap();
416
417 assert_eq!(result.len(), 0);
419 }
420
421 #[test]
422 fn test_emphasis_in_paragraph() {
423 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
424 let content = "This is a paragraph with *emphasis* in the middle.";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let result = rule.check(&ctx).unwrap();
427
428 assert_eq!(result.len(), 0);
430 }
431
432 #[test]
433 fn test_emphasis_in_list() {
434 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
435 let content = "- *List item with emphasis*\n- Another item";
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_blockquote() {
445 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
446 let content = "> *Quote with emphasis*\n> Another line";
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_code_block() {
456 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
457 let content = "```\n*Not emphasis in code*\n```";
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_html_comment() {
467 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
468 let content = "<!--\n**bigger**\ncomment\n-->";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470 let result = rule.check(&ctx).unwrap();
471
472 assert_eq!(
474 result.len(),
475 0,
476 "Expected no warnings for emphasis in HTML comment, got: {result:?}"
477 );
478 }
479
480 #[test]
481 fn test_toc_label() {
482 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
483 let content = "**Table of Contents**\n\n- Item 1\n- Item 2";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486
487 assert_eq!(result.len(), 0);
489 }
490
491 #[test]
492 fn test_already_heading() {
493 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
494 let content = "# **Bold in heading**\n\nRegular text";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497
498 assert_eq!(result.len(), 0);
500 }
501
502 #[test]
503 fn test_fix_disabled_by_default() {
504 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
506 let content = "*Convert to heading*\n\nRegular text";
507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let fixed = rule.fix(&ctx).unwrap();
509
510 assert_eq!(fixed, content);
512 }
513
514 #[test]
515 fn test_fix_disabled_preserves_content() {
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_enabled_single_asterisk() {
528 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
530 let content = "*Section Title*\n\nBody text.";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let fixed = rule.fix(&ctx).unwrap();
533
534 assert_eq!(fixed, "## Section Title\n\nBody text.");
535 }
536
537 #[test]
538 fn test_fix_enabled_double_asterisk() {
539 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
541 let content = "**Section Title**\n\nBody text.";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let fixed = rule.fix(&ctx).unwrap();
544
545 assert_eq!(fixed, "## Section Title\n\nBody text.");
546 }
547
548 #[test]
549 fn test_fix_enabled_single_underscore() {
550 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 3);
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_underscore() {
561 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 4);
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_multiple_lines() {
572 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
574 let content = "**First Section**\n\nSome text.\n\n**Second Section**\n\nMore text.";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576 let fixed = rule.fix(&ctx).unwrap();
577
578 assert_eq!(
579 fixed,
580 "## First Section\n\nSome text.\n\n## Second Section\n\nMore text."
581 );
582 }
583
584 #[test]
585 fn test_fix_enabled_skips_punctuation() {
586 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
588 let content = "**Important Note:**\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, content);
594 }
595
596 #[test]
597 fn test_fix_enabled_heading_level_1() {
598 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 1);
599 let content = "**Title**";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let fixed = rule.fix(&ctx).unwrap();
602
603 assert_eq!(fixed, "# Title");
604 }
605
606 #[test]
607 fn test_fix_enabled_heading_level_6() {
608 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 6);
609 let content = "**Subsubsubheading**";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let fixed = rule.fix(&ctx).unwrap();
612
613 assert_eq!(fixed, "###### Subsubsubheading");
614 }
615
616 #[test]
617 fn test_fix_preserves_trailing_newline_enabled() {
618 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
619 let content = "**Heading**\n";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let fixed = rule.fix(&ctx).unwrap();
622
623 assert_eq!(fixed, "## Heading\n");
624 }
625
626 #[test]
627 fn test_fix_idempotent() {
628 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
630 let content = "**Section Title**\n\nBody text.";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let fixed1 = rule.fix(&ctx).unwrap();
633 assert_eq!(fixed1, "## Section Title\n\nBody text.");
634
635 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
637 let fixed2 = rule.fix(&ctx2).unwrap();
638 assert_eq!(fixed2, fixed1, "Fix should be idempotent");
639 }
640
641 #[test]
642 fn test_fix_skips_lists() {
643 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
644 let content = "- *List item*\n- Another item";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let fixed = rule.fix(&ctx).unwrap();
647
648 assert_eq!(fixed, content);
650 }
651
652 #[test]
653 fn test_fix_skips_blockquotes() {
654 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
655 let content = "> **Quoted text**\n> More quote";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let fixed = rule.fix(&ctx).unwrap();
658
659 assert_eq!(fixed, content);
661 }
662
663 #[test]
664 fn test_fix_skips_code_blocks() {
665 let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
666 let content = "```\n**Not a heading**\n```";
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_empty_punctuation_config() {
676 let rule = MD036NoEmphasisAsHeading::new("".to_string());
677 let content = "**Important Note:**\n\nRegular text";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680
681 assert_eq!(result.len(), 1);
683
684 let fixed = rule.fix(&ctx).unwrap();
685 assert_eq!(fixed, content);
687 }
688
689 #[test]
690 fn test_empty_punctuation_config_with_fix() {
691 let rule = MD036NoEmphasisAsHeading::new_with_fix("".to_string(), true, HeadingStyle::Atx, 2);
693 let content = "**Important Note:**\n\nRegular text";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let fixed = rule.fix(&ctx).unwrap();
696
697 assert_eq!(fixed, "## Important Note:\n\nRegular text");
699 }
700
701 #[test]
702 fn test_multiple_emphasized_lines() {
703 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
704 let content = "*First heading*\n\nSome text\n\n**Second heading**\n\nMore text";
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let result = rule.check(&ctx).unwrap();
707
708 assert_eq!(result.len(), 2);
709 assert_eq!(result[0].line, 1);
710 assert_eq!(result[1].line, 5);
711 }
712
713 #[test]
714 fn test_whitespace_handling() {
715 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
716 let content = " **Indented emphasis** \n\nRegular 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(), 1);
721 assert_eq!(result[0].line, 1);
722 }
723
724 #[test]
725 fn test_nested_emphasis() {
726 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
727 let content = "***Not a simple emphasis***\n\nRegular text";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730
731 assert_eq!(result.len(), 0);
733 }
734
735 #[test]
736 fn test_emphasis_with_newlines() {
737 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
738 let content = "*First line\nSecond line*\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(), 0);
744 }
745
746 #[test]
747 fn test_fix_preserves_trailing_newline_disabled() {
748 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
750 let content = "*Convert to heading*\n";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752 let fixed = rule.fix(&ctx).unwrap();
753
754 assert_eq!(fixed, content);
756 }
757
758 #[test]
759 fn test_default_config() {
760 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
761 let (name, config) = rule.default_config_section().unwrap();
762 assert_eq!(name, "MD036");
763
764 let table = config.as_table().unwrap();
765 assert_eq!(table.get("punctuation").unwrap().as_str().unwrap(), ".,;:!?");
766 assert!(!table.get("fix").unwrap().as_bool().unwrap());
767 assert_eq!(table.get("heading-style").unwrap().as_str().unwrap(), "atx");
768 assert_eq!(table.get("heading-level").unwrap().as_integer().unwrap(), 2);
769 }
770
771 #[test]
772 fn test_image_caption_scenario() {
773 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
775 let content = "#### Métriques\n\n**commits par année : rumdl**\n\n";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778
779 assert_eq!(result.len(), 1);
781 assert_eq!(result[0].line, 3);
782 assert!(result[0].message.contains("commits par année : rumdl"));
783
784 assert!(result[0].fix.is_none());
786
787 let fixed = rule.fix(&ctx).unwrap();
789 assert_eq!(fixed, content);
790 }
791
792 #[test]
793 fn test_bold_with_colon_no_punctuation_config() {
794 let rule = MD036NoEmphasisAsHeading::new("".to_string());
796 let content = "**commits par année : rumdl**\n\nSome text";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799
800 assert_eq!(result.len(), 1);
802 assert!(result[0].fix.is_none());
803 }
804
805 #[test]
806 fn test_bold_with_colon_default_config() {
807 let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
809 let content = "**Important Note:**\n\nSome text";
810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx).unwrap();
812
813 assert_eq!(result.len(), 0);
815 }
816}