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_index.line_content_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    fn default_config_section(&self) -> Option<(String, toml::Value)> {
322        let mut map = toml::map::Map::new();
323        map.insert(
324            "punctuation".to_string(),
325            toml::Value::String(self.config.punctuation.clone()),
326        );
327        // Emit `fix = false` so the init-generated config matches the runtime
328        // default established by `from_config`. Auto-conversion is opt-in
329        // because it changes document meaning; users enable it explicitly.
330        map.insert("fix".to_string(), toml::Value::Boolean(false));
331        map.insert("heading-style".to_string(), toml::Value::String("atx".to_string()));
332        map.insert(
333            "heading-level".to_string(),
334            toml::Value::Integer(i64::from(self.config.heading_level.get())),
335        );
336        Some((self.name().to_string(), toml::Value::Table(map)))
337    }
338
339    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
340    where
341        Self: Sized,
342    {
343        let punctuation = crate::config::get_rule_config_value::<String>(config, "MD036", "punctuation")
344            .unwrap_or_else(|| ".,;:!?".to_string());
345
346        // Default to false: converting emphasis to a heading is a meaning
347        // change the linter cannot verify. A standalone emphasized line is
348        // textually indistinguishable whether it is a bold name, filename,
349        // label, version note, or a phrase genuinely meant as a heading, so
350        // auto-converting by default silently rewrites documents (and injects
351        // phantom entries into the heading structure). check() still warns;
352        // users who want the rewrite opt in with `fix = true`.
353        let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(false);
354
355        // heading_style currently only supports "atx"
356        let heading_style = HeadingStyle::Atx;
357
358        // HeadingLevel validation is handled by new_with_fix, which defaults to 2 if invalid
359        let heading_level = crate::config::get_rule_config_value::<u8>(config, "MD036", "heading-level")
360            .or_else(|| crate::config::get_rule_config_value::<u8>(config, "MD036", "heading_level"))
361            .unwrap_or(2);
362
363        Box::new(MD036NoEmphasisAsHeading::new_with_fix(
364            punctuation,
365            fix,
366            heading_style,
367            heading_level,
368        ))
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::lint_context::LintContext;
376
377    #[test]
378    fn test_single_asterisk_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_single_underscore_emphasis() {
395        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
396        let content = "_This is emphasized_\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 emphasized'")
406        );
407    }
408
409    #[test]
410    fn test_double_asterisk_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_double_underscore_strong() {
427        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
428        let content = "__This is strong__\n\nRegular text";
429        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430        let result = rule.check(&ctx).unwrap();
431
432        assert_eq!(result.len(), 1);
433        assert_eq!(result[0].line, 1);
434        assert!(
435            result[0]
436                .message
437                .contains("Emphasis used instead of a heading: 'This is strong'")
438        );
439    }
440
441    #[test]
442    fn test_emphasis_with_punctuation() {
443        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
444        let content = "**Important Note:**\n\nRegular text";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446        let result = rule.check(&ctx).unwrap();
447
448        // Emphasis with punctuation should NOT be flagged (matches markdownlint)
449        assert_eq!(result.len(), 0);
450    }
451
452    #[test]
453    fn test_emphasis_in_paragraph() {
454        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
455        let content = "This is a paragraph with *emphasis* in the middle.";
456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457        let result = rule.check(&ctx).unwrap();
458
459        // Should not flag emphasis within a line
460        assert_eq!(result.len(), 0);
461    }
462
463    #[test]
464    fn test_emphasis_in_list() {
465        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
466        let content = "- *List item with emphasis*\n- Another item";
467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
468        let result = rule.check(&ctx).unwrap();
469
470        // Should not flag emphasis in list items
471        assert_eq!(result.len(), 0);
472    }
473
474    #[test]
475    fn test_emphasis_in_blockquote() {
476        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
477        let content = "> *Quote with emphasis*\n> Another line";
478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let result = rule.check(&ctx).unwrap();
480
481        // Should not flag emphasis in blockquotes
482        assert_eq!(result.len(), 0);
483    }
484
485    #[test]
486    fn test_emphasis_in_code_block() {
487        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
488        let content = "```\n*Not emphasis in code*\n```";
489        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490        let result = rule.check(&ctx).unwrap();
491
492        // Should not flag emphasis in code blocks
493        assert_eq!(result.len(), 0);
494    }
495
496    #[test]
497    fn test_emphasis_in_html_comment() {
498        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
499        let content = "<!--\n**bigger**\ncomment\n-->";
500        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
501        let result = rule.check(&ctx).unwrap();
502
503        // Should not flag emphasis in HTML comments (matches markdownlint)
504        assert_eq!(
505            result.len(),
506            0,
507            "Expected no warnings for emphasis in HTML comment, got: {result:?}"
508        );
509    }
510
511    #[test]
512    fn test_toc_label() {
513        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
514        let content = "**Table of Contents**\n\n- Item 1\n- Item 2";
515        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516        let result = rule.check(&ctx).unwrap();
517
518        // Should not flag common TOC labels
519        assert_eq!(result.len(), 0);
520    }
521
522    #[test]
523    fn test_already_heading() {
524        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
525        let content = "# **Bold in heading**\n\nRegular text";
526        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527        let result = rule.check(&ctx).unwrap();
528
529        // Should not flag emphasis that's already in a heading
530        assert_eq!(result.len(), 0);
531    }
532
533    #[test]
534    fn test_fix_disabled_by_default() {
535        // When fix is not enabled (default), no changes should be made
536        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
537        let content = "*Convert to heading*\n\nRegular text";
538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539        let fixed = rule.fix(&ctx).unwrap();
540
541        // Fix is opt-in, so by default no changes are made
542        assert_eq!(fixed, content);
543    }
544
545    #[test]
546    fn test_fix_disabled_preserves_content() {
547        // When fix is not enabled, content is preserved
548        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
549        let content = "**Convert to heading**\n\nRegular text";
550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551        let fixed = rule.fix(&ctx).unwrap();
552
553        // Fix is opt-in, so by default no changes are made
554        assert_eq!(fixed, content);
555    }
556
557    #[test]
558    fn test_fix_enabled_single_asterisk() {
559        // When fix is enabled, single asterisk emphasis is converted
560        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
561        let content = "*Section Title*\n\nBody text.";
562        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563        let fixed = rule.fix(&ctx).unwrap();
564
565        assert_eq!(fixed, "## Section Title\n\nBody text.");
566    }
567
568    #[test]
569    fn test_fix_enabled_double_asterisk() {
570        // When fix is enabled, double asterisk emphasis is converted
571        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
572        let content = "**Section Title**\n\nBody text.";
573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574        let fixed = rule.fix(&ctx).unwrap();
575
576        assert_eq!(fixed, "## Section Title\n\nBody text.");
577    }
578
579    #[test]
580    fn test_fix_enabled_single_underscore() {
581        // When fix is enabled, single underscore emphasis is converted
582        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 3);
583        let content = "_Section Title_\n\nBody text.";
584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585        let fixed = rule.fix(&ctx).unwrap();
586
587        assert_eq!(fixed, "### Section Title\n\nBody text.");
588    }
589
590    #[test]
591    fn test_fix_enabled_double_underscore() {
592        // When fix is enabled, double underscore emphasis is converted
593        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 4);
594        let content = "__Section Title__\n\nBody text.";
595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596        let fixed = rule.fix(&ctx).unwrap();
597
598        assert_eq!(fixed, "#### Section Title\n\nBody text.");
599    }
600
601    #[test]
602    fn test_fix_enabled_multiple_lines() {
603        // When fix is enabled, multiple emphasis-as-heading lines are converted
604        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
605        let content = "**First Section**\n\nSome text.\n\n**Second Section**\n\nMore text.";
606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607        let fixed = rule.fix(&ctx).unwrap();
608
609        assert_eq!(
610            fixed,
611            "## First Section\n\nSome text.\n\n## Second Section\n\nMore text."
612        );
613    }
614
615    #[test]
616    fn test_fix_enabled_skips_punctuation() {
617        // When fix is enabled, lines ending with punctuation are skipped
618        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
619        let content = "**Important Note:**\n\nBody text.";
620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621        let fixed = rule.fix(&ctx).unwrap();
622
623        // Should not be changed because it ends with punctuation (colon)
624        assert_eq!(fixed, content);
625    }
626
627    #[test]
628    fn test_fix_enabled_heading_level_1() {
629        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 1);
630        let content = "**Title**";
631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632        let fixed = rule.fix(&ctx).unwrap();
633
634        assert_eq!(fixed, "# Title");
635    }
636
637    #[test]
638    fn test_fix_enabled_heading_level_6() {
639        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 6);
640        let content = "**Subsubsubheading**";
641        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642        let fixed = rule.fix(&ctx).unwrap();
643
644        assert_eq!(fixed, "###### Subsubsubheading");
645    }
646
647    #[test]
648    fn test_fix_preserves_trailing_newline_enabled() {
649        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
650        let content = "**Heading**\n";
651        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652        let fixed = rule.fix(&ctx).unwrap();
653
654        assert_eq!(fixed, "## Heading\n");
655    }
656
657    #[test]
658    fn test_fix_idempotent() {
659        // A second fix run should produce no further changes
660        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
661        let content = "**Section Title**\n\nBody text.";
662        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663        let fixed1 = rule.fix(&ctx).unwrap();
664        assert_eq!(fixed1, "## Section Title\n\nBody text.");
665
666        // Run fix again on the fixed content
667        let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
668        let fixed2 = rule.fix(&ctx2).unwrap();
669        assert_eq!(fixed2, fixed1, "Fix should be idempotent");
670    }
671
672    #[test]
673    fn test_fix_skips_lists() {
674        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
675        let content = "- *List item*\n- Another item";
676        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677        let fixed = rule.fix(&ctx).unwrap();
678
679        // List items should not be converted
680        assert_eq!(fixed, content);
681    }
682
683    #[test]
684    fn test_fix_skips_blockquotes() {
685        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
686        let content = "> **Quoted text**\n> More quote";
687        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688        let fixed = rule.fix(&ctx).unwrap();
689
690        // Blockquotes should not be converted
691        assert_eq!(fixed, content);
692    }
693
694    #[test]
695    fn test_fix_skips_code_blocks() {
696        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
697        let content = "```\n**Not a heading**\n```";
698        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699        let fixed = rule.fix(&ctx).unwrap();
700
701        // Code blocks should not be converted
702        assert_eq!(fixed, content);
703    }
704
705    #[test]
706    fn test_empty_punctuation_config() {
707        let rule = MD036NoEmphasisAsHeading::new("".to_string());
708        let content = "**Important Note:**\n\nRegular text";
709        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710        let result = rule.check(&ctx).unwrap();
711
712        // With empty punctuation config, all emphasis is flagged
713        assert_eq!(result.len(), 1);
714
715        let fixed = rule.fix(&ctx).unwrap();
716        // Fix is opt-in, so by default no changes are made
717        assert_eq!(fixed, content);
718    }
719
720    #[test]
721    fn test_empty_punctuation_config_with_fix() {
722        // With fix enabled and empty punctuation, all emphasis is converted
723        let rule = MD036NoEmphasisAsHeading::new_with_fix("".to_string(), true, HeadingStyle::Atx, 2);
724        let content = "**Important Note:**\n\nRegular text";
725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726        let fixed = rule.fix(&ctx).unwrap();
727
728        // With empty punctuation and fix enabled, all emphasis is converted
729        assert_eq!(fixed, "## Important Note:\n\nRegular text");
730    }
731
732    #[test]
733    fn test_multiple_emphasized_lines() {
734        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
735        let content = "*First heading*\n\nSome text\n\n**Second heading**\n\nMore text";
736        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737        let result = rule.check(&ctx).unwrap();
738
739        assert_eq!(result.len(), 2);
740        assert_eq!(result[0].line, 1);
741        assert_eq!(result[1].line, 5);
742    }
743
744    #[test]
745    fn test_whitespace_handling() {
746        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
747        let content = "  **Indented emphasis**  \n\nRegular text";
748        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749        let result = rule.check(&ctx).unwrap();
750
751        assert_eq!(result.len(), 1);
752        assert_eq!(result[0].line, 1);
753    }
754
755    #[test]
756    fn test_nested_emphasis() {
757        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
758        let content = "***Not a simple emphasis***\n\nRegular text";
759        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760        let result = rule.check(&ctx).unwrap();
761
762        // Nested emphasis (3 asterisks) should not match our patterns
763        assert_eq!(result.len(), 0);
764    }
765
766    #[test]
767    fn test_emphasis_with_newlines() {
768        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
769        let content = "*First line\nSecond line*\n\nRegular text";
770        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771        let result = rule.check(&ctx).unwrap();
772
773        // Multi-line emphasis should not be flagged
774        assert_eq!(result.len(), 0);
775    }
776
777    #[test]
778    fn test_fix_preserves_trailing_newline_disabled() {
779        // When fix is disabled, trailing newline is preserved
780        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
781        let content = "*Convert to heading*\n";
782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783        let fixed = rule.fix(&ctx).unwrap();
784
785        // Fix is opt-in, so by default no changes are made
786        assert_eq!(fixed, content);
787    }
788
789    #[test]
790    fn test_default_config() {
791        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
792        let (name, config) = rule.default_config_section().unwrap();
793        assert_eq!(name, "MD036");
794
795        let table = config.as_table().unwrap();
796        assert_eq!(table.get("punctuation").unwrap().as_str().unwrap(), ".,;:!?");
797        // `fix = false` matches the runtime default in `from_config`, so the
798        // generated init config is consistent with no-config behavior.
799        assert!(!table.get("fix").unwrap().as_bool().unwrap());
800        assert_eq!(table.get("heading-style").unwrap().as_str().unwrap(), "atx");
801        assert_eq!(table.get("heading-level").unwrap().as_integer().unwrap(), 2);
802    }
803
804    #[test]
805    fn test_default_warns_but_does_not_autoconvert() {
806        // Through the production config path, MD036's autofix is opt-in: `check`
807        // still warns (a useful nudge, matching markdownlint's detect-only
808        // behavior), but `fmt` must not convert emphasis to a heading. Bold
809        // names, filenames, and notes are textually indistinguishable from a
810        // genuine heading, so auto-conversion silently corrupts documents.
811        let rule = MD036NoEmphasisAsHeading::from_config(&crate::config::Config::default());
812        let content = "**Michael Rose**\n\nProfile text.";
813        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814
815        let warnings = rule.check(&ctx).unwrap();
816        assert_eq!(warnings.len(), 1, "detection should still fire by default");
817        assert!(warnings[0].fix.is_none(), "default warnings must not carry a fix");
818
819        let fixed = rule.fix(&ctx).unwrap();
820        assert_eq!(
821            fixed, content,
822            "default fmt must not auto-convert emphasis to a heading"
823        );
824    }
825
826    #[test]
827    fn test_default_preserves_real_world_emphasis() {
828        // Regression: emphasis that is plainly not a heading must survive default
829        // `rumdl fmt` untouched. These cases were found by formatting the clap and
830        // minimal-mistakes corpora, where the old default rewrote a prose note, a
831        // bold filename, and a bold version note into H2 headings.
832        let rule = MD036NoEmphasisAsHeading::from_config(&crate::config::Config::default());
833        for content in [
834            "Intro.\n\n*Note: without the links setup, we can't demonstrate the behavior*\n\nMore.",
835            "**index.md**\n\nA configuration file.",
836            "*New in v4.26.0*\n\nA new feature.",
837        ] {
838            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839            assert_eq!(
840                rule.fix(&ctx).unwrap(),
841                content,
842                "default fmt must preserve: {content:?}"
843            );
844        }
845    }
846
847    #[test]
848    fn test_image_caption_scenario() {
849        // Test the specific issue from #23 - bold text used as image caption
850        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
851        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\")";
852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853        let result = rule.check(&ctx).unwrap();
854
855        // Should detect the bold text even though it's followed by an image
856        assert_eq!(result.len(), 1);
857        assert_eq!(result[0].line, 3);
858        assert!(result[0].message.contains("commits par année : rumdl"));
859
860        // Warnings don't include inline fixes (fix is opt-in via config)
861        assert!(result[0].fix.is_none());
862
863        // Fix is opt-in, so by default the content is unchanged
864        let fixed = rule.fix(&ctx).unwrap();
865        assert_eq!(fixed, content);
866    }
867
868    #[test]
869    fn test_bold_with_colon_no_punctuation_config() {
870        // Test that with empty punctuation config, even text ending with colon is flagged
871        let rule = MD036NoEmphasisAsHeading::new("".to_string());
872        let content = "**commits par année : rumdl**\n\nSome text";
873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874        let result = rule.check(&ctx).unwrap();
875
876        // With empty punctuation config, this should be flagged
877        assert_eq!(result.len(), 1);
878        assert!(result[0].fix.is_none());
879    }
880
881    #[test]
882    fn test_bold_with_colon_default_config() {
883        // Test that with default punctuation config, text ending with colon is NOT flagged
884        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
885        let content = "**Important Note:**\n\nSome text";
886        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887        let result = rule.check(&ctx).unwrap();
888
889        // With default punctuation including colon, this should NOT be flagged
890        assert_eq!(result.len(), 0);
891    }
892
893    #[test]
894    fn test_mkdocs_admonition_body_not_flagged() {
895        // An emphasis-only line indented inside a MkDocs
896        // admonition body is container content, not a standalone paragraph,
897        // so it must not be flagged as emphasis-as-heading.
898        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
899        let content = "!!! note\n\n    _Foo_";
900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
901        let result = rule.check(&ctx).unwrap();
902
903        assert_eq!(
904            result.len(),
905            0,
906            "emphasis inside an admonition body should not be flagged, got: {result:?}"
907        );
908    }
909
910    #[test]
911    fn test_mkdocs_content_tab_body_not_flagged() {
912        // The same false positive occurs inside a MkDocs content tab body.
913        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
914        let content = "=== \"Tab A\"\n\n    _Foo_";
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            0,
921            "emphasis inside a content tab body should not be flagged, got: {result:?}"
922        );
923    }
924
925    #[test]
926    fn test_mkdocs_top_level_emphasis_still_flagged() {
927        // Control: a top-level emphasis-only line outside any admonition/tab
928        // must still be flagged under MkDocs flavor.
929        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
930        let content = "_Foo_\n\nRegular text";
931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
932        let result = rule.check(&ctx).unwrap();
933
934        assert_eq!(
935            result.len(),
936            1,
937            "top-level emphasis should still be flagged under mkdocs flavor, got: {result:?}"
938        );
939    }
940
941    #[test]
942    fn test_standard_flavor_indented_emphasis_unchanged() {
943        // Control: without mkdocs flavor, a 4-space indented emphasis-only line
944        // is CommonMark indented code and was already not flagged. Confirms the
945        // new guard doesn't change standard-flavor behavior.
946        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
947        let content = "Intro\n\n    _Foo_\n\nMore text";
948        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949        let result = rule.check(&ctx).unwrap();
950
951        assert_eq!(
952            result.len(),
953            0,
954            "indented emphasis is indented code under standard flavor, got: {result:?}"
955        );
956    }
957
958    #[test]
959    fn test_mkdocs_cascade_fix_does_not_corrupt_admonition() {
960        // With MD036 autofix enabled, the admonition-nested
961        // emphasis must not be converted to a heading (which MD023 would then
962        // de-indent out of the admonition in the same --fix pass).
963        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
964        let content = "!!! note\n\n    _Foo_";
965        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
966        let fixed = rule.fix(&ctx).unwrap();
967
968        assert_eq!(
969            fixed, content,
970            "admonition-nested emphasis must not be converted to a heading"
971        );
972    }
973
974    #[test]
975    fn test_html_markdown_div_emphasis_still_flagged() {
976        // A markdown="1" HTML div is tag-scoped, not indentation-scoped, so
977        // emphasis-as-heading detection keeps applying inside it in every
978        // flavor.
979        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
980        let content = "<div markdown=\"1\">\n\n_Foo_\n\n</div>";
981        for flavor in [
982            crate::config::MarkdownFlavor::Standard,
983            crate::config::MarkdownFlavor::MkDocs,
984        ] {
985            let ctx = LintContext::new(content, flavor, None);
986            let result = rule.check(&ctx).unwrap();
987            assert_eq!(
988                result.len(),
989                1,
990                "emphasis inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
991            );
992        }
993    }
994}