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