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