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