Skip to main content

rumdl_lib/rules/
md036_no_emphasis_only_first.rs

1//!
2//! Rule MD036: No emphasis used as a heading
3//!
4//! See [docs/md036.md](../../docs/md036.md) for full documentation, configuration, and examples.
5
6use 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
16// Optimize regex patterns with compilation once at startup
17// Note: The content between emphasis markers should not contain other emphasis markers
18// to avoid matching nested emphasis like _**text**_ or **_text_**
19static 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());
27// Pattern to match common Table of Contents labels that should not be converted to headings
28static TOC_LABEL_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(r"^\s*(?:\*\*|\*|__|_)(?:Table of Contents|Contents|TOC|Index)(?:\*\*|\*|__|_)\s*$").unwrap()
30});
31
32/// Rule MD036: Emphasis used instead of a heading
33#[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        // Validate heading level, defaulting to 2 if invalid
52        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    /// Generate the ATX heading prefix for the configured heading level
65    fn atx_prefix(&self) -> String {
66        // HeadingLevel is already validated to 1-6, no clamping needed
67        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        // Check if the last character is in the punctuation set
80        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        // Check for inline code: `code`
88        // This is simple but effective since we're checking text that's already
89        // been identified as emphasized content
90        if text.contains('`') {
91            return true;
92        }
93
94        // Check for markdown links: [text](url) or [text][ref]
95        // We need both [ and ] for it to be a potential link
96        // and either ( ) for inline links or ][ for reference links
97        if text.contains('[') && text.contains(']') {
98            // Check for inline link pattern [...](...)
99            if text.contains("](") {
100                return true;
101            }
102            // Check for reference link pattern [...][...] or [...][]
103            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        // Fast path for empty lines and lines that don't contain emphasis markers
121        if line.is_empty() || (!line.contains('*') && !line.contains('_')) {
122            return None;
123        }
124
125        // Skip if line is already a heading (but not a heading with emphasis)
126        if HEADING_MARKER.is_match(line) && !HEADING_WITH_EMPHASIS.is_match(line) {
127            return None;
128        }
129
130        // Skip if line is a Table of Contents label (common legitimate use of bold text)
131        if TOC_LABEL_PATTERN.is_match(line) {
132            return None;
133        }
134
135        // Skip if line is in a list, blockquote, code block, or HTML comment
136        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        // Helper closure to check common conditions for all emphasis patterns
150        let check_emphasis = |text: &str, level: usize, pattern: String| -> Option<(usize, String, usize, usize)> {
151            // Check if text ends with punctuation - if so, don't flag it
152            if !self.config.punctuation.is_empty() && self.ends_with_punctuation(text) {
153                return None;
154            }
155            // Skip if text contains links or inline code (matches markdownlint behavior)
156            // In markdownlint, these would be multiple tokens and thus not flagged
157            if self.contains_link_or_code(text) {
158                return None;
159            }
160            // Find position in original line by looking for the emphasis pattern
161            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        // Check for *emphasis* pattern (entire line)
167        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        // Check for _emphasis_ pattern (entire line)
174        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        // Check for **strong** pattern (entire line)
181        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        // Check for __strong__ pattern (entire line)
188        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        // Fast path for empty content or content without emphasis markers
214        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            // Skip obvious non-matches quickly
225            if line.trim().is_empty() || (!line.contains('*') && !line.contains('_')) {
226                continue;
227            }
228
229            // Emphasis-as-heading requires the line to be a standalone paragraph:
230            // - preceded by a blank line (or start of document)
231            // - followed by a blank line (or end of document)
232            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                // Only include fix if auto-fix is enabled in config
243                let fix = if self.config.fix {
244                    let prefix = self.atx_prefix();
245                    // Get the byte range for the full line content
246                    let range = ctx.line_index.line_content_range(i + 1);
247                    // Preserve leading whitespace by not including it in the replacement
248                    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        // Auto-fix is opt-in: only apply if explicitly enabled in config
272        // When disabled, check() returns warnings without fixes, so this is a no-op
273        if !self.config.fix {
274            return Ok(ctx.content.to_string());
275        }
276
277        // Get warnings with their inline fixes
278        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 no warnings with fixes, return original content
283        if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
284            return Ok(ctx.content.to_string());
285        }
286
287        // Collect all fixes and sort by range start (descending) to apply from end to beginning
288        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        // Apply fixes from end to beginning to preserve byte offsets
295        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    /// Check if this rule should be skipped for performance
306    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
307        // Skip if content is empty or has no emphasis markers
308        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        // Emit `fix = true` so the init-generated config matches the runtime
322        // default established by `from_config`. The rule advertises
323        // `FixCapability::FullyFixable`; users who want diagnostic-only
324        // behavior can flip this explicitly.
325        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        // Default to true: MD036 advertises FixCapability::FullyFixable, and
342        // check() already excludes lists, blockquotes, code blocks, headings,
343        // TOC labels, and punctuation-terminated phrases — so any line that
344        // survives those filters is a genuine emphasis-as-heading that the
345        // rule should rewrite. Users who want diagnostic-only behavior can
346        // still set `fix = false` explicitly.
347        let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(true);
348
349        // heading_style currently only supports "atx"
350        let heading_style = HeadingStyle::Atx;
351
352        // HeadingLevel validation is handled by new_with_fix, which defaults to 2 if invalid
353        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        // Emphasis with punctuation should NOT be flagged (matches markdownlint)
443        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        // Should not flag emphasis within a line
454        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        // Should not flag emphasis in list items
465        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        // Should not flag emphasis in blockquotes
476        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        // Should not flag emphasis in code blocks
487        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        // Should not flag emphasis in HTML comments (matches markdownlint)
498        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        // Should not flag common TOC labels
513        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        // Should not flag emphasis that's already in a heading
524        assert_eq!(result.len(), 0);
525    }
526
527    #[test]
528    fn test_fix_disabled_by_default() {
529        // When fix is not enabled (default), no changes should be made
530        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        // Fix is opt-in, so by default no changes are made
536        assert_eq!(fixed, content);
537    }
538
539    #[test]
540    fn test_fix_disabled_preserves_content() {
541        // When fix is not enabled, content is preserved
542        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        // Fix is opt-in, so by default no changes are made
548        assert_eq!(fixed, content);
549    }
550
551    #[test]
552    fn test_fix_enabled_single_asterisk() {
553        // When fix is enabled, single asterisk emphasis is converted
554        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        // When fix is enabled, double asterisk emphasis is converted
565        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        // When fix is enabled, single underscore emphasis is converted
576        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        // When fix is enabled, double underscore emphasis is converted
587        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        // When fix is enabled, multiple emphasis-as-heading lines are converted
598        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        // When fix is enabled, lines ending with punctuation are skipped
612        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        // Should not be changed because it ends with punctuation (colon)
618        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        // A second fix run should produce no further changes
654        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        // Run fix again on the fixed content
661        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        // List items should not be converted
674        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        // Blockquotes should not be converted
685        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        // Code blocks should not be converted
696        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        // With empty punctuation config, all emphasis is flagged
707        assert_eq!(result.len(), 1);
708
709        let fixed = rule.fix(&ctx).unwrap();
710        // Fix is opt-in, so by default no changes are made
711        assert_eq!(fixed, content);
712    }
713
714    #[test]
715    fn test_empty_punctuation_config_with_fix() {
716        // With fix enabled and empty punctuation, all emphasis is converted
717        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        // With empty punctuation and fix enabled, all emphasis is converted
723        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        // Nested emphasis (3 asterisks) should not match our patterns
757        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        // Multi-line emphasis should not be flagged
768        assert_eq!(result.len(), 0);
769    }
770
771    #[test]
772    fn test_fix_preserves_trailing_newline_disabled() {
773        // When fix is disabled, trailing newline is preserved
774        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        // Fix is opt-in, so by default no changes are made
780        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        // `fix = true` matches the runtime default in `from_config`, so the
792        // generated init config is consistent with no-config behavior.
793        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        // Test the specific issue from #23 - bold text used as image caption
801        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
802        let content = "#### Métriques\n\n**commits par année : rumdl**\n\n![rumdl Commits By Year image](commits_by_year.png \"commits par année : rumdl\")";
803        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804        let result = rule.check(&ctx).unwrap();
805
806        // Should detect the bold text even though it's followed by an image
807        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        // Warnings don't include inline fixes (fix is opt-in via config)
812        assert!(result[0].fix.is_none());
813
814        // Fix is opt-in, so by default the content is unchanged
815        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        // Test that with empty punctuation config, even text ending with colon is flagged
822        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        // With empty punctuation config, this should be flagged
828        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        // Test that with default punctuation config, text ending with colon is NOT flagged
835        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        // With default punctuation including colon, this should NOT be flagged
841        assert_eq!(result.len(), 0);
842    }
843}