Skip to main content

rumdl_lib/rules/
md050_strong_style.rs

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