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