Skip to main content

rumdl_lib/rules/
md050_strong_style.rs

1use crate::utils::range_utils::calculate_match_range;
2use crate::utils::regex_cache::{BOLD_ASTERISK_REGEX, BOLD_UNDERSCORE_REGEX};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
5use crate::rules::strong_style::StrongStyle;
6use crate::utils::regex_cache::get_cached_regex;
7use crate::utils::skip_context::{is_in_math_context, is_in_mkdocs_markup};
8
9// Reference definition pattern
10const REF_DEF_REGEX_STR: &str = r#"(?m)^[ ]{0,3}\[([^\]]+)\]:\s*([^\s]+)(?:\s+(?:"([^"]*)"|'([^']*)'))?$"#;
11
12/// Check if a byte position within a line is inside a backtick-delimited code span.
13/// This is a line-level fallback for cases where pulldown-cmark's code span detection
14/// misses spans due to table parsing interference (e.g., pipes inside code spans
15/// in table rows cause pulldown-cmark to misidentify cell boundaries).
16fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
17    let bytes = line.as_bytes();
18    let mut i = 0;
19
20    while i < bytes.len() {
21        if bytes[i] == b'`' {
22            let open_start = i;
23            let mut backtick_count = 0;
24            while i < bytes.len() && bytes[i] == b'`' {
25                backtick_count += 1;
26                i += 1;
27            }
28
29            // Search for matching closing backticks
30            let mut j = i;
31            while j < bytes.len() {
32                if bytes[j] == b'`' {
33                    let mut close_count = 0;
34                    while j < bytes.len() && bytes[j] == b'`' {
35                        close_count += 1;
36                        j += 1;
37                    }
38                    if close_count == backtick_count {
39                        // Found matching pair: code span covers open_start..j
40                        if byte_pos >= open_start && byte_pos < j {
41                            return true;
42                        }
43                        i = j;
44                        break;
45                    }
46                } else {
47                    j += 1;
48                }
49            }
50
51            if j >= bytes.len() {
52                // No matching close found, remaining text is not a code span
53                break;
54            }
55        } else {
56            i += 1;
57        }
58    }
59
60    false
61}
62
63mod md050_config;
64use md050_config::MD050Config;
65
66/// Rule MD050: Strong style
67///
68/// See [docs/md050.md](../../docs/md050.md) for full documentation, configuration, and examples.
69///
70/// This rule is triggered when strong markers (** or __) are used in an inconsistent way.
71#[derive(Debug, Default, Clone)]
72pub struct MD050StrongStyle {
73    config: MD050Config,
74}
75
76impl MD050StrongStyle {
77    pub fn new(style: StrongStyle) -> Self {
78        Self {
79            config: MD050Config { style },
80        }
81    }
82
83    pub fn from_config_struct(config: MD050Config) -> Self {
84        Self { config }
85    }
86
87    /// Check if a byte position is within a link (inline links, reference links, or reference definitions)
88    fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
89        // Check inline and reference links
90        for link in &ctx.links {
91            if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
92                return true;
93            }
94        }
95
96        // Check images (which use similar syntax)
97        for image in &ctx.images {
98            if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
99                return true;
100            }
101        }
102
103        // Check reference definitions [ref]: url "title" using regex pattern
104        if let Ok(re) = get_cached_regex(REF_DEF_REGEX_STR) {
105            for m in re.find_iter(ctx.content) {
106                if m.start() <= byte_pos && byte_pos < m.end() {
107                    return true;
108                }
109            }
110        }
111
112        false
113    }
114
115    /// Check if a byte position is within an HTML tag
116    fn is_in_html_tag(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
117        // Check HTML tags
118        for html_tag in ctx.html_tags().iter() {
119            // Only consider the position inside the tag if it's between the < and >
120            // Don't include positions after the tag ends
121            if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
122                return true;
123            }
124        }
125        false
126    }
127
128    /// Check if a byte position is within HTML code tags (<code>...</code>)
129    /// This is separate from is_in_html_tag because we need to check the content between tags
130    fn is_in_html_code_content(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
131        let html_tags = ctx.html_tags();
132        let mut open_code_pos: Option<usize> = None;
133
134        for tag in html_tags.iter() {
135            // If we've passed our position, check if we're in an open code block
136            if tag.byte_offset > byte_pos {
137                return open_code_pos.is_some();
138            }
139
140            if tag.tag_name == "code" {
141                if tag.is_self_closing {
142                    // Self-closing tags don't create a code context
143                    continue;
144                } else if !tag.is_closing {
145                    // Opening <code> tag
146                    open_code_pos = Some(tag.byte_end);
147                } else if tag.is_closing && open_code_pos.is_some() {
148                    // Closing </code> tag
149                    if let Some(open_pos) = open_code_pos
150                        && byte_pos >= open_pos
151                        && byte_pos < tag.byte_offset
152                    {
153                        // We're between <code> and </code>
154                        return true;
155                    }
156                    open_code_pos = None;
157                }
158            }
159        }
160
161        // Check if we're still in an unclosed code tag
162        open_code_pos.is_some() && byte_pos >= open_code_pos.unwrap()
163    }
164
165    fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
166        let content = ctx.content;
167        let lines = ctx.raw_lines();
168
169        // Count how many times each marker appears (prevalence-based approach)
170        let mut asterisk_count = 0;
171        for m in BOLD_ASTERISK_REGEX.find_iter(content) {
172            // Skip matches in front matter
173            let (line_num, col) = ctx.offset_to_line_col(m.start());
174            let skip_context = ctx
175                .line_info(line_num)
176                .map(|info| info.in_front_matter)
177                .unwrap_or(false);
178
179            // Check MkDocs markup
180            let in_mkdocs_markup = lines
181                .get(line_num.saturating_sub(1))
182                .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
183
184            // Line-level inline code fallback for cases pulldown-cmark misses
185            let in_inline_code = lines
186                .get(line_num.saturating_sub(1))
187                .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
188
189            if !skip_context
190                && !ctx.is_in_code_block_or_span(m.start())
191                && !in_inline_code
192                && !self.is_in_link(ctx, m.start())
193                && !self.is_in_html_tag(ctx, m.start())
194                && !self.is_in_html_code_content(ctx, m.start())
195                && !in_mkdocs_markup
196                && !is_in_math_context(ctx, m.start())
197            {
198                asterisk_count += 1;
199            }
200        }
201
202        let mut underscore_count = 0;
203        for m in BOLD_UNDERSCORE_REGEX.find_iter(content) {
204            // Skip matches in front matter
205            let (line_num, col) = ctx.offset_to_line_col(m.start());
206            let skip_context = ctx
207                .line_info(line_num)
208                .map(|info| info.in_front_matter)
209                .unwrap_or(false);
210
211            // Check MkDocs markup
212            let in_mkdocs_markup = lines
213                .get(line_num.saturating_sub(1))
214                .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
215
216            // Line-level inline code fallback for cases pulldown-cmark misses
217            let in_inline_code = lines
218                .get(line_num.saturating_sub(1))
219                .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
220
221            if !skip_context
222                && !ctx.is_in_code_block_or_span(m.start())
223                && !in_inline_code
224                && !self.is_in_link(ctx, m.start())
225                && !self.is_in_html_tag(ctx, m.start())
226                && !self.is_in_html_code_content(ctx, m.start())
227                && !in_mkdocs_markup
228                && !is_in_math_context(ctx, m.start())
229            {
230                underscore_count += 1;
231            }
232        }
233
234        match (asterisk_count, underscore_count) {
235            (0, 0) => None,
236            (_, 0) => Some(StrongStyle::Asterisk),
237            (0, _) => Some(StrongStyle::Underscore),
238            (a, u) => {
239                // Use the most prevalent marker as the target style
240                // In case of a tie, prefer asterisk (matches CommonMark recommendation)
241                if a >= u {
242                    Some(StrongStyle::Asterisk)
243                } else {
244                    Some(StrongStyle::Underscore)
245                }
246            }
247        }
248    }
249
250    fn is_escaped(&self, text: &str, pos: usize) -> bool {
251        if pos == 0 {
252            return false;
253        }
254
255        let mut backslash_count = 0;
256        let mut i = pos;
257        let bytes = text.as_bytes();
258        while i > 0 {
259            i -= 1;
260            // Safe for ASCII backslash
261            if i < bytes.len() && bytes[i] != b'\\' {
262                break;
263            }
264            backslash_count += 1;
265        }
266        backslash_count % 2 == 1
267    }
268}
269
270impl Rule for MD050StrongStyle {
271    fn name(&self) -> &'static str {
272        "MD050"
273    }
274
275    fn description(&self) -> &'static str {
276        "Strong emphasis style should be consistent"
277    }
278
279    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
280        let content = ctx.content;
281        let line_index = &ctx.line_index;
282
283        let mut warnings = Vec::new();
284
285        let target_style = match self.config.style {
286            StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
287            _ => self.config.style,
288        };
289
290        let strong_regex = match target_style {
291            StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
292            StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
293            StrongStyle::Consistent => {
294                // This case is handled separately in the calling code
295                // but fallback to asterisk style for safety
296                &*BOLD_UNDERSCORE_REGEX
297            }
298        };
299
300        for (line_num, line) in content.lines().enumerate() {
301            // Skip if this line is in front matter
302            if let Some(line_info) = ctx.line_info(line_num + 1)
303                && line_info.in_front_matter
304            {
305                continue;
306            }
307
308            let byte_pos = line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
309
310            for m in strong_regex.find_iter(line) {
311                // Calculate the byte position of this match in the document
312                let match_byte_pos = byte_pos + m.start();
313
314                // Skip if this strong text is inside a code block, code span, link, HTML code content, MkDocs markup, or math block
315                if ctx.is_in_code_block_or_span(match_byte_pos)
316                    || is_in_inline_code_on_line(line, m.start())
317                    || self.is_in_link(ctx, match_byte_pos)
318                    || self.is_in_html_code_content(ctx, match_byte_pos)
319                    || is_in_mkdocs_markup(line, m.start(), ctx.flavor)
320                    || is_in_math_context(ctx, match_byte_pos)
321                {
322                    continue;
323                }
324
325                // Skip strong emphasis inside HTML tags
326                if self.is_in_html_tag(ctx, match_byte_pos) {
327                    continue;
328                }
329
330                if !self.is_escaped(line, m.start()) {
331                    let text = &line[m.start() + 2..m.end() - 2];
332
333                    // NOTE: Intentional deviation from markdownlint behavior.
334                    // markdownlint reports two warnings per emphasis (one for opening marker,
335                    // one for closing marker). We report one warning per emphasis block because:
336                    // 1. The markers are semantically one unit - you can't fix one without the other
337                    // 2. Cleaner output - "10 issues" vs "20 issues" for 10 bold words
338                    // 3. The fix is atomic - replacing the entire emphasis at once
339                    let message = match target_style {
340                        StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
341                        StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
342                        StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
343                    };
344
345                    // Calculate precise character range for the entire strong emphasis
346                    let (start_line, start_col, end_line, end_col) =
347                        calculate_match_range(line_num + 1, line, m.start(), m.len());
348
349                    warnings.push(LintWarning {
350                        rule_name: Some(self.name().to_string()),
351                        line: start_line,
352                        column: start_col,
353                        end_line,
354                        end_column: end_col,
355                        message: message.to_string(),
356                        severity: Severity::Warning,
357                        fix: Some(Fix {
358                            range: line_index.line_col_to_byte_range_with_length(line_num + 1, m.start() + 1, m.len()),
359                            replacement: match target_style {
360                                StrongStyle::Asterisk => format!("**{text}**"),
361                                StrongStyle::Underscore => format!("__{text}__"),
362                                StrongStyle::Consistent => format!("**{text}**"),
363                            },
364                        }),
365                    });
366                }
367            }
368        }
369
370        Ok(warnings)
371    }
372
373    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
374        let content = ctx.content;
375
376        let target_style = match self.config.style {
377            StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
378            _ => self.config.style,
379        };
380
381        let strong_regex = match target_style {
382            StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
383            StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
384            StrongStyle::Consistent => {
385                // This case is handled separately in the calling code
386                // but fallback to asterisk style for safety
387                &*BOLD_UNDERSCORE_REGEX
388            }
389        };
390
391        // Store matches with their positions
392        let lines = ctx.raw_lines();
393
394        let matches: Vec<(usize, usize)> = strong_regex
395            .find_iter(content)
396            .filter(|m| {
397                // Skip matches in front matter
398                let (line_num, col) = ctx.offset_to_line_col(m.start());
399                if let Some(line_info) = ctx.line_info(line_num)
400                    && line_info.in_front_matter
401                {
402                    return false;
403                }
404                // Skip MkDocs markup and math blocks
405                let in_mkdocs_markup = lines
406                    .get(line_num.saturating_sub(1))
407                    .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
408                // Line-level inline code fallback for cases pulldown-cmark misses
409                let in_inline_code = lines
410                    .get(line_num.saturating_sub(1))
411                    .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
412                !ctx.is_in_code_block_or_span(m.start())
413                    && !in_inline_code
414                    && !self.is_in_link(ctx, m.start())
415                    && !self.is_in_html_tag(ctx, m.start())
416                    && !self.is_in_html_code_content(ctx, m.start())
417                    && !in_mkdocs_markup
418                    && !is_in_math_context(ctx, m.start())
419            })
420            .filter(|m| !self.is_escaped(content, m.start()))
421            .map(|m| (m.start(), m.end()))
422            .collect();
423
424        // Process matches in reverse order to maintain correct indices
425
426        let mut result = content.to_string();
427        for (start, end) in matches.into_iter().rev() {
428            let text = &result[start + 2..end - 2];
429            let replacement = match target_style {
430                StrongStyle::Asterisk => format!("**{text}**"),
431                StrongStyle::Underscore => format!("__{text}__"),
432                StrongStyle::Consistent => {
433                    // This case is handled separately in the calling code
434                    // but fallback to asterisk style for safety
435                    format!("**{text}**")
436                }
437            };
438            result.replace_range(start..end, &replacement);
439        }
440
441        Ok(result)
442    }
443
444    /// Check if this rule should be skipped
445    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
446        // Strong uses double markers, but likely_has_emphasis checks for count > 1
447        ctx.content.is_empty() || !ctx.likely_has_emphasis()
448    }
449
450    fn as_any(&self) -> &dyn std::any::Any {
451        self
452    }
453
454    fn default_config_section(&self) -> Option<(String, toml::Value)> {
455        let json_value = serde_json::to_value(&self.config).ok()?;
456        Some((
457            self.name().to_string(),
458            crate::rule_config_serde::json_to_toml_value(&json_value)?,
459        ))
460    }
461
462    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
463    where
464        Self: Sized,
465    {
466        let rule_config = crate::rule_config_serde::load_rule_config::<MD050Config>(config);
467        Box::new(Self::from_config_struct(rule_config))
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::lint_context::LintContext;
475
476    #[test]
477    fn test_asterisk_style_with_asterisks() {
478        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
479        let content = "This is **strong text** here.";
480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481        let result = rule.check(&ctx).unwrap();
482
483        assert_eq!(result.len(), 0);
484    }
485
486    #[test]
487    fn test_asterisk_style_with_underscores() {
488        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
489        let content = "This is __strong text__ here.";
490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491        let result = rule.check(&ctx).unwrap();
492
493        assert_eq!(result.len(), 1);
494        assert!(
495            result[0]
496                .message
497                .contains("Strong emphasis should use ** instead of __")
498        );
499        assert_eq!(result[0].line, 1);
500        assert_eq!(result[0].column, 9);
501    }
502
503    #[test]
504    fn test_underscore_style_with_underscores() {
505        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
506        let content = "This is __strong text__ here.";
507        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508        let result = rule.check(&ctx).unwrap();
509
510        assert_eq!(result.len(), 0);
511    }
512
513    #[test]
514    fn test_underscore_style_with_asterisks() {
515        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
516        let content = "This is **strong text** here.";
517        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518        let result = rule.check(&ctx).unwrap();
519
520        assert_eq!(result.len(), 1);
521        assert!(
522            result[0]
523                .message
524                .contains("Strong emphasis should use __ instead of **")
525        );
526    }
527
528    #[test]
529    fn test_consistent_style_first_asterisk() {
530        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
531        let content = "First **strong** then __also strong__.";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533        let result = rule.check(&ctx).unwrap();
534
535        // First strong is **, so __ should be flagged
536        assert_eq!(result.len(), 1);
537        assert!(
538            result[0]
539                .message
540                .contains("Strong emphasis should use ** instead of __")
541        );
542    }
543
544    #[test]
545    fn test_consistent_style_tie_prefers_asterisk() {
546        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
547        let content = "First __strong__ then **also strong**.";
548        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549        let result = rule.check(&ctx).unwrap();
550
551        // Equal counts (1 vs 1), so prefer asterisks per CommonMark recommendation
552        // The __ should be flagged to change to **
553        assert_eq!(result.len(), 1);
554        assert!(
555            result[0]
556                .message
557                .contains("Strong emphasis should use ** instead of __")
558        );
559    }
560
561    #[test]
562    fn test_detect_style_asterisk() {
563        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
564        let ctx = LintContext::new(
565            "This has **strong** text.",
566            crate::config::MarkdownFlavor::Standard,
567            None,
568        );
569        let style = rule.detect_style(&ctx);
570
571        assert_eq!(style, Some(StrongStyle::Asterisk));
572    }
573
574    #[test]
575    fn test_detect_style_underscore() {
576        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
577        let ctx = LintContext::new(
578            "This has __strong__ text.",
579            crate::config::MarkdownFlavor::Standard,
580            None,
581        );
582        let style = rule.detect_style(&ctx);
583
584        assert_eq!(style, Some(StrongStyle::Underscore));
585    }
586
587    #[test]
588    fn test_detect_style_none() {
589        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
590        let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
591        let style = rule.detect_style(&ctx);
592
593        assert_eq!(style, None);
594    }
595
596    #[test]
597    fn test_strong_in_code_block() {
598        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
599        let content = "```\n__strong__ in code\n```\n__strong__ outside";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601        let result = rule.check(&ctx).unwrap();
602
603        // Only the strong outside code block should be flagged
604        assert_eq!(result.len(), 1);
605        assert_eq!(result[0].line, 4);
606    }
607
608    #[test]
609    fn test_strong_in_inline_code() {
610        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
611        let content = "Text with `__strong__` in code and __strong__ outside.";
612        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613        let result = rule.check(&ctx).unwrap();
614
615        // Only the strong outside inline code should be flagged
616        assert_eq!(result.len(), 1);
617    }
618
619    #[test]
620    fn test_escaped_strong() {
621        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
622        let content = "This is \\__not strong\\__ but __this is__.";
623        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624        let result = rule.check(&ctx).unwrap();
625
626        // Only the unescaped strong should be flagged
627        assert_eq!(result.len(), 1);
628        assert_eq!(result[0].line, 1);
629        assert_eq!(result[0].column, 30);
630    }
631
632    #[test]
633    fn test_fix_asterisks_to_underscores() {
634        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
635        let content = "This is **strong** text.";
636        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637        let fixed = rule.fix(&ctx).unwrap();
638
639        assert_eq!(fixed, "This is __strong__ text.");
640    }
641
642    #[test]
643    fn test_fix_underscores_to_asterisks() {
644        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
645        let content = "This is __strong__ text.";
646        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647        let fixed = rule.fix(&ctx).unwrap();
648
649        assert_eq!(fixed, "This is **strong** text.");
650    }
651
652    #[test]
653    fn test_fix_multiple_strong() {
654        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
655        let content = "First __strong__ and second __also strong__.";
656        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657        let fixed = rule.fix(&ctx).unwrap();
658
659        assert_eq!(fixed, "First **strong** and second **also strong**.");
660    }
661
662    #[test]
663    fn test_fix_preserves_code_blocks() {
664        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
665        let content = "```\n__strong__ in code\n```\n__strong__ outside";
666        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667        let fixed = rule.fix(&ctx).unwrap();
668
669        assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
670    }
671
672    #[test]
673    fn test_multiline_content() {
674        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
675        let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
676        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677        let result = rule.check(&ctx).unwrap();
678
679        assert_eq!(result.len(), 2);
680        assert_eq!(result[0].line, 1);
681        assert_eq!(result[1].line, 2);
682    }
683
684    #[test]
685    fn test_nested_emphasis() {
686        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
687        let content = "This has __strong with *emphasis* inside__.";
688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689        let result = rule.check(&ctx).unwrap();
690
691        assert_eq!(result.len(), 1);
692    }
693
694    #[test]
695    fn test_empty_content() {
696        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
697        let content = "";
698        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699        let result = rule.check(&ctx).unwrap();
700
701        assert_eq!(result.len(), 0);
702    }
703
704    #[test]
705    fn test_default_config() {
706        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
707        let (name, _config) = rule.default_config_section().unwrap();
708        assert_eq!(name, "MD050");
709    }
710
711    #[test]
712    fn test_strong_in_links_not_flagged() {
713        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
714        let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
715
716Hint:
717
718- [An article on something](https://blog.yuo.be/2018/08/16/__init_subclass__-a-simpler-way-to-implement-class-registries-in-python/ "Some details on using `__init_subclass__`")
719
720
721[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
722        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723        let result = rule.check(&ctx).unwrap();
724
725        // None of the __ patterns in links should be flagged
726        assert_eq!(result.len(), 0);
727    }
728
729    #[test]
730    fn test_strong_in_links_vs_outside_links() {
731        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
732        let content = r#"We're doing this because generator functions return a generator object which [is an iterator][generators are iterators] and **we need `__iter__` to return an [iterator][]**.
733
734Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
735
736This is __real strong text__ that should be flagged.
737
738[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
739        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740        let result = rule.check(&ctx).unwrap();
741
742        // Only the real strong text should be flagged, not the __ in links
743        assert_eq!(result.len(), 1);
744        assert!(
745            result[0]
746                .message
747                .contains("Strong emphasis should use ** instead of __")
748        );
749        // The flagged text should be "real strong text"
750        assert!(result[0].line > 4); // Should be on the line with "real strong text"
751    }
752
753    #[test]
754    fn test_front_matter_not_flagged() {
755        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
756        let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
757        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758        let result = rule.check(&ctx).unwrap();
759
760        // Only the strong text outside front matter should be flagged
761        assert_eq!(result.len(), 1);
762        assert_eq!(result[0].line, 6);
763        assert!(
764            result[0]
765                .message
766                .contains("Strong emphasis should use ** instead of __")
767        );
768    }
769
770    #[test]
771    fn test_html_tags_not_flagged() {
772        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
773        let content = r#"# Test
774
775This has HTML with underscores:
776
777<iframe src="https://example.com/__init__/__repr__"> </iframe>
778
779This __should be flagged__ as inconsistent."#;
780        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781        let result = rule.check(&ctx).unwrap();
782
783        // Only the strong text outside HTML tags should be flagged
784        assert_eq!(result.len(), 1);
785        assert_eq!(result[0].line, 7);
786        assert!(
787            result[0]
788                .message
789                .contains("Strong emphasis should use ** instead of __")
790        );
791    }
792
793    #[test]
794    fn test_mkdocs_keys_notation_not_flagged() {
795        // Keys notation uses ++ which shouldn't be flagged as strong emphasis
796        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
797        let content = "Press ++ctrl+alt+del++ to restart.";
798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
799        let result = rule.check(&ctx).unwrap();
800
801        // Keys notation should not be flagged as strong emphasis
802        assert!(
803            result.is_empty(),
804            "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
805        );
806    }
807
808    #[test]
809    fn test_mkdocs_caret_notation_not_flagged() {
810        // Insert notation (^^text^^) should not be flagged as strong emphasis
811        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
812        let content = "This is ^^inserted^^ text.";
813        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
814        let result = rule.check(&ctx).unwrap();
815
816        assert!(
817            result.is_empty(),
818            "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
819        );
820    }
821
822    #[test]
823    fn test_mkdocs_mark_notation_not_flagged() {
824        // Mark notation (==highlight==) should not be flagged
825        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
826        let content = "This is ==highlighted== text.";
827        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
828        let result = rule.check(&ctx).unwrap();
829
830        assert!(
831            result.is_empty(),
832            "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
833        );
834    }
835
836    #[test]
837    fn test_mkdocs_mixed_content_with_real_strong() {
838        // Mixed content: MkDocs markup + real strong emphasis that should be flagged
839        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
840        let content = "Press ++ctrl++ and __underscore strong__ here.";
841        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
842        let result = rule.check(&ctx).unwrap();
843
844        // Only the real underscore strong should be flagged (not Keys notation)
845        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
846        assert!(
847            result[0]
848                .message
849                .contains("Strong emphasis should use ** instead of __")
850        );
851    }
852
853    #[test]
854    fn test_mkdocs_icon_shortcode_not_flagged() {
855        // Icon shortcodes like :material-star: should not affect strong detection
856        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
857        let content = "Click :material-check: and __this should be flagged__.";
858        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
859        let result = rule.check(&ctx).unwrap();
860
861        // The underscore strong should still be flagged
862        assert_eq!(result.len(), 1);
863        assert!(
864            result[0]
865                .message
866                .contains("Strong emphasis should use ** instead of __")
867        );
868    }
869
870    #[test]
871    fn test_math_block_not_flagged() {
872        // Math blocks contain _ and * characters that are not emphasis
873        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
874        let content = r#"# Math Section
875
876$$
877E = mc^2
878x_1 + x_2 = y
879a**b = c
880$$
881
882This __should be flagged__ outside math.
883"#;
884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
885        let result = rule.check(&ctx).unwrap();
886
887        // Only the strong outside math block should be flagged
888        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
889        assert!(result[0].line > 7, "Warning should be on line after math block");
890    }
891
892    #[test]
893    fn test_math_block_with_underscores_not_flagged() {
894        // LaTeX subscripts use underscores that shouldn't be flagged
895        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
896        let content = r#"$$
897x_1 + x_2 + x__3 = y
898\alpha__\beta
899$$
900"#;
901        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
902        let result = rule.check(&ctx).unwrap();
903
904        // Nothing should be flagged - all content is in math block
905        assert!(
906            result.is_empty(),
907            "Math block content should not be flagged. Got: {result:?}"
908        );
909    }
910
911    #[test]
912    fn test_math_block_with_asterisks_not_flagged() {
913        // LaTeX multiplication uses asterisks that shouldn't be flagged
914        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
915        let content = r#"$$
916a**b = c
9172 ** 3 = 8
918x***y
919$$
920"#;
921        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
922        let result = rule.check(&ctx).unwrap();
923
924        // Nothing should be flagged - all content is in math block
925        assert!(
926            result.is_empty(),
927            "Math block content should not be flagged. Got: {result:?}"
928        );
929    }
930
931    #[test]
932    fn test_math_block_fix_preserves_content() {
933        // Fix should not modify content inside math blocks
934        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
935        let content = r#"$$
936x__y = z
937$$
938
939This __word__ should change.
940"#;
941        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
942        let fixed = rule.fix(&ctx).unwrap();
943
944        // Math block content should be unchanged
945        assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
946        // Strong outside should be fixed
947        assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
948    }
949
950    #[test]
951    fn test_inline_math_simple() {
952        // Simple inline math without underscore patterns that could be confused with strong
953        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
954        let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
955        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
956        let result = rule.check(&ctx).unwrap();
957
958        // __this__ should be flagged (it's outside the inline math)
959        assert_eq!(
960            result.len(),
961            1,
962            "Expected 1 warning for strong outside math. Got: {result:?}"
963        );
964    }
965
966    #[test]
967    fn test_multiple_math_blocks_and_strong() {
968        // Test with multiple math blocks and strong emphasis between them
969        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
970        let content = r#"# Document
971
972$$
973a = b
974$$
975
976This __should be flagged__ text.
977
978$$
979c = d
980$$
981"#;
982        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
983        let result = rule.check(&ctx).unwrap();
984
985        // Only the strong between math blocks should be flagged
986        assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
987        assert!(result[0].message.contains("**"));
988    }
989
990    #[test]
991    fn test_html_tag_skip_consistency_between_check_and_fix() {
992        // Verify that check() and fix() share the same HTML tag boundary logic,
993        // so double underscores inside HTML attributes are skipped consistently.
994        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
995
996        let content = r#"<a href="__test__">link</a>
997
998This __should be flagged__ text."#;
999        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000
1001        let check_result = rule.check(&ctx).unwrap();
1002        let fix_result = rule.fix(&ctx).unwrap();
1003
1004        // Only the __should be flagged__ outside the HTML tag should be flagged
1005        assert_eq!(
1006            check_result.len(),
1007            1,
1008            "check() should flag exactly one emphasis outside HTML tags"
1009        );
1010        assert!(check_result[0].message.contains("**"));
1011
1012        // fix() should only transform the same emphasis that check() flagged
1013        assert!(
1014            fix_result.contains("**should be flagged**"),
1015            "fix() should convert the flagged emphasis"
1016        );
1017        assert!(
1018            fix_result.contains("__test__"),
1019            "fix() should not modify emphasis inside HTML tags"
1020        );
1021    }
1022
1023    #[test]
1024    fn test_detect_style_ignores_emphasis_in_inline_code_on_table_lines() {
1025        // In Consistent mode, detect_style() should not count emphasis markers
1026        // inside inline code spans on table cell lines, matching check() and fix().
1027        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
1028
1029        // The only real emphasis is **real** (asterisks). The __code__ inside
1030        // backtick code spans should be ignored by detect_style().
1031        let content = "| `__code__` | **real** |\n| --- | --- |\n| data | data |";
1032        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033
1034        let style = rule.detect_style(&ctx);
1035        // Should detect asterisk as the dominant style (underscore inside code is skipped)
1036        assert_eq!(style, Some(StrongStyle::Asterisk));
1037    }
1038}