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