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