Skip to main content

rumdl_lib/rules/
md050_strong_style.rs

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