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