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    pub fn from_config_struct(config: MD036Config) -> Self {
65        Self { config }
66    }
67
68    /// Generate the ATX heading prefix for the configured heading level
69    fn atx_prefix(&self) -> String {
70        // HeadingLevel is already validated to 1-6, no clamping needed
71        let level = self.config.heading_level.get();
72        format!("{} ", "#".repeat(level as usize))
73    }
74
75    fn ends_with_punctuation(&self, text: &str) -> bool {
76        if text.is_empty() {
77            return false;
78        }
79        let trimmed = text.trim();
80        if trimmed.is_empty() {
81            return false;
82        }
83        // Check if the last character is in the punctuation set
84        trimmed
85            .chars()
86            .last()
87            .is_some_and(|ch| self.config.punctuation.contains(ch))
88    }
89
90    fn contains_link_or_code(&self, text: &str) -> bool {
91        // Check for inline code: `code`
92        // This is simple but effective since we're checking text that's already
93        // been identified as emphasized content
94        if text.contains('`') {
95            return true;
96        }
97
98        // Check for markdown links: [text](url) or [text][ref]
99        // We need both [ and ] for it to be a potential link
100        // and either ( ) for inline links or ][ for reference links
101        if text.contains('[') && text.contains(']') {
102            // Check for inline link pattern [...](...)
103            if text.contains("](") {
104                return true;
105            }
106            // Check for reference link pattern [...][...] or [...][]
107            if text.contains("][") || text.ends_with(']') {
108                return true;
109            }
110        }
111
112        false
113    }
114
115    fn is_entire_line_emphasized(
116        &self,
117        line: &str,
118        ctx: &crate::lint_context::LintContext,
119        line_num: usize,
120    ) -> Option<(usize, String, usize, usize)> {
121        let original_line = line;
122        let line = line.trim();
123
124        // Fast path for empty lines and lines that don't contain emphasis markers
125        if line.is_empty() || (!line.contains('*') && !line.contains('_')) {
126            return None;
127        }
128
129        // Skip if line is already a heading (but not a heading with emphasis)
130        if HEADING_MARKER.is_match(line) && !HEADING_WITH_EMPHASIS.is_match(line) {
131            return None;
132        }
133
134        // Skip if line is a Table of Contents label (common legitimate use of bold text)
135        if TOC_LABEL_PATTERN.is_match(line) {
136            return None;
137        }
138
139        // Skip if line is in a list, blockquote, code block, or HTML comment
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            })
149        {
150            return None;
151        }
152
153        // Helper closure to check common conditions for all emphasis patterns
154        let check_emphasis = |text: &str, level: usize, pattern: String| -> Option<(usize, String, usize, usize)> {
155            // Check if text ends with punctuation - if so, don't flag it
156            if !self.config.punctuation.is_empty() && self.ends_with_punctuation(text) {
157                return None;
158            }
159            // Skip if text contains links or inline code (matches markdownlint behavior)
160            // In markdownlint, these would be multiple tokens and thus not flagged
161            if self.contains_link_or_code(text) {
162                return None;
163            }
164            // Find position in original line by looking for the emphasis pattern
165            let start_pos = original_line.find(&pattern).unwrap_or(0);
166            let end_pos = start_pos + pattern.len();
167            Some((level, text.to_string(), start_pos, end_pos))
168        };
169
170        // Check for *emphasis* pattern (entire line)
171        if let Some(caps) = RE_ASTERISK_SINGLE.captures(line) {
172            let text = caps.get(1).unwrap().as_str();
173            let pattern = format!("*{text}*");
174            return check_emphasis(text, 1, pattern);
175        }
176
177        // Check for _emphasis_ pattern (entire line)
178        if let Some(caps) = RE_UNDERSCORE_SINGLE.captures(line) {
179            let text = caps.get(1).unwrap().as_str();
180            let pattern = format!("_{text}_");
181            return check_emphasis(text, 1, pattern);
182        }
183
184        // Check for **strong** pattern (entire line)
185        if let Some(caps) = RE_ASTERISK_DOUBLE.captures(line) {
186            let text = caps.get(1).unwrap().as_str();
187            let pattern = format!("**{text}**");
188            return check_emphasis(text, 2, pattern);
189        }
190
191        // Check for __strong__ pattern (entire line)
192        if let Some(caps) = RE_UNDERSCORE_DOUBLE.captures(line) {
193            let text = caps.get(1).unwrap().as_str();
194            let pattern = format!("__{text}__");
195            return check_emphasis(text, 2, pattern);
196        }
197
198        None
199    }
200}
201
202impl Rule for MD036NoEmphasisAsHeading {
203    fn name(&self) -> &'static str {
204        "MD036"
205    }
206
207    fn description(&self) -> &'static str {
208        "Emphasis should not be used instead of a heading"
209    }
210
211    fn category(&self) -> RuleCategory {
212        RuleCategory::Emphasis
213    }
214
215    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
216        let content = ctx.content;
217        // Fast path for empty content or content without emphasis markers
218        if content.is_empty() || (!content.contains('*') && !content.contains('_')) {
219            return Ok(Vec::new());
220        }
221
222        let mut warnings = Vec::new();
223
224        let lines: Vec<&str> = content.lines().collect();
225        let line_count = lines.len();
226
227        for (i, line) in lines.iter().enumerate() {
228            // Skip obvious non-matches quickly
229            if line.trim().is_empty() || (!line.contains('*') && !line.contains('_')) {
230                continue;
231            }
232
233            // Emphasis-as-heading requires the line to be a standalone paragraph:
234            // - preceded by a blank line (or start of document)
235            // - followed by a blank line (or end of document)
236            let prev_blank = i == 0 || lines[i - 1].trim().is_empty();
237            let next_blank = i + 1 >= line_count || lines[i + 1].trim().is_empty();
238            if !prev_blank || !next_blank {
239                continue;
240            }
241
242            if let Some((_level, text, start_pos, end_pos)) = self.is_entire_line_emphasized(line, ctx, i) {
243                let (start_line, start_col, end_line, end_col) =
244                    calculate_emphasis_range(i + 1, line, start_pos, end_pos);
245
246                // Only include fix if auto-fix is enabled in config
247                let fix = if self.config.fix {
248                    let prefix = self.atx_prefix();
249                    // Get the byte range for the full line content
250                    let range = ctx.line_index.line_content_range(i + 1);
251                    // Preserve leading whitespace by not including it in the replacement
252                    let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
253                    Some(Fix {
254                        range,
255                        replacement: format!("{leading_ws}{prefix}{text}"),
256                    })
257                } else {
258                    None
259                };
260
261                warnings.push(LintWarning {
262                    rule_name: Some(self.name().to_string()),
263                    line: start_line,
264                    column: start_col,
265                    end_line,
266                    end_column: end_col,
267                    message: format!("Emphasis used instead of a heading: '{text}'"),
268                    severity: Severity::Warning,
269                    fix,
270                });
271            }
272        }
273
274        Ok(warnings)
275    }
276
277    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
278        // Auto-fix is opt-in: only apply if explicitly enabled in config
279        // When disabled, check() returns warnings without fixes, so this is a no-op
280        if !self.config.fix {
281            return Ok(ctx.content.to_string());
282        }
283
284        // Get warnings with their inline fixes
285        let warnings = self.check(ctx)?;
286        let warnings =
287            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
288
289        // If no warnings with fixes, return original content
290        if warnings.is_empty() || !warnings.iter().any(|w| w.fix.is_some()) {
291            return Ok(ctx.content.to_string());
292        }
293
294        // Collect all fixes and sort by range start (descending) to apply from end to beginning
295        let mut fixes: Vec<_> = warnings
296            .iter()
297            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
298            .collect();
299        fixes.sort_by(|a, b| b.0.cmp(&a.0));
300
301        // Apply fixes from end to beginning to preserve byte offsets
302        let mut result = ctx.content.to_string();
303        for (start, end, replacement) in fixes {
304            if start < result.len() && end <= result.len() && start <= end {
305                result.replace_range(start..end, replacement);
306            }
307        }
308
309        Ok(result)
310    }
311
312    /// Check if this rule should be skipped for performance
313    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
314        // Skip if content is empty or has no emphasis markers
315        ctx.content.is_empty() || !ctx.likely_has_emphasis()
316    }
317
318    fn as_any(&self) -> &dyn std::any::Any {
319        self
320    }
321
322    fn default_config_section(&self) -> Option<(String, toml::Value)> {
323        let mut map = toml::map::Map::new();
324        map.insert(
325            "punctuation".to_string(),
326            toml::Value::String(self.config.punctuation.clone()),
327        );
328        map.insert("fix".to_string(), toml::Value::Boolean(self.config.fix));
329        map.insert("heading-style".to_string(), toml::Value::String("atx".to_string()));
330        map.insert(
331            "heading-level".to_string(),
332            toml::Value::Integer(i64::from(self.config.heading_level.get())),
333        );
334        Some((self.name().to_string(), toml::Value::Table(map)))
335    }
336
337    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
338    where
339        Self: Sized,
340    {
341        let punctuation = crate::config::get_rule_config_value::<String>(config, "MD036", "punctuation")
342            .unwrap_or_else(|| ".,;:!?".to_string());
343
344        let fix = crate::config::get_rule_config_value::<bool>(config, "MD036", "fix").unwrap_or(false);
345
346        // heading_style currently only supports "atx"
347        let heading_style = HeadingStyle::Atx;
348
349        // HeadingLevel validation is handled by new_with_fix, which defaults to 2 if invalid
350        let heading_level = crate::config::get_rule_config_value::<u8>(config, "MD036", "heading-level")
351            .or_else(|| crate::config::get_rule_config_value::<u8>(config, "MD036", "heading_level"))
352            .unwrap_or(2);
353
354        Box::new(MD036NoEmphasisAsHeading::new_with_fix(
355            punctuation,
356            fix,
357            heading_style,
358            heading_level,
359        ))
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::lint_context::LintContext;
367
368    #[test]
369    fn test_single_asterisk_emphasis() {
370        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
371        let content = "*This is emphasized*\n\nRegular text";
372        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
373        let result = rule.check(&ctx).unwrap();
374
375        assert_eq!(result.len(), 1);
376        assert_eq!(result[0].line, 1);
377        assert!(
378            result[0]
379                .message
380                .contains("Emphasis used instead of a heading: 'This is emphasized'")
381        );
382    }
383
384    #[test]
385    fn test_single_underscore_emphasis() {
386        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
387        let content = "_This is emphasized_\n\nRegular text";
388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389        let result = rule.check(&ctx).unwrap();
390
391        assert_eq!(result.len(), 1);
392        assert_eq!(result[0].line, 1);
393        assert!(
394            result[0]
395                .message
396                .contains("Emphasis used instead of a heading: 'This is emphasized'")
397        );
398    }
399
400    #[test]
401    fn test_double_asterisk_strong() {
402        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
403        let content = "**This is strong**\n\nRegular text";
404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
405        let result = rule.check(&ctx).unwrap();
406
407        assert_eq!(result.len(), 1);
408        assert_eq!(result[0].line, 1);
409        assert!(
410            result[0]
411                .message
412                .contains("Emphasis used instead of a heading: 'This is strong'")
413        );
414    }
415
416    #[test]
417    fn test_double_underscore_strong() {
418        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
419        let content = "__This is strong__\n\nRegular text";
420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421        let result = rule.check(&ctx).unwrap();
422
423        assert_eq!(result.len(), 1);
424        assert_eq!(result[0].line, 1);
425        assert!(
426            result[0]
427                .message
428                .contains("Emphasis used instead of a heading: 'This is strong'")
429        );
430    }
431
432    #[test]
433    fn test_emphasis_with_punctuation() {
434        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
435        let content = "**Important Note:**\n\nRegular text";
436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437        let result = rule.check(&ctx).unwrap();
438
439        // Emphasis with punctuation should NOT be flagged (matches markdownlint)
440        assert_eq!(result.len(), 0);
441    }
442
443    #[test]
444    fn test_emphasis_in_paragraph() {
445        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
446        let content = "This is a paragraph with *emphasis* in the middle.";
447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448        let result = rule.check(&ctx).unwrap();
449
450        // Should not flag emphasis within a line
451        assert_eq!(result.len(), 0);
452    }
453
454    #[test]
455    fn test_emphasis_in_list() {
456        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
457        let content = "- *List item with emphasis*\n- Another item";
458        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
459        let result = rule.check(&ctx).unwrap();
460
461        // Should not flag emphasis in list items
462        assert_eq!(result.len(), 0);
463    }
464
465    #[test]
466    fn test_emphasis_in_blockquote() {
467        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
468        let content = "> *Quote with emphasis*\n> Another line";
469        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470        let result = rule.check(&ctx).unwrap();
471
472        // Should not flag emphasis in blockquotes
473        assert_eq!(result.len(), 0);
474    }
475
476    #[test]
477    fn test_emphasis_in_code_block() {
478        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
479        let content = "```\n*Not emphasis in code*\n```";
480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481        let result = rule.check(&ctx).unwrap();
482
483        // Should not flag emphasis in code blocks
484        assert_eq!(result.len(), 0);
485    }
486
487    #[test]
488    fn test_emphasis_in_html_comment() {
489        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
490        let content = "<!--\n**bigger**\ncomment\n-->";
491        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492        let result = rule.check(&ctx).unwrap();
493
494        // Should not flag emphasis in HTML comments (matches markdownlint)
495        assert_eq!(
496            result.len(),
497            0,
498            "Expected no warnings for emphasis in HTML comment, got: {result:?}"
499        );
500    }
501
502    #[test]
503    fn test_toc_label() {
504        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
505        let content = "**Table of Contents**\n\n- Item 1\n- Item 2";
506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507        let result = rule.check(&ctx).unwrap();
508
509        // Should not flag common TOC labels
510        assert_eq!(result.len(), 0);
511    }
512
513    #[test]
514    fn test_already_heading() {
515        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
516        let content = "# **Bold in heading**\n\nRegular text";
517        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518        let result = rule.check(&ctx).unwrap();
519
520        // Should not flag emphasis that's already in a heading
521        assert_eq!(result.len(), 0);
522    }
523
524    #[test]
525    fn test_fix_disabled_by_default() {
526        // When fix is not enabled (default), no changes should be made
527        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
528        let content = "*Convert to heading*\n\nRegular text";
529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530        let fixed = rule.fix(&ctx).unwrap();
531
532        // Fix is opt-in, so by default no changes are made
533        assert_eq!(fixed, content);
534    }
535
536    #[test]
537    fn test_fix_disabled_preserves_content() {
538        // When fix is not enabled, content is preserved
539        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
540        let content = "**Convert to heading**\n\nRegular text";
541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542        let fixed = rule.fix(&ctx).unwrap();
543
544        // Fix is opt-in, so by default no changes are made
545        assert_eq!(fixed, content);
546    }
547
548    #[test]
549    fn test_fix_enabled_single_asterisk() {
550        // When fix is enabled, single asterisk emphasis is converted
551        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
552        let content = "*Section Title*\n\nBody text.";
553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554        let fixed = rule.fix(&ctx).unwrap();
555
556        assert_eq!(fixed, "## Section Title\n\nBody text.");
557    }
558
559    #[test]
560    fn test_fix_enabled_double_asterisk() {
561        // When fix is enabled, double asterisk emphasis is converted
562        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
563        let content = "**Section Title**\n\nBody text.";
564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565        let fixed = rule.fix(&ctx).unwrap();
566
567        assert_eq!(fixed, "## Section Title\n\nBody text.");
568    }
569
570    #[test]
571    fn test_fix_enabled_single_underscore() {
572        // When fix is enabled, single underscore emphasis is converted
573        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 3);
574        let content = "_Section Title_\n\nBody text.";
575        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576        let fixed = rule.fix(&ctx).unwrap();
577
578        assert_eq!(fixed, "### Section Title\n\nBody text.");
579    }
580
581    #[test]
582    fn test_fix_enabled_double_underscore() {
583        // When fix is enabled, double underscore emphasis is converted
584        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 4);
585        let content = "__Section Title__\n\nBody text.";
586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587        let fixed = rule.fix(&ctx).unwrap();
588
589        assert_eq!(fixed, "#### Section Title\n\nBody text.");
590    }
591
592    #[test]
593    fn test_fix_enabled_multiple_lines() {
594        // When fix is enabled, multiple emphasis-as-heading lines are converted
595        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
596        let content = "**First Section**\n\nSome text.\n\n**Second Section**\n\nMore text.";
597        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598        let fixed = rule.fix(&ctx).unwrap();
599
600        assert_eq!(
601            fixed,
602            "## First Section\n\nSome text.\n\n## Second Section\n\nMore text."
603        );
604    }
605
606    #[test]
607    fn test_fix_enabled_skips_punctuation() {
608        // When fix is enabled, lines ending with punctuation are skipped
609        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
610        let content = "**Important Note:**\n\nBody text.";
611        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612        let fixed = rule.fix(&ctx).unwrap();
613
614        // Should not be changed because it ends with punctuation (colon)
615        assert_eq!(fixed, content);
616    }
617
618    #[test]
619    fn test_fix_enabled_heading_level_1() {
620        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 1);
621        let content = "**Title**";
622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623        let fixed = rule.fix(&ctx).unwrap();
624
625        assert_eq!(fixed, "# Title");
626    }
627
628    #[test]
629    fn test_fix_enabled_heading_level_6() {
630        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 6);
631        let content = "**Subsubsubheading**";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633        let fixed = rule.fix(&ctx).unwrap();
634
635        assert_eq!(fixed, "###### Subsubsubheading");
636    }
637
638    #[test]
639    fn test_fix_preserves_trailing_newline_enabled() {
640        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
641        let content = "**Heading**\n";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643        let fixed = rule.fix(&ctx).unwrap();
644
645        assert_eq!(fixed, "## Heading\n");
646    }
647
648    #[test]
649    fn test_fix_idempotent() {
650        // A second fix run should produce no further changes
651        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
652        let content = "**Section Title**\n\nBody text.";
653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654        let fixed1 = rule.fix(&ctx).unwrap();
655        assert_eq!(fixed1, "## Section Title\n\nBody text.");
656
657        // Run fix again on the fixed content
658        let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
659        let fixed2 = rule.fix(&ctx2).unwrap();
660        assert_eq!(fixed2, fixed1, "Fix should be idempotent");
661    }
662
663    #[test]
664    fn test_fix_skips_lists() {
665        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
666        let content = "- *List item*\n- Another item";
667        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668        let fixed = rule.fix(&ctx).unwrap();
669
670        // List items should not be converted
671        assert_eq!(fixed, content);
672    }
673
674    #[test]
675    fn test_fix_skips_blockquotes() {
676        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
677        let content = "> **Quoted text**\n> More quote";
678        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679        let fixed = rule.fix(&ctx).unwrap();
680
681        // Blockquotes should not be converted
682        assert_eq!(fixed, content);
683    }
684
685    #[test]
686    fn test_fix_skips_code_blocks() {
687        let rule = MD036NoEmphasisAsHeading::new_with_fix(".,;:!?".to_string(), true, HeadingStyle::Atx, 2);
688        let content = "```\n**Not a heading**\n```";
689        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690        let fixed = rule.fix(&ctx).unwrap();
691
692        // Code blocks should not be converted
693        assert_eq!(fixed, content);
694    }
695
696    #[test]
697    fn test_empty_punctuation_config() {
698        let rule = MD036NoEmphasisAsHeading::new("".to_string());
699        let content = "**Important Note:**\n\nRegular text";
700        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701        let result = rule.check(&ctx).unwrap();
702
703        // With empty punctuation config, all emphasis is flagged
704        assert_eq!(result.len(), 1);
705
706        let fixed = rule.fix(&ctx).unwrap();
707        // Fix is opt-in, so by default no changes are made
708        assert_eq!(fixed, content);
709    }
710
711    #[test]
712    fn test_empty_punctuation_config_with_fix() {
713        // With fix enabled and empty punctuation, all emphasis is converted
714        let rule = MD036NoEmphasisAsHeading::new_with_fix("".to_string(), true, HeadingStyle::Atx, 2);
715        let content = "**Important Note:**\n\nRegular text";
716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717        let fixed = rule.fix(&ctx).unwrap();
718
719        // With empty punctuation and fix enabled, all emphasis is converted
720        assert_eq!(fixed, "## Important Note:\n\nRegular text");
721    }
722
723    #[test]
724    fn test_multiple_emphasized_lines() {
725        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
726        let content = "*First heading*\n\nSome text\n\n**Second heading**\n\nMore text";
727        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728        let result = rule.check(&ctx).unwrap();
729
730        assert_eq!(result.len(), 2);
731        assert_eq!(result[0].line, 1);
732        assert_eq!(result[1].line, 5);
733    }
734
735    #[test]
736    fn test_whitespace_handling() {
737        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
738        let content = "  **Indented emphasis**  \n\nRegular text";
739        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740        let result = rule.check(&ctx).unwrap();
741
742        assert_eq!(result.len(), 1);
743        assert_eq!(result[0].line, 1);
744    }
745
746    #[test]
747    fn test_nested_emphasis() {
748        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
749        let content = "***Not a simple emphasis***\n\nRegular text";
750        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751        let result = rule.check(&ctx).unwrap();
752
753        // Nested emphasis (3 asterisks) should not match our patterns
754        assert_eq!(result.len(), 0);
755    }
756
757    #[test]
758    fn test_emphasis_with_newlines() {
759        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
760        let content = "*First line\nSecond line*\n\nRegular text";
761        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762        let result = rule.check(&ctx).unwrap();
763
764        // Multi-line emphasis should not be flagged
765        assert_eq!(result.len(), 0);
766    }
767
768    #[test]
769    fn test_fix_preserves_trailing_newline_disabled() {
770        // When fix is disabled, trailing newline is preserved
771        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
772        let content = "*Convert to heading*\n";
773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774        let fixed = rule.fix(&ctx).unwrap();
775
776        // Fix is opt-in, so by default no changes are made
777        assert_eq!(fixed, content);
778    }
779
780    #[test]
781    fn test_default_config() {
782        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
783        let (name, config) = rule.default_config_section().unwrap();
784        assert_eq!(name, "MD036");
785
786        let table = config.as_table().unwrap();
787        assert_eq!(table.get("punctuation").unwrap().as_str().unwrap(), ".,;:!?");
788        assert!(!table.get("fix").unwrap().as_bool().unwrap());
789        assert_eq!(table.get("heading-style").unwrap().as_str().unwrap(), "atx");
790        assert_eq!(table.get("heading-level").unwrap().as_integer().unwrap(), 2);
791    }
792
793    #[test]
794    fn test_image_caption_scenario() {
795        // Test the specific issue from #23 - bold text used as image caption
796        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
797        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\")";
798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799        let result = rule.check(&ctx).unwrap();
800
801        // Should detect the bold text even though it's followed by an image
802        assert_eq!(result.len(), 1);
803        assert_eq!(result[0].line, 3);
804        assert!(result[0].message.contains("commits par année : rumdl"));
805
806        // Warnings don't include inline fixes (fix is opt-in via config)
807        assert!(result[0].fix.is_none());
808
809        // Fix is opt-in, so by default the content is unchanged
810        let fixed = rule.fix(&ctx).unwrap();
811        assert_eq!(fixed, content);
812    }
813
814    #[test]
815    fn test_bold_with_colon_no_punctuation_config() {
816        // Test that with empty punctuation config, even text ending with colon is flagged
817        let rule = MD036NoEmphasisAsHeading::new("".to_string());
818        let content = "**commits par année : rumdl**\n\nSome text";
819        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820        let result = rule.check(&ctx).unwrap();
821
822        // With empty punctuation config, this should be flagged
823        assert_eq!(result.len(), 1);
824        assert!(result[0].fix.is_none());
825    }
826
827    #[test]
828    fn test_bold_with_colon_default_config() {
829        // Test that with default punctuation config, text ending with colon is NOT flagged
830        let rule = MD036NoEmphasisAsHeading::new(".,;:!?".to_string());
831        let content = "**Important Note:**\n\nSome text";
832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833        let result = rule.check(&ctx).unwrap();
834
835        // With default punctuation including colon, this should NOT be flagged
836        assert_eq!(result.len(), 0);
837    }
838}