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::{compute_html_code_ranges, should_skip_emphasis_span};
7
8/// Convert a StrongSpanDetail to a StrongStyle
9fn span_style(span: &StrongSpanDetail) -> StrongStyle {
10    if span.is_asterisk {
11        StrongStyle::Asterisk
12    } else {
13        StrongStyle::Underscore
14    }
15}
16
17mod md050_config;
18use md050_config::MD050Config;
19
20/// Rule MD050: Strong style
21///
22/// See [docs/md050.md](../../docs/md050.md) for full documentation, configuration, and examples.
23///
24/// This rule is triggered when strong markers (** or __) are used in an inconsistent way.
25#[derive(Debug, Default, Clone)]
26pub struct MD050StrongStyle {
27    config: MD050Config,
28}
29
30impl MD050StrongStyle {
31    pub fn new(style: StrongStyle) -> Self {
32        Self {
33            config: MD050Config { style },
34        }
35    }
36
37    pub fn from_config_struct(config: MD050Config) -> Self {
38        Self { config }
39    }
40
41    #[cfg(test)]
42    fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
43        let html_tags = ctx.html_tags();
44        let html_code_ranges = compute_html_code_ranges(&html_tags);
45        self.detect_style_from_spans(ctx, &html_tags, &html_code_ranges, &ctx.strong_spans)
46    }
47
48    fn detect_style_from_spans(
49        &self,
50        ctx: &crate::lint_context::LintContext,
51        html_tags: &[crate::lint_context::HtmlTag],
52        html_code_ranges: &[(usize, usize)],
53        spans: &[StrongSpanDetail],
54    ) -> Option<StrongStyle> {
55        let mut asterisk_count = 0;
56        let mut underscore_count = 0;
57
58        for span in spans {
59            if should_skip_emphasis_span(ctx, html_tags, html_code_ranges, span.start) {
60                continue;
61            }
62
63            match span_style(span) {
64                StrongStyle::Asterisk => asterisk_count += 1,
65                StrongStyle::Underscore => underscore_count += 1,
66                StrongStyle::Consistent => {}
67            }
68        }
69
70        match (asterisk_count, underscore_count) {
71            (0, 0) => None,
72            (_, 0) => Some(StrongStyle::Asterisk),
73            (0, _) => Some(StrongStyle::Underscore),
74            // In case of a tie, prefer asterisk (matches CommonMark recommendation)
75            (a, u) => {
76                if a >= u {
77                    Some(StrongStyle::Asterisk)
78                } else {
79                    Some(StrongStyle::Underscore)
80                }
81            }
82        }
83    }
84}
85
86impl Rule for MD050StrongStyle {
87    fn name(&self) -> &'static str {
88        "MD050"
89    }
90
91    fn description(&self) -> &'static str {
92        "Strong emphasis style should be consistent"
93    }
94
95    fn category(&self) -> RuleCategory {
96        RuleCategory::Emphasis
97    }
98
99    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
100        let content = ctx.content;
101        let lines = ctx.raw_lines();
102
103        let mut warnings = Vec::new();
104
105        let spans = &ctx.strong_spans;
106        let html_tags = ctx.html_tags();
107        let html_code_ranges = compute_html_code_ranges(&html_tags);
108
109        let target_style = match self.config.style {
110            StrongStyle::Consistent => self
111                .detect_style_from_spans(ctx, &html_tags, &html_code_ranges, spans)
112                .unwrap_or(StrongStyle::Asterisk),
113            _ => self.config.style,
114        };
115
116        for span in spans {
117            // Only flag spans that use the wrong style
118            if span_style(span) == target_style {
119                continue;
120            }
121
122            // Skip too-short spans
123            if span.end - span.start < 4 {
124                continue;
125            }
126
127            // Only check skip context for wrong-style spans (the minority)
128            if should_skip_emphasis_span(ctx, &html_tags, &html_code_ranges, span.start) {
129                continue;
130            }
131
132            let (line_num, _col) = ctx.offset_to_line_col(span.start);
133            let line_start = ctx.line_start_byte(line_num).unwrap_or(0);
134            let line_content = lines.get(line_num - 1).unwrap_or(&"");
135            let match_start_in_line = span.start - line_start;
136            let match_len = span.end - span.start;
137
138            let inner_text = &content[span.start + 2..span.end - 2];
139
140            // NOTE: Intentional deviation from markdownlint behavior.
141            // markdownlint reports two warnings per emphasis (one for opening marker,
142            // one for closing marker). We report one warning per emphasis block because:
143            // 1. The markers are semantically one unit - you can't fix one without the other
144            // 2. Cleaner output - "10 issues" vs "20 issues" for 10 bold words
145            // 3. The fix is atomic - replacing the entire emphasis at once
146            let message = match target_style {
147                StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
148                StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
149                StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
150            };
151
152            let (start_line, start_col, end_line, end_col) =
153                calculate_match_range(line_num, line_content, match_start_in_line, match_len);
154
155            warnings.push(LintWarning {
156                rule_name: Some(self.name().to_string()),
157                line: start_line,
158                column: start_col,
159                end_line,
160                end_column: end_col,
161                message: message.to_string(),
162                severity: Severity::Warning,
163                fix: Some(Fix::new(
164                    span.start..span.end,
165                    match target_style {
166                        StrongStyle::Asterisk => format!("**{inner_text}**"),
167                        StrongStyle::Underscore => format!("__{inner_text}__"),
168                        StrongStyle::Consistent => format!("**{inner_text}**"),
169                    },
170                )),
171            });
172        }
173
174        Ok(warnings)
175    }
176
177    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
178        if self.should_skip(ctx) {
179            return Ok(ctx.content.to_string());
180        }
181        let warnings = self.check(ctx)?;
182        if warnings.is_empty() {
183            return Ok(ctx.content.to_string());
184        }
185        let warnings =
186            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
187        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
188            .map_err(crate::rule::LintError::InvalidInput)
189    }
190
191    /// Check if this rule should be skipped
192    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
193        // Strong uses double markers, but likely_has_emphasis checks for count > 1
194        ctx.content.is_empty() || !ctx.likely_has_emphasis()
195    }
196
197    fn as_any(&self) -> &dyn std::any::Any {
198        self
199    }
200
201    crate::impl_rule_config_methods!(MD050Config);
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::lint_context::LintContext;
208
209    #[test]
210    fn test_asterisk_style_with_asterisks() {
211        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
212        let content = "This is **strong text** here.";
213        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
214        let result = rule.check(&ctx).unwrap();
215
216        assert_eq!(result.len(), 0);
217    }
218
219    #[test]
220    fn test_asterisk_style_with_underscores() {
221        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
222        let content = "This is __strong text__ here.";
223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
224        let result = rule.check(&ctx).unwrap();
225
226        assert_eq!(result.len(), 1);
227        assert!(
228            result[0]
229                .message
230                .contains("Strong emphasis should use ** instead of __")
231        );
232        assert_eq!(result[0].line, 1);
233        assert_eq!(result[0].column, 9);
234    }
235
236    #[test]
237    fn test_underscore_style_with_underscores() {
238        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
239        let content = "This is __strong text__ here.";
240        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
241        let result = rule.check(&ctx).unwrap();
242
243        assert_eq!(result.len(), 0);
244    }
245
246    #[test]
247    fn test_underscore_style_with_asterisks() {
248        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
249        let content = "This is **strong text** here.";
250        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
251        let result = rule.check(&ctx).unwrap();
252
253        assert_eq!(result.len(), 1);
254        assert!(
255            result[0]
256                .message
257                .contains("Strong emphasis should use __ instead of **")
258        );
259    }
260
261    #[test]
262    fn test_consistent_style_first_asterisk() {
263        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
264        let content = "First **strong** then __also strong__.";
265        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
266        let result = rule.check(&ctx).unwrap();
267
268        // First strong is **, so __ should be flagged
269        assert_eq!(result.len(), 1);
270        assert!(
271            result[0]
272                .message
273                .contains("Strong emphasis should use ** instead of __")
274        );
275    }
276
277    #[test]
278    fn test_consistent_style_tie_prefers_asterisk() {
279        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
280        let content = "First __strong__ then **also strong**.";
281        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
282        let result = rule.check(&ctx).unwrap();
283
284        // Equal counts (1 vs 1), so prefer asterisks per CommonMark recommendation
285        // The __ should be flagged to change to **
286        assert_eq!(result.len(), 1);
287        assert!(
288            result[0]
289                .message
290                .contains("Strong emphasis should use ** instead of __")
291        );
292    }
293
294    #[test]
295    fn test_detect_style_asterisk() {
296        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
297        let ctx = LintContext::new(
298            "This has **strong** text.",
299            crate::config::MarkdownFlavor::Standard,
300            None,
301        );
302        let style = rule.detect_style(&ctx);
303
304        assert_eq!(style, Some(StrongStyle::Asterisk));
305    }
306
307    #[test]
308    fn test_detect_style_underscore() {
309        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
310        let ctx = LintContext::new(
311            "This has __strong__ text.",
312            crate::config::MarkdownFlavor::Standard,
313            None,
314        );
315        let style = rule.detect_style(&ctx);
316
317        assert_eq!(style, Some(StrongStyle::Underscore));
318    }
319
320    #[test]
321    fn test_detect_style_none() {
322        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
323        let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
324        let style = rule.detect_style(&ctx);
325
326        assert_eq!(style, None);
327    }
328
329    #[test]
330    fn test_strong_in_code_block() {
331        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
332        let content = "```\n__strong__ in code\n```\n__strong__ outside";
333        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
334        let result = rule.check(&ctx).unwrap();
335
336        // Only the strong outside code block should be flagged
337        assert_eq!(result.len(), 1);
338        assert_eq!(result[0].line, 4);
339    }
340
341    #[test]
342    fn test_strong_in_inline_code() {
343        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
344        let content = "Text with `__strong__` in code and __strong__ outside.";
345        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
346        let result = rule.check(&ctx).unwrap();
347
348        // Only the strong outside inline code should be flagged
349        assert_eq!(result.len(), 1);
350    }
351
352    #[test]
353    fn test_escaped_strong() {
354        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
355        let content = "This is \\__not strong\\__ but __this is__.";
356        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357        let result = rule.check(&ctx).unwrap();
358
359        // Only the unescaped strong should be flagged
360        assert_eq!(result.len(), 1);
361        assert_eq!(result[0].line, 1);
362        assert_eq!(result[0].column, 30);
363    }
364
365    #[test]
366    fn test_fix_asterisks_to_underscores() {
367        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
368        let content = "This is **strong** text.";
369        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
370        let fixed = rule.fix(&ctx).unwrap();
371
372        assert_eq!(fixed, "This is __strong__ text.");
373    }
374
375    #[test]
376    fn test_fix_underscores_to_asterisks() {
377        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
378        let content = "This is __strong__ text.";
379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380        let fixed = rule.fix(&ctx).unwrap();
381
382        assert_eq!(fixed, "This is **strong** text.");
383    }
384
385    #[test]
386    fn test_fix_multiple_strong() {
387        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
388        let content = "First __strong__ and second __also strong__.";
389        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390        let fixed = rule.fix(&ctx).unwrap();
391
392        assert_eq!(fixed, "First **strong** and second **also strong**.");
393    }
394
395    #[test]
396    fn test_fix_preserves_code_blocks() {
397        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
398        let content = "```\n__strong__ in code\n```\n__strong__ outside";
399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400        let fixed = rule.fix(&ctx).unwrap();
401
402        assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
403    }
404
405    #[test]
406    fn test_multiline_content() {
407        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
408        let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410        let result = rule.check(&ctx).unwrap();
411
412        assert_eq!(result.len(), 2);
413        assert_eq!(result[0].line, 1);
414        assert_eq!(result[1].line, 2);
415    }
416
417    #[test]
418    fn test_nested_emphasis() {
419        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
420        let content = "This has __strong with *emphasis* inside__.";
421        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422        let result = rule.check(&ctx).unwrap();
423
424        assert_eq!(result.len(), 1);
425    }
426
427    #[test]
428    fn test_empty_content() {
429        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
430        let content = "";
431        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
432        let result = rule.check(&ctx).unwrap();
433
434        assert_eq!(result.len(), 0);
435    }
436
437    #[test]
438    fn test_default_config() {
439        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
440        let (name, _config) = rule.default_config_section().unwrap();
441        assert_eq!(name, "MD050");
442    }
443
444    #[test]
445    fn test_strong_in_links_not_flagged() {
446        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
447        let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
448
449Hint:
450
451- [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__`")
452
453
454[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456        let result = rule.check(&ctx).unwrap();
457
458        // None of the __ patterns in links should be flagged
459        assert_eq!(result.len(), 0);
460    }
461
462    #[test]
463    fn test_strong_in_links_vs_outside_links() {
464        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
465        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][]**.
466
467Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
468
469This is __real strong text__ that should be flagged.
470
471[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473        let result = rule.check(&ctx).unwrap();
474
475        // Only the real strong text should be flagged, not the __ in links
476        assert_eq!(result.len(), 1);
477        assert!(
478            result[0]
479                .message
480                .contains("Strong emphasis should use ** instead of __")
481        );
482        // The flagged text should be "real strong text"
483        assert!(result[0].line > 4); // Should be on the line with "real strong text"
484    }
485
486    #[test]
487    fn test_front_matter_not_flagged() {
488        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
489        let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491        let result = rule.check(&ctx).unwrap();
492
493        // Only the strong text outside front matter should be flagged
494        assert_eq!(result.len(), 1);
495        assert_eq!(result[0].line, 6);
496        assert!(
497            result[0]
498                .message
499                .contains("Strong emphasis should use ** instead of __")
500        );
501    }
502
503    #[test]
504    fn test_html_tags_not_flagged() {
505        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
506        let content = r#"# Test
507
508This has HTML with underscores:
509
510<iframe src="https://example.com/__init__/__repr__"> </iframe>
511
512This __should be flagged__ as inconsistent."#;
513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514        let result = rule.check(&ctx).unwrap();
515
516        // Only the strong text outside HTML tags should be flagged
517        assert_eq!(result.len(), 1);
518        assert_eq!(result[0].line, 7);
519        assert!(
520            result[0]
521                .message
522                .contains("Strong emphasis should use ** instead of __")
523        );
524    }
525
526    #[test]
527    fn test_mkdocs_keys_notation_not_flagged() {
528        // Keys notation uses ++ which shouldn't be flagged as strong emphasis
529        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
530        let content = "Press ++ctrl+alt+del++ to restart.";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
532        let result = rule.check(&ctx).unwrap();
533
534        // Keys notation should not be flagged as strong emphasis
535        assert!(
536            result.is_empty(),
537            "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
538        );
539    }
540
541    #[test]
542    fn test_mkdocs_caret_notation_not_flagged() {
543        // Insert notation (^^text^^) should not be flagged as strong emphasis
544        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
545        let content = "This is ^^inserted^^ text.";
546        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
547        let result = rule.check(&ctx).unwrap();
548
549        assert!(
550            result.is_empty(),
551            "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
552        );
553    }
554
555    #[test]
556    fn test_mkdocs_mark_notation_not_flagged() {
557        // Mark notation (==highlight==) should not be flagged
558        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
559        let content = "This is ==highlighted== text.";
560        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
561        let result = rule.check(&ctx).unwrap();
562
563        assert!(
564            result.is_empty(),
565            "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
566        );
567    }
568
569    #[test]
570    fn test_mkdocs_mixed_content_with_real_strong() {
571        // Mixed content: MkDocs markup + real strong emphasis that should be flagged
572        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
573        let content = "Press ++ctrl++ and __underscore strong__ here.";
574        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
575        let result = rule.check(&ctx).unwrap();
576
577        // Only the real underscore strong should be flagged (not Keys notation)
578        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
579        assert!(
580            result[0]
581                .message
582                .contains("Strong emphasis should use ** instead of __")
583        );
584    }
585
586    #[test]
587    fn test_mkdocs_icon_shortcode_not_flagged() {
588        // Icon shortcodes like :material-star: should not affect strong detection
589        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
590        let content = "Click :material-check: and __this should be flagged__.";
591        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
592        let result = rule.check(&ctx).unwrap();
593
594        // The underscore strong should still be flagged
595        assert_eq!(result.len(), 1);
596        assert!(
597            result[0]
598                .message
599                .contains("Strong emphasis should use ** instead of __")
600        );
601    }
602
603    #[test]
604    fn test_math_block_not_flagged() {
605        // Math blocks contain _ and * characters that are not emphasis
606        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
607        let content = r#"# Math Section
608
609$$
610E = mc^2
611x_1 + x_2 = y
612a**b = c
613$$
614
615This __should be flagged__ outside math.
616"#;
617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
618        let result = rule.check(&ctx).unwrap();
619
620        // Only the strong outside math block should be flagged
621        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
622        assert!(result[0].line > 7, "Warning should be on line after math block");
623    }
624
625    #[test]
626    fn test_math_block_with_underscores_not_flagged() {
627        // LaTeX subscripts use underscores that shouldn't be flagged
628        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
629        let content = r#"$$
630x_1 + x_2 + x__3 = y
631\alpha__\beta
632$$
633"#;
634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
635        let result = rule.check(&ctx).unwrap();
636
637        // Nothing should be flagged - all content is in math block
638        assert!(
639            result.is_empty(),
640            "Math block content should not be flagged. Got: {result:?}"
641        );
642    }
643
644    #[test]
645    fn test_math_block_with_asterisks_not_flagged() {
646        // LaTeX multiplication uses asterisks that shouldn't be flagged
647        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
648        let content = r#"$$
649a**b = c
6502 ** 3 = 8
651x***y
652$$
653"#;
654        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
655        let result = rule.check(&ctx).unwrap();
656
657        // Nothing should be flagged - all content is in math block
658        assert!(
659            result.is_empty(),
660            "Math block content should not be flagged. Got: {result:?}"
661        );
662    }
663
664    #[test]
665    fn test_math_block_fix_preserves_content() {
666        // Fix should not modify content inside math blocks
667        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
668        let content = r#"$$
669x__y = z
670$$
671
672This __word__ should change.
673"#;
674        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
675        let fixed = rule.fix(&ctx).unwrap();
676
677        // Math block content should be unchanged
678        assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
679        // Strong outside should be fixed
680        assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
681    }
682
683    #[test]
684    fn test_inline_math_simple() {
685        // Simple inline math without underscore patterns that could be confused with strong
686        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
687        let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
689        let result = rule.check(&ctx).unwrap();
690
691        // __this__ should be flagged (it's outside the inline math)
692        assert_eq!(
693            result.len(),
694            1,
695            "Expected 1 warning for strong outside math. Got: {result:?}"
696        );
697    }
698
699    #[test]
700    fn test_multiple_math_blocks_and_strong() {
701        // Test with multiple math blocks and strong emphasis between them
702        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
703        let content = r#"# Document
704
705$$
706a = b
707$$
708
709This __should be flagged__ text.
710
711$$
712c = d
713$$
714"#;
715        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
716        let result = rule.check(&ctx).unwrap();
717
718        // Only the strong between math blocks should be flagged
719        assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
720        assert!(result[0].message.contains("**"));
721    }
722
723    #[test]
724    fn test_html_tag_skip_consistency_between_check_and_fix() {
725        // Verify that check() and fix() share the same HTML tag boundary logic,
726        // so double underscores inside HTML attributes are skipped consistently.
727        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
728
729        let content = r#"<a href="__test__">link</a>
730
731This __should be flagged__ text."#;
732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733
734        let check_result = rule.check(&ctx).unwrap();
735        let fix_result = rule.fix(&ctx).unwrap();
736
737        // Only the __should be flagged__ outside the HTML tag should be flagged
738        assert_eq!(
739            check_result.len(),
740            1,
741            "check() should flag exactly one emphasis outside HTML tags"
742        );
743        assert!(check_result[0].message.contains("**"));
744
745        // fix() should only transform the same emphasis that check() flagged
746        assert!(
747            fix_result.contains("**should be flagged**"),
748            "fix() should convert the flagged emphasis"
749        );
750        assert!(
751            fix_result.contains("__test__"),
752            "fix() should not modify emphasis inside HTML tags"
753        );
754    }
755
756    #[test]
757    fn test_detect_style_ignores_emphasis_in_inline_code_on_table_lines() {
758        // In Consistent mode, detect_style() should not count emphasis markers
759        // inside inline code spans on table cell lines, matching check() and fix().
760        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
761
762        // The only real emphasis is **real** (asterisks). The __code__ inside
763        // backtick code spans should be ignored by detect_style().
764        let content = "| `__code__` | **real** |\n| --- | --- |\n| data | data |";
765        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766
767        let style = rule.detect_style(&ctx);
768        // Should detect asterisk as the dominant style (underscore inside code is skipped)
769        assert_eq!(style, Some(StrongStyle::Asterisk));
770    }
771
772    #[test]
773    fn test_five_underscores_not_flagged() {
774        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
775        let content = "This is a series of underscores: _____";
776        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777        let result = rule.check(&ctx).unwrap();
778        assert!(
779            result.is_empty(),
780            "_____ should not be flagged as strong emphasis. Got: {result:?}"
781        );
782    }
783
784    #[test]
785    fn test_five_asterisks_not_flagged() {
786        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
787        let content = "This is a series of asterisks: *****";
788        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789        let result = rule.check(&ctx).unwrap();
790        assert!(
791            result.is_empty(),
792            "***** should not be flagged as strong emphasis. Got: {result:?}"
793        );
794    }
795
796    #[test]
797    fn test_five_underscores_with_frontmatter_not_flagged() {
798        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
799        let content = "---\ntitle: Level 1 heading\n---\n\nThis is a series of underscores: _____\n";
800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801        let result = rule.check(&ctx).unwrap();
802        assert!(result.is_empty(), "_____ should not be flagged. Got: {result:?}");
803    }
804
805    #[test]
806    fn test_four_underscores_not_flagged() {
807        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
808        let content = "This is: ____";
809        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810        let result = rule.check(&ctx).unwrap();
811        assert!(result.is_empty(), "____ should not be flagged. Got: {result:?}");
812    }
813
814    #[test]
815    fn test_four_asterisks_not_flagged() {
816        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
817        let content = "This is: ****";
818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819        let result = rule.check(&ctx).unwrap();
820        assert!(result.is_empty(), "**** should not be flagged. Got: {result:?}");
821    }
822
823    #[test]
824    fn test_detect_style_ignores_underscore_sequences() {
825        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
826        let content = "This is: _____ and also **real bold**";
827        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828        let style = rule.detect_style(&ctx);
829        assert_eq!(style, Some(StrongStyle::Asterisk));
830    }
831
832    #[test]
833    fn test_fix_does_not_modify_underscore_sequences() {
834        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
835        let content = "Some _____ sequence and __real bold__ text.";
836        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837        let fixed = rule.fix(&ctx).unwrap();
838        assert!(fixed.contains("_____"), "_____ should be preserved");
839        assert!(fixed.contains("**real bold**"), "Real bold should be converted");
840    }
841
842    #[test]
843    fn test_six_or_more_consecutive_markers_not_flagged() {
844        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
845        for count in [6, 7, 8, 10] {
846            let underscores = "_".repeat(count);
847            let asterisks = "*".repeat(count);
848            let content_u = format!("Text with {underscores} here");
849            let content_a = format!("Text with {asterisks} here");
850
851            let ctx_u = LintContext::new(&content_u, crate::config::MarkdownFlavor::Standard, None);
852            let ctx_a = LintContext::new(&content_a, crate::config::MarkdownFlavor::Standard, None);
853
854            let result_u = rule.check(&ctx_u).unwrap();
855            let result_a = rule.check(&ctx_a).unwrap();
856
857            assert!(
858                result_u.is_empty(),
859                "{count} underscores should not be flagged. Got: {result_u:?}"
860            );
861            assert!(
862                result_a.is_empty(),
863                "{count} asterisks should not be flagged. Got: {result_a:?}"
864            );
865        }
866    }
867
868    #[test]
869    fn test_mkdocstrings_block_not_flagged() {
870        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
871        let content = "# Example\n\nWe have here some **bold text**.\n\n::: my_module.MyClass\n    options:\n      members:\n        - __init__\n";
872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
873        let result = rule.check(&ctx).unwrap();
874
875        assert!(
876            result.is_empty(),
877            "__init__ inside mkdocstrings block should not be flagged. Got: {result:?}"
878        );
879    }
880
881    #[test]
882    fn test_mkdocstrings_block_fix_preserves_content() {
883        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
884        let content = "# Example\n\nWe have here some **bold text**.\n\n::: my_module.MyClass\n    options:\n      members:\n        - __init__\n        - __repr__\n";
885        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
886        let fixed = rule.fix(&ctx).unwrap();
887
888        assert!(
889            fixed.contains("__init__"),
890            "__init__ in mkdocstrings block should be preserved"
891        );
892        assert!(
893            fixed.contains("__repr__"),
894            "__repr__ in mkdocstrings block should be preserved"
895        );
896        assert!(fixed.contains("**bold text**"), "Real bold text should be unchanged");
897    }
898
899    #[test]
900    fn test_mkdocstrings_block_with_strong_outside() {
901        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
902        let content = "::: my_module.MyClass\n    options:\n      members:\n        - __init__\n\nThis __should be flagged__ outside.\n";
903        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
904        let result = rule.check(&ctx).unwrap();
905
906        assert_eq!(
907            result.len(),
908            1,
909            "Only strong outside mkdocstrings should be flagged. Got: {result:?}"
910        );
911        assert_eq!(result[0].line, 6);
912    }
913
914    #[test]
915    fn test_thematic_break_not_flagged() {
916        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
917        let content = "Before\n\n*****\n\nAfter";
918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919        let result = rule.check(&ctx).unwrap();
920        assert!(
921            result.is_empty(),
922            "Thematic break (*****) should not be flagged. Got: {result:?}"
923        );
924
925        let content2 = "Before\n\n_____\n\nAfter";
926        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
927        let result2 = rule.check(&ctx2).unwrap();
928        assert!(
929            result2.is_empty(),
930            "Thematic break (_____) should not be flagged. Got: {result2:?}"
931        );
932    }
933}