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, HTML comment, or an
136        // indentation-scoped MkDocs container (admonition or content tab), where
137        // an emphasis-only line is container content rather than a standalone
138        // paragraph. markdown="1" HTML divs are deliberately not skipped: they
139        // are tag-scoped and detected in every flavor.
140        if LIST_MARKER.is_match(line)
141            || BLOCKQUOTE_MARKER.is_match(line)
142            || ctx.line_info(line_num + 1).is_some_and(|info| {
143                info.in_code_block
144                    || info.in_html_comment
145                    || info.in_mdx_comment
146                    || info.in_pymdown_block
147                    || info.in_mkdocstrings
148                    || info.in_admonition
149                    || info.in_content_tab
150            })
151        {
152            return None;
153        }
154
155        // Helper closure to check common conditions for all emphasis patterns
156        let check_emphasis = |text: &str, level: usize, pattern: String| -> Option<(usize, String, usize, usize)> {
157            // Check if text ends with punctuation - if so, don't flag it
158            if !self.config.punctuation.is_empty() && self.ends_with_punctuation(text) {
159                return None;
160            }
161            // Skip if text contains links or inline code (matches markdownlint behavior)
162            // In markdownlint, these would be multiple tokens and thus not flagged
163            if self.contains_link_or_code(text) {
164                return None;
165            }
166            // Find position in original line by looking for the emphasis pattern
167            let start_pos = original_line.find(&pattern).unwrap_or(0);
168            let end_pos = start_pos + pattern.len();
169            Some((level, text.to_string(), start_pos, end_pos))
170        };
171
172        // Check for *emphasis* pattern (entire line)
173        if let Some(caps) = RE_ASTERISK_SINGLE.captures(line) {
174            let text = caps.get(1).unwrap().as_str();
175            let pattern = format!("*{text}*");
176            return check_emphasis(text, 1, pattern);
177        }
178
179        // Check for _emphasis_ pattern (entire line)
180        if let Some(caps) = RE_UNDERSCORE_SINGLE.captures(line) {
181            let text = caps.get(1).unwrap().as_str();
182            let pattern = format!("_{text}_");
183            return check_emphasis(text, 1, pattern);
184        }
185
186        // Check for **strong** pattern (entire line)
187        if let Some(caps) = RE_ASTERISK_DOUBLE.captures(line) {
188            let text = caps.get(1).unwrap().as_str();
189            let pattern = format!("**{text}**");
190            return check_emphasis(text, 2, pattern);
191        }
192
193        // Check for __strong__ pattern (entire line)
194        if let Some(caps) = RE_UNDERSCORE_DOUBLE.captures(line) {
195            let text = caps.get(1).unwrap().as_str();
196            let pattern = format!("__{text}__");
197            return check_emphasis(text, 2, pattern);
198        }
199
200        None
201    }
202}
203
204impl Rule for MD036NoEmphasisAsHeading {
205    fn name(&self) -> &'static str {
206        "MD036"
207    }
208
209    fn description(&self) -> &'static str {
210        "Emphasis should not be used instead of a heading"
211    }
212
213    fn category(&self) -> RuleCategory {
214        RuleCategory::Emphasis
215    }
216
217    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
218        let content = ctx.content;
219        // Fast path for empty content or content without emphasis markers
220        if content.is_empty() || (!content.contains('*') && !content.contains('_')) {
221            return Ok(Vec::new());
222        }
223
224        let mut warnings = Vec::new();
225
226        let lines: Vec<&str> = content.lines().collect();
227        let line_count = lines.len();
228
229        for (i, line) in lines.iter().enumerate() {
230            // Skip obvious non-matches quickly
231            if line.trim().is_empty() || (!line.contains('*') && !line.contains('_')) {
232                continue;
233            }
234
235            // Emphasis-as-heading requires the line to be a standalone paragraph:
236            // - preceded by a blank line (or start of document)
237            // - followed by a blank line (or end of document)
238            let prev_blank = i == 0 || lines[i - 1].trim().is_empty();
239            let next_blank = i + 1 >= line_count || lines[i + 1].trim().is_empty();
240            if !prev_blank || !next_blank {
241                continue;
242            }
243
244            if let Some((_level, text, start_pos, end_pos)) = self.is_entire_line_emphasized(line, ctx, i) {
245                let (start_line, start_col, end_line, end_col) =
246                    calculate_emphasis_range(i + 1, line, start_pos, end_pos);
247
248                // Only include fix if auto-fix is enabled in config
249                let fix = if self.config.fix {
250                    let prefix = self.atx_prefix();
251                    // Get the byte range for the full line content
252                    let range = ctx.line_content_byte_range(i + 1);
253                    // Preserve leading whitespace by not including it in the replacement
254                    let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
255                    Some(Fix::new(range, format!("{leading_ws}{prefix}{text}")))
256                } else {
257                    None
258                };
259
260                warnings.push(LintWarning {
261                    rule_name: Some(self.name().to_string()),
262                    line: start_line,
263                    column: start_col,
264                    end_line,
265                    end_column: end_col,
266                    message: format!("Emphasis used instead of a heading: '{text}'"),
267                    severity: Severity::Warning,
268                    fix,
269                });
270            }
271        }
272
273        Ok(warnings)
274    }
275
276    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
277        // Auto-fix is opt-in: only apply if explicitly enabled in config
278        // When disabled, check() returns warnings without fixes, so this is a no-op
279        if !self.config.fix {
280            return Ok(ctx.content.to_string());
281        }
282
283        // Get warnings with their inline fixes
284        let warnings = self.check(ctx)?;
285        let warnings =
286            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
287
288        // If no warnings with fixes, return original content
289        if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
290            return Ok(ctx.content.to_string());
291        }
292
293        // Collect all fixes and sort by range start (descending) to apply from end to beginning
294        let mut fixes: Vec<_> = warnings
295            .iter()
296            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
297            .collect();
298        fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
299
300        // Apply fixes from end to beginning to preserve byte offsets
301        let mut result = ctx.content.to_string();
302        for (start, end, replacement) in fixes {
303            if start < result.len() && end <= result.len() && start <= end {
304                result.replace_range(start..end, replacement);
305            }
306        }
307
308        Ok(result)
309    }
310
311    /// Check if this rule should be skipped for performance
312    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
313        // Skip if content is empty or has no emphasis markers
314        ctx.content.is_empty() || !ctx.likely_has_emphasis()
315    }
316
317    fn as_any(&self) -> &dyn std::any::Any {
318        self
319    }
320
321    crate::impl_rule_config_sections!(MD036Config);
322
323    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
324    where
325        Self: Sized,
326    {
327        let punctuation = crate::config::get_rule_config_value::<String>(config, "MD036", "punctuation")
328            .unwrap_or_else(|| ".,;:!?".to_string());
329
330        // Default to false: converting emphasis to a heading is a meaning
331        // change the linter cannot verify. A standalone emphasized line is
332        // textually indistinguishable whether it is a bold name, filename,
333        // label, version note, or a phrase genuinely meant as a heading, so
334        // auto-converting by default silently rewrites documents (and injects
335        // phantom entries into the heading structure). check() still warns;
336        // users who want the rewrite opt in with `fix = true`.
337        let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(false);
338
339        // heading_style currently only supports "atx"
340        let heading_style = HeadingStyle::Atx;
341
342        // HeadingLevel validation is handled by new_with_fix, which defaults to 2 if invalid
343        let heading_level = crate::config::get_rule_config_value::<u8>(config, "MD036", "heading-level")
344            .or_else(|| crate::config::get_rule_config_value::<u8>(config, "MD036", "heading_level"))
345            .unwrap_or(2);
346
347        Box::new(MD036NoEmphasisAsHeading::new_with_fix(
348            punctuation,
349            fix,
350            heading_style,
351            heading_level,
352        ))
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::lint_context::LintContext;
360
361    #[test]
362    fn test_single_asterisk_emphasis() {
363        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
364        let content = "*This is emphasized*\n\nRegular text";
365        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
366        let result = rule.check(&ctx).unwrap();
367
368        assert_eq!(result.len(), 1);
369        assert_eq!(result[0].line, 1);
370        assert!(
371            result[0]
372                .message
373                .contains("Emphasis used instead of a heading: 'This is emphasized'")
374        );
375    }
376
377    #[test]
378    fn test_single_underscore_emphasis() {
379        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
380        let content = "_This is emphasized_\n\nRegular text";
381        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
382        let result = rule.check(&ctx).unwrap();
383
384        assert_eq!(result.len(), 1);
385        assert_eq!(result[0].line, 1);
386        assert!(
387            result[0]
388                .message
389                .contains("Emphasis used instead of a heading: 'This is emphasized'")
390        );
391    }
392
393    #[test]
394    fn test_double_asterisk_strong() {
395        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
396        let content = "**This is strong**\n\nRegular text";
397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398        let result = rule.check(&ctx).unwrap();
399
400        assert_eq!(result.len(), 1);
401        assert_eq!(result[0].line, 1);
402        assert!(
403            result[0]
404                .message
405                .contains("Emphasis used instead of a heading: 'This is strong'")
406        );
407    }
408
409    #[test]
410    fn test_double_underscore_strong() {
411        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
412        let content = "__This is strong__\n\nRegular text";
413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414        let result = rule.check(&ctx).unwrap();
415
416        assert_eq!(result.len(), 1);
417        assert_eq!(result[0].line, 1);
418        assert!(
419            result[0]
420                .message
421                .contains("Emphasis used instead of a heading: 'This is strong'")
422        );
423    }
424
425    #[test]
426    fn test_emphasis_with_punctuation() {
427        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
428        let content = "**Important Note:**\n\nRegular text";
429        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430        let result = rule.check(&ctx).unwrap();
431
432        // Emphasis with punctuation should NOT be flagged (matches markdownlint)
433        assert_eq!(result.len(), 0);
434    }
435
436    #[test]
437    fn test_emphasis_in_paragraph() {
438        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
439        let content = "This is a paragraph with *emphasis* in the middle.";
440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441        let result = rule.check(&ctx).unwrap();
442
443        // Should not flag emphasis within a line
444        assert_eq!(result.len(), 0);
445    }
446
447    #[test]
448    fn test_emphasis_in_list() {
449        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
450        let content = "- *List item with emphasis*\n- Another item";
451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452        let result = rule.check(&ctx).unwrap();
453
454        // Should not flag emphasis in list items
455        assert_eq!(result.len(), 0);
456    }
457
458    #[test]
459    fn test_emphasis_in_blockquote() {
460        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
461        let content = "> *Quote with emphasis*\n> Another line";
462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463        let result = rule.check(&ctx).unwrap();
464
465        // Should not flag emphasis in blockquotes
466        assert_eq!(result.len(), 0);
467    }
468
469    #[test]
470    fn test_emphasis_in_code_block() {
471        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
472        let content = "```\n*Not emphasis in code*\n```";
473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474        let result = rule.check(&ctx).unwrap();
475
476        // Should not flag emphasis in code blocks
477        assert_eq!(result.len(), 0);
478    }
479
480    #[test]
481    fn test_emphasis_in_html_comment() {
482        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
483        let content = "<!--\n**bigger**\ncomment\n-->";
484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485        let result = rule.check(&ctx).unwrap();
486
487        // Should not flag emphasis in HTML comments (matches markdownlint)
488        assert_eq!(
489            result.len(),
490            0,
491            "Expected no warnings for emphasis in HTML comment, got: {result:?}"
492        );
493    }
494
495    #[test]
496    fn test_toc_label() {
497        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
498        let content = "**Table of Contents**\n\n- Item 1\n- Item 2";
499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500        let result = rule.check(&ctx).unwrap();
501
502        // Should not flag common TOC labels
503        assert_eq!(result.len(), 0);
504    }
505
506    #[test]
507    fn test_already_heading() {
508        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
509        let content = "# **Bold in heading**\n\nRegular text";
510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511        let result = rule.check(&ctx).unwrap();
512
513        // Should not flag emphasis that's already in a heading
514        assert_eq!(result.len(), 0);
515    }
516
517    #[test]
518    fn test_fix_disabled_by_default() {
519        // When fix is not enabled (default), no changes should be made
520        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
521        let content = "*Convert to heading*\n\nRegular text";
522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523        let fixed = rule.fix(&ctx).unwrap();
524
525        // Fix is opt-in, so by default no changes are made
526        assert_eq!(fixed, content);
527    }
528
529    #[test]
530    fn test_fix_disabled_preserves_content() {
531        // When fix is not enabled, content is preserved
532        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
533        let content = "**Convert to heading**\n\nRegular text";
534        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535        let fixed = rule.fix(&ctx).unwrap();
536
537        // Fix is opt-in, so by default no changes are made
538        assert_eq!(fixed, content);
539    }
540
541    #[test]
542    fn test_fix_enabled_single_asterisk() {
543        // When fix is enabled, single asterisk emphasis is converted
544        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
545        let content = "*Section Title*\n\nBody text.";
546        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547        let fixed = rule.fix(&ctx).unwrap();
548
549        assert_eq!(fixed, "## Section Title\n\nBody text.");
550    }
551
552    #[test]
553    fn test_fix_enabled_double_asterisk() {
554        // When fix is enabled, double asterisk emphasis is converted
555        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
556        let content = "**Section Title**\n\nBody text.";
557        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558        let fixed = rule.fix(&ctx).unwrap();
559
560        assert_eq!(fixed, "## Section Title\n\nBody text.");
561    }
562
563    #[test]
564    fn test_fix_enabled_single_underscore() {
565        // When fix is enabled, single underscore emphasis is converted
566        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 3);
567        let content = "_Section Title_\n\nBody text.";
568        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569        let fixed = rule.fix(&ctx).unwrap();
570
571        assert_eq!(fixed, "### Section Title\n\nBody text.");
572    }
573
574    #[test]
575    fn test_fix_enabled_double_underscore() {
576        // When fix is enabled, double underscore emphasis is converted
577        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 4);
578        let content = "__Section Title__\n\nBody text.";
579        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580        let fixed = rule.fix(&ctx).unwrap();
581
582        assert_eq!(fixed, "#### Section Title\n\nBody text.");
583    }
584
585    #[test]
586    fn test_fix_enabled_multiple_lines() {
587        // When fix is enabled, multiple emphasis-as-heading lines are converted
588        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
589        let content = "**First Section**\n\nSome text.\n\n**Second Section**\n\nMore text.";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591        let fixed = rule.fix(&ctx).unwrap();
592
593        assert_eq!(
594            fixed,
595            "## First Section\n\nSome text.\n\n## Second Section\n\nMore text."
596        );
597    }
598
599    #[test]
600    fn test_fix_enabled_skips_punctuation() {
601        // When fix is enabled, lines ending with punctuation are skipped
602        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
603        let content = "**Important Note:**\n\nBody text.";
604        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605        let fixed = rule.fix(&ctx).unwrap();
606
607        // Should not be changed because it ends with punctuation (colon)
608        assert_eq!(fixed, content);
609    }
610
611    #[test]
612    fn test_fix_enabled_heading_level_1() {
613        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 1);
614        let content = "**Title**";
615        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616        let fixed = rule.fix(&ctx).unwrap();
617
618        assert_eq!(fixed, "# Title");
619    }
620
621    #[test]
622    fn test_fix_enabled_heading_level_6() {
623        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 6);
624        let content = "**Subsubsubheading**";
625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626        let fixed = rule.fix(&ctx).unwrap();
627
628        assert_eq!(fixed, "###### Subsubsubheading");
629    }
630
631    #[test]
632    fn test_fix_preserves_trailing_newline_enabled() {
633        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
634        let content = "**Heading**\n";
635        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636        let fixed = rule.fix(&ctx).unwrap();
637
638        assert_eq!(fixed, "## Heading\n");
639    }
640
641    #[test]
642    fn test_fix_idempotent() {
643        // A second fix run should produce no further changes
644        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
645        let content = "**Section Title**\n\nBody text.";
646        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647        let fixed1 = rule.fix(&ctx).unwrap();
648        assert_eq!(fixed1, "## Section Title\n\nBody text.");
649
650        // Run fix again on the fixed content
651        let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
652        let fixed2 = rule.fix(&ctx2).unwrap();
653        assert_eq!(fixed2, fixed1, "Fix should be idempotent");
654    }
655
656    #[test]
657    fn test_fix_skips_lists() {
658        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
659        let content = "- *List item*\n- Another item";
660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661        let fixed = rule.fix(&ctx).unwrap();
662
663        // List items should not be converted
664        assert_eq!(fixed, content);
665    }
666
667    #[test]
668    fn test_fix_skips_blockquotes() {
669        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
670        let content = "> **Quoted text**\n> More quote";
671        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672        let fixed = rule.fix(&ctx).unwrap();
673
674        // Blockquotes should not be converted
675        assert_eq!(fixed, content);
676    }
677
678    #[test]
679    fn test_fix_skips_code_blocks() {
680        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
681        let content = "```\n**Not a heading**\n```";
682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683        let fixed = rule.fix(&ctx).unwrap();
684
685        // Code blocks should not be converted
686        assert_eq!(fixed, content);
687    }
688
689    #[test]
690    fn test_empty_punctuation_config() {
691        let rule = MD036NoEmphasisAsHeading::new("".to_string());
692        let content = "**Important Note:**\n\nRegular text";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let result = rule.check(&ctx).unwrap();
695
696        // With empty punctuation config, all emphasis is flagged
697        assert_eq!(result.len(), 1);
698
699        let fixed = rule.fix(&ctx).unwrap();
700        // Fix is opt-in, so by default no changes are made
701        assert_eq!(fixed, content);
702    }
703
704    #[test]
705    fn test_empty_punctuation_config_with_fix() {
706        // With fix enabled and empty punctuation, all emphasis is converted
707        let rule = MD036NoEmphasisAsHeading::new_with_fix("".to_string(), true, HeadingStyle::Atx, 2);
708        let content = "**Important Note:**\n\nRegular text";
709        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710        let fixed = rule.fix(&ctx).unwrap();
711
712        // With empty punctuation and fix enabled, all emphasis is converted
713        assert_eq!(fixed, "## Important Note:\n\nRegular text");
714    }
715
716    #[test]
717    fn test_multiple_emphasized_lines() {
718        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
719        let content = "*First heading*\n\nSome text\n\n**Second heading**\n\nMore text";
720        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721        let result = rule.check(&ctx).unwrap();
722
723        assert_eq!(result.len(), 2);
724        assert_eq!(result[0].line, 1);
725        assert_eq!(result[1].line, 5);
726    }
727
728    #[test]
729    fn test_whitespace_handling() {
730        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
731        let content = "  **Indented emphasis**  \n\nRegular text";
732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733        let result = rule.check(&ctx).unwrap();
734
735        assert_eq!(result.len(), 1);
736        assert_eq!(result[0].line, 1);
737    }
738
739    #[test]
740    fn test_nested_emphasis() {
741        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
742        let content = "***Not a simple emphasis***\n\nRegular text";
743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744        let result = rule.check(&ctx).unwrap();
745
746        // Nested emphasis (3 asterisks) should not match our patterns
747        assert_eq!(result.len(), 0);
748    }
749
750    #[test]
751    fn test_emphasis_with_newlines() {
752        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
753        let content = "*First line\nSecond line*\n\nRegular text";
754        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755        let result = rule.check(&ctx).unwrap();
756
757        // Multi-line emphasis should not be flagged
758        assert_eq!(result.len(), 0);
759    }
760
761    #[test]
762    fn test_fix_preserves_trailing_newline_disabled() {
763        // When fix is disabled, trailing newline is preserved
764        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
765        let content = "*Convert to heading*\n";
766        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767        let fixed = rule.fix(&ctx).unwrap();
768
769        // Fix is opt-in, so by default no changes are made
770        assert_eq!(fixed, content);
771    }
772
773    #[test]
774    fn test_default_config() {
775        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
776        let (name, config) = rule.default_config_section().unwrap();
777        assert_eq!(name, "MD036");
778
779        let table = config.as_table().unwrap();
780        assert_eq!(table.get("punctuation").unwrap().as_str().unwrap(), ".,;:!?");
781        // `fix = false` matches the runtime default in `from_config`, so the
782        // generated init config is consistent with no-config behavior.
783        assert!(!table.get("fix").unwrap().as_bool().unwrap());
784        assert_eq!(table.get("heading-style").unwrap().as_str().unwrap(), "atx");
785        assert_eq!(table.get("heading-level").unwrap().as_integer().unwrap(), 2);
786    }
787
788    #[test]
789    fn test_default_warns_but_does_not_autoconvert() {
790        // Through the production config path, MD036's autofix is opt-in: `check`
791        // still warns (a useful nudge, matching markdownlint's detect-only
792        // behavior), but `fmt` must not convert emphasis to a heading. Bold
793        // names, filenames, and notes are textually indistinguishable from a
794        // genuine heading, so auto-conversion silently corrupts documents.
795        let rule = MD036NoEmphasisAsHeading::from_config(&crate::config::Config::default());
796        let content = "**Michael Rose**\n\nProfile text.";
797        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798
799        let warnings = rule.check(&ctx).unwrap();
800        assert_eq!(warnings.len(), 1, "detection should still fire by default");
801        assert!(warnings[0].fix.is_none(), "default warnings must not carry a fix");
802
803        let fixed = rule.fix(&ctx).unwrap();
804        assert_eq!(
805            fixed, content,
806            "default fmt must not auto-convert emphasis to a heading"
807        );
808    }
809
810    #[test]
811    fn test_default_preserves_real_world_emphasis() {
812        // Regression: emphasis that is plainly not a heading must survive default
813        // `rumdl fmt` untouched. These cases were found by formatting the clap and
814        // minimal-mistakes corpora, where the old default rewrote a prose note, a
815        // bold filename, and a bold version note into H2 headings.
816        let rule = MD036NoEmphasisAsHeading::from_config(&crate::config::Config::default());
817        for content in [
818            "Intro.\n\n*Note: without the links setup, we can't demonstrate the behavior*\n\nMore.",
819            "**index.md**\n\nA configuration file.",
820            "*New in v4.26.0*\n\nA new feature.",
821        ] {
822            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823            assert_eq!(
824                rule.fix(&ctx).unwrap(),
825                content,
826                "default fmt must preserve: {content:?}"
827            );
828        }
829    }
830
831    #[test]
832    fn test_image_caption_scenario() {
833        // Test the specific issue from #23 - bold text used as image caption
834        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
835        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\")";
836        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837        let result = rule.check(&ctx).unwrap();
838
839        // Should detect the bold text even though it's followed by an image
840        assert_eq!(result.len(), 1);
841        assert_eq!(result[0].line, 3);
842        assert!(result[0].message.contains("commits par année : rumdl"));
843
844        // Warnings don't include inline fixes (fix is opt-in via config)
845        assert!(result[0].fix.is_none());
846
847        // Fix is opt-in, so by default the content is unchanged
848        let fixed = rule.fix(&ctx).unwrap();
849        assert_eq!(fixed, content);
850    }
851
852    #[test]
853    fn test_bold_with_colon_no_punctuation_config() {
854        // Test that with empty punctuation config, even text ending with colon is flagged
855        let rule = MD036NoEmphasisAsHeading::new("".to_string());
856        let content = "**commits par année : rumdl**\n\nSome text";
857        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858        let result = rule.check(&ctx).unwrap();
859
860        // With empty punctuation config, this should be flagged
861        assert_eq!(result.len(), 1);
862        assert!(result[0].fix.is_none());
863    }
864
865    #[test]
866    fn test_bold_with_colon_default_config() {
867        // Test that with default punctuation config, text ending with colon is NOT flagged
868        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
869        let content = "**Important Note:**\n\nSome text";
870        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
871        let result = rule.check(&ctx).unwrap();
872
873        // With default punctuation including colon, this should NOT be flagged
874        assert_eq!(result.len(), 0);
875    }
876
877    #[test]
878    fn test_mkdocs_admonition_body_not_flagged() {
879        // An emphasis-only line indented inside a MkDocs
880        // admonition body is container content, not a standalone paragraph,
881        // so it must not be flagged as emphasis-as-heading.
882        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
883        let content = "!!! note\n\n    _Foo_";
884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
885        let result = rule.check(&ctx).unwrap();
886
887        assert_eq!(
888            result.len(),
889            0,
890            "emphasis inside an admonition body should not be flagged, got: {result:?}"
891        );
892    }
893
894    #[test]
895    fn test_mkdocs_content_tab_body_not_flagged() {
896        // The same false positive occurs inside a MkDocs content tab body.
897        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
898        let content = "=== \"Tab A\"\n\n    _Foo_";
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
900        let result = rule.check(&ctx).unwrap();
901
902        assert_eq!(
903            result.len(),
904            0,
905            "emphasis inside a content tab body should not be flagged, got: {result:?}"
906        );
907    }
908
909    #[test]
910    fn test_mkdocs_top_level_emphasis_still_flagged() {
911        // Control: a top-level emphasis-only line outside any admonition/tab
912        // must still be flagged under MkDocs flavor.
913        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
914        let content = "_Foo_\n\nRegular text";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
916        let result = rule.check(&ctx).unwrap();
917
918        assert_eq!(
919            result.len(),
920            1,
921            "top-level emphasis should still be flagged under mkdocs flavor, got: {result:?}"
922        );
923    }
924
925    #[test]
926    fn test_standard_flavor_indented_emphasis_unchanged() {
927        // Control: without mkdocs flavor, a 4-space indented emphasis-only line
928        // is CommonMark indented code and was already not flagged. Confirms the
929        // new guard doesn't change standard-flavor behavior.
930        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
931        let content = "Intro\n\n    _Foo_\n\nMore text";
932        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933        let result = rule.check(&ctx).unwrap();
934
935        assert_eq!(
936            result.len(),
937            0,
938            "indented emphasis is indented code under standard flavor, got: {result:?}"
939        );
940    }
941
942    #[test]
943    fn test_mkdocs_cascade_fix_does_not_corrupt_admonition() {
944        // With MD036 autofix enabled, the admonition-nested
945        // emphasis must not be converted to a heading (which MD023 would then
946        // de-indent out of the admonition in the same --fix pass).
947        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
948        let content = "!!! note\n\n    _Foo_";
949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
950        let fixed = rule.fix(&ctx).unwrap();
951
952        assert_eq!(
953            fixed, content,
954            "admonition-nested emphasis must not be converted to a heading"
955        );
956    }
957
958    #[test]
959    fn test_html_markdown_div_emphasis_still_flagged() {
960        // A markdown="1" HTML div is tag-scoped, not indentation-scoped, so
961        // emphasis-as-heading detection keeps applying inside it in every
962        // flavor.
963        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
964        let content = "<div markdown=\"1\">\n\n_Foo_\n\n</div>";
965        for flavor in [
966            crate::config::MarkdownFlavor::Standard,
967            crate::config::MarkdownFlavor::MkDocs,
968        ] {
969            let ctx = LintContext::new(content, flavor, None);
970            let result = rule.check(&ctx).unwrap();
971            assert_eq!(
972                result.len(),
973                1,
974                "emphasis inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
975            );
976        }
977    }
978}