Skip to main content

rumdl_lib/rules/
md036_no_emphasis_only_first.rs

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