Skip to main content

rumdl_lib/rules/
md050_strong_style.rs

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