Skip to main content

rumdl_lib/rules/
md050_strong_style.rs

1use crate::utils::range_utils::calculate_match_range;
2use pulldown_cmark::{Event, Options, Parser, Tag};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
5use crate::rules::strong_style::StrongStyle;
6use crate::utils::skip_context::{is_in_jsx_expression, is_in_math_context, is_in_mdx_comment, is_in_mkdocs_markup};
7
8/// Check if a byte position within a line is inside a backtick-delimited code span.
9/// This is a line-level fallback for cases where pulldown-cmark's code span detection
10/// misses spans due to table parsing interference (e.g., pipes inside code spans
11/// in table rows cause pulldown-cmark to misidentify cell boundaries).
12fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
13    let bytes = line.as_bytes();
14    let mut i = 0;
15
16    while i < bytes.len() {
17        if bytes[i] == b'`' {
18            let open_start = i;
19            let mut backtick_count = 0;
20            while i < bytes.len() && bytes[i] == b'`' {
21                backtick_count += 1;
22                i += 1;
23            }
24
25            // Search for matching closing backticks
26            let mut j = i;
27            while j < bytes.len() {
28                if bytes[j] == b'`' {
29                    let mut close_count = 0;
30                    while j < bytes.len() && bytes[j] == b'`' {
31                        close_count += 1;
32                        j += 1;
33                    }
34                    if close_count == backtick_count {
35                        // Found matching pair: code span covers open_start..j
36                        if byte_pos >= open_start && byte_pos < j {
37                            return true;
38                        }
39                        i = j;
40                        break;
41                    }
42                } else {
43                    j += 1;
44                }
45            }
46
47            if j >= bytes.len() {
48                // No matching close found, remaining text is not a code span
49                break;
50            }
51        } else {
52            i += 1;
53        }
54    }
55
56    false
57}
58
59/// A strong emphasis span found by pulldown-cmark
60struct StrongSpan {
61    /// Byte range in source covering **text** or __text__ including markers
62    range: std::ops::Range<usize>,
63    /// Whether this uses asterisk (**) or underscore (__) markers
64    style: StrongStyle,
65}
66
67/// Find all genuine strong emphasis spans using pulldown-cmark's parser.
68/// Correctly identifies that sequences like `_____` or `*****` are NOT
69/// strong emphasis.
70fn find_strong_spans(content: &str) -> Vec<StrongSpan> {
71    let parser = Parser::new_ext(content, Options::all());
72    let mut spans = Vec::new();
73
74    for (event, range) in parser.into_offset_iter() {
75        if let Event::Start(Tag::Strong) = event
76            && range.start + 2 <= content.len()
77        {
78            let marker = &content[range.start..range.start + 2];
79            let style = if marker == "**" {
80                StrongStyle::Asterisk
81            } else {
82                StrongStyle::Underscore
83            };
84            spans.push(StrongSpan { range, style });
85        }
86    }
87
88    spans
89}
90
91mod md050_config;
92use md050_config::MD050Config;
93
94/// Rule MD050: Strong style
95///
96/// See [docs/md050.md](../../docs/md050.md) for full documentation, configuration, and examples.
97///
98/// This rule is triggered when strong markers (** or __) are used in an inconsistent way.
99#[derive(Debug, Default, Clone)]
100pub struct MD050StrongStyle {
101    config: MD050Config,
102}
103
104impl MD050StrongStyle {
105    pub fn new(style: StrongStyle) -> Self {
106        Self {
107            config: MD050Config { style },
108        }
109    }
110
111    pub fn from_config_struct(config: MD050Config) -> Self {
112        Self { config }
113    }
114
115    /// Check if a byte position is within a link (inline links, reference links, or reference definitions)
116    fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
117        // Check inline and reference links
118        for link in &ctx.links {
119            if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
120                return true;
121            }
122        }
123
124        // Check images (which use similar syntax)
125        for image in &ctx.images {
126            if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
127                return true;
128            }
129        }
130
131        // Check reference definitions [ref]: url "title"
132        if ctx.is_in_reference_def(byte_pos) {
133            return true;
134        }
135
136        false
137    }
138
139    /// Check if a byte position is within an HTML tag
140    fn is_in_html_tag(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
141        // Check HTML tags
142        for html_tag in ctx.html_tags().iter() {
143            // Only consider the position inside the tag if it's between the < and >
144            // Don't include positions after the tag ends
145            if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
146                return true;
147            }
148        }
149        false
150    }
151
152    /// Check if a byte position is within HTML code tags (<code>...</code>)
153    /// This is separate from is_in_html_tag because we need to check the content between tags
154    fn is_in_html_code_content(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
155        let html_tags = ctx.html_tags();
156        let mut open_code_pos: Option<usize> = None;
157
158        for tag in html_tags.iter() {
159            // If we've passed our position, check if we're in an open code block
160            if tag.byte_offset > byte_pos {
161                return open_code_pos.is_some();
162            }
163
164            if tag.tag_name == "code" {
165                if tag.is_self_closing {
166                    // Self-closing tags don't create a code context
167                    continue;
168                } else if !tag.is_closing {
169                    // Opening <code> tag
170                    open_code_pos = Some(tag.byte_end);
171                } else if tag.is_closing && open_code_pos.is_some() {
172                    // Closing </code> tag
173                    if let Some(open_pos) = open_code_pos
174                        && byte_pos >= open_pos
175                        && byte_pos < tag.byte_offset
176                    {
177                        // We're between <code> and </code>
178                        return true;
179                    }
180                    open_code_pos = None;
181                }
182            }
183        }
184
185        // Check if we're still in an unclosed code tag
186        open_code_pos.is_some() && byte_pos >= open_code_pos.unwrap()
187    }
188
189    /// Check if a strong emphasis span should be skipped based on context
190    fn should_skip_span(&self, ctx: &crate::lint_context::LintContext, span_start: usize) -> bool {
191        let lines = ctx.raw_lines();
192        let (line_num, col) = ctx.offset_to_line_col(span_start);
193
194        // Skip matches in front matter
195        if ctx
196            .line_info(line_num)
197            .map(|info| info.in_front_matter)
198            .unwrap_or(false)
199        {
200            return true;
201        }
202
203        // Check MkDocs markup
204        let in_mkdocs_markup = lines
205            .get(line_num.saturating_sub(1))
206            .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
207
208        // Line-level inline code fallback for cases pulldown-cmark misses
209        let in_inline_code = lines
210            .get(line_num.saturating_sub(1))
211            .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
212
213        ctx.is_in_code_block_or_span(span_start)
214            || in_inline_code
215            || self.is_in_link(ctx, span_start)
216            || self.is_in_html_tag(ctx, span_start)
217            || self.is_in_html_code_content(ctx, span_start)
218            || in_mkdocs_markup
219            || is_in_math_context(ctx, span_start)
220            || is_in_jsx_expression(ctx, span_start)
221            || is_in_mdx_comment(ctx, span_start)
222    }
223
224    #[cfg(test)]
225    fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
226        let spans = find_strong_spans(ctx.content);
227        self.detect_style_from_spans(ctx, &spans)
228    }
229
230    fn detect_style_from_spans(
231        &self,
232        ctx: &crate::lint_context::LintContext,
233        spans: &[StrongSpan],
234    ) -> Option<StrongStyle> {
235        let mut asterisk_count = 0;
236        let mut underscore_count = 0;
237
238        for span in spans {
239            if self.should_skip_span(ctx, span.range.start) {
240                continue;
241            }
242
243            match span.style {
244                StrongStyle::Asterisk => asterisk_count += 1,
245                StrongStyle::Underscore => underscore_count += 1,
246                StrongStyle::Consistent => {}
247            }
248        }
249
250        match (asterisk_count, underscore_count) {
251            (0, 0) => None,
252            (_, 0) => Some(StrongStyle::Asterisk),
253            (0, _) => Some(StrongStyle::Underscore),
254            // In case of a tie, prefer asterisk (matches CommonMark recommendation)
255            (a, u) => {
256                if a >= u {
257                    Some(StrongStyle::Asterisk)
258                } else {
259                    Some(StrongStyle::Underscore)
260                }
261            }
262        }
263    }
264}
265
266impl Rule for MD050StrongStyle {
267    fn name(&self) -> &'static str {
268        "MD050"
269    }
270
271    fn description(&self) -> &'static str {
272        "Strong emphasis style should be consistent"
273    }
274
275    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
276        let content = ctx.content;
277        let line_index = &ctx.line_index;
278
279        let mut warnings = Vec::new();
280
281        let spans = find_strong_spans(content);
282
283        let target_style = match self.config.style {
284            StrongStyle::Consistent => self
285                .detect_style_from_spans(ctx, &spans)
286                .unwrap_or(StrongStyle::Asterisk),
287            _ => self.config.style,
288        };
289
290        for span in &spans {
291            // Only flag spans that use the wrong style
292            if span.style == target_style {
293                continue;
294            }
295
296            // Guard against spans shorter than 4 bytes (2 markers on each side)
297            if span.range.end - span.range.start < 4 {
298                continue;
299            }
300
301            if self.should_skip_span(ctx, span.range.start) {
302                continue;
303            }
304
305            let (line_num, _col) = ctx.offset_to_line_col(span.range.start);
306            let line_start = line_index.get_line_start_byte(line_num).unwrap_or(0);
307            let line_content = content.lines().nth(line_num - 1).unwrap_or("");
308            let match_start_in_line = span.range.start - line_start;
309            let match_len = span.range.end - span.range.start;
310
311            let inner_text = &content[span.range.start + 2..span.range.end - 2];
312
313            // NOTE: Intentional deviation from markdownlint behavior.
314            // markdownlint reports two warnings per emphasis (one for opening marker,
315            // one for closing marker). We report one warning per emphasis block because:
316            // 1. The markers are semantically one unit - you can't fix one without the other
317            // 2. Cleaner output - "10 issues" vs "20 issues" for 10 bold words
318            // 3. The fix is atomic - replacing the entire emphasis at once
319            let message = match target_style {
320                StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
321                StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
322                StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
323            };
324
325            let (start_line, start_col, end_line, end_col) =
326                calculate_match_range(line_num, line_content, match_start_in_line, match_len);
327
328            warnings.push(LintWarning {
329                rule_name: Some(self.name().to_string()),
330                line: start_line,
331                column: start_col,
332                end_line,
333                end_column: end_col,
334                message: message.to_string(),
335                severity: Severity::Warning,
336                fix: Some(Fix {
337                    range: span.range.start..span.range.end,
338                    replacement: match target_style {
339                        StrongStyle::Asterisk => format!("**{inner_text}**"),
340                        StrongStyle::Underscore => format!("__{inner_text}__"),
341                        StrongStyle::Consistent => format!("**{inner_text}**"),
342                    },
343                }),
344            });
345        }
346
347        Ok(warnings)
348    }
349
350    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
351        let content = ctx.content;
352
353        let spans = find_strong_spans(content);
354
355        let target_style = match self.config.style {
356            StrongStyle::Consistent => self
357                .detect_style_from_spans(ctx, &spans)
358                .unwrap_or(StrongStyle::Asterisk),
359            _ => self.config.style,
360        };
361
362        // Collect spans that need fixing (wrong style and not in a skip context)
363        let matches: Vec<std::ops::Range<usize>> = spans
364            .iter()
365            .filter(|span| span.range.end - span.range.start >= 4)
366            .filter(|span| span.style != target_style)
367            .filter(|span| !self.should_skip_span(ctx, span.range.start))
368            .filter(|span| {
369                let (line_num, _) = ctx.offset_to_line_col(span.range.start);
370                !ctx.inline_config().is_rule_disabled(self.name(), line_num)
371            })
372            .map(|span| span.range.clone())
373            .collect();
374
375        // Process matches in reverse order to maintain correct indices
376        let mut result = content.to_string();
377        for range in matches.into_iter().rev() {
378            let text = &result[range.start + 2..range.end - 2];
379            let replacement = match target_style {
380                StrongStyle::Asterisk => format!("**{text}**"),
381                StrongStyle::Underscore => format!("__{text}__"),
382                StrongStyle::Consistent => format!("**{text}**"),
383            };
384            result.replace_range(range, &replacement);
385        }
386
387        Ok(result)
388    }
389
390    /// Check if this rule should be skipped
391    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
392        // Strong uses double markers, but likely_has_emphasis checks for count > 1
393        ctx.content.is_empty() || !ctx.likely_has_emphasis()
394    }
395
396    fn as_any(&self) -> &dyn std::any::Any {
397        self
398    }
399
400    fn default_config_section(&self) -> Option<(String, toml::Value)> {
401        let json_value = serde_json::to_value(&self.config).ok()?;
402        Some((
403            self.name().to_string(),
404            crate::rule_config_serde::json_to_toml_value(&json_value)?,
405        ))
406    }
407
408    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
409    where
410        Self: Sized,
411    {
412        let rule_config = crate::rule_config_serde::load_rule_config::<MD050Config>(config);
413        Box::new(Self::from_config_struct(rule_config))
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::lint_context::LintContext;
421
422    #[test]
423    fn test_asterisk_style_with_asterisks() {
424        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
425        let content = "This is **strong text** here.";
426        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
427        let result = rule.check(&ctx).unwrap();
428
429        assert_eq!(result.len(), 0);
430    }
431
432    #[test]
433    fn test_asterisk_style_with_underscores() {
434        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
435        let content = "This is __strong text__ here.";
436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437        let result = rule.check(&ctx).unwrap();
438
439        assert_eq!(result.len(), 1);
440        assert!(
441            result[0]
442                .message
443                .contains("Strong emphasis should use ** instead of __")
444        );
445        assert_eq!(result[0].line, 1);
446        assert_eq!(result[0].column, 9);
447    }
448
449    #[test]
450    fn test_underscore_style_with_underscores() {
451        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
452        let content = "This is __strong text__ here.";
453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454        let result = rule.check(&ctx).unwrap();
455
456        assert_eq!(result.len(), 0);
457    }
458
459    #[test]
460    fn test_underscore_style_with_asterisks() {
461        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
462        let content = "This is **strong text** here.";
463        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464        let result = rule.check(&ctx).unwrap();
465
466        assert_eq!(result.len(), 1);
467        assert!(
468            result[0]
469                .message
470                .contains("Strong emphasis should use __ instead of **")
471        );
472    }
473
474    #[test]
475    fn test_consistent_style_first_asterisk() {
476        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
477        let content = "First **strong** then __also strong__.";
478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let result = rule.check(&ctx).unwrap();
480
481        // First strong is **, so __ should be flagged
482        assert_eq!(result.len(), 1);
483        assert!(
484            result[0]
485                .message
486                .contains("Strong emphasis should use ** instead of __")
487        );
488    }
489
490    #[test]
491    fn test_consistent_style_tie_prefers_asterisk() {
492        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
493        let content = "First __strong__ then **also strong**.";
494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495        let result = rule.check(&ctx).unwrap();
496
497        // Equal counts (1 vs 1), so prefer asterisks per CommonMark recommendation
498        // The __ should be flagged to change to **
499        assert_eq!(result.len(), 1);
500        assert!(
501            result[0]
502                .message
503                .contains("Strong emphasis should use ** instead of __")
504        );
505    }
506
507    #[test]
508    fn test_detect_style_asterisk() {
509        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
510        let ctx = LintContext::new(
511            "This has **strong** text.",
512            crate::config::MarkdownFlavor::Standard,
513            None,
514        );
515        let style = rule.detect_style(&ctx);
516
517        assert_eq!(style, Some(StrongStyle::Asterisk));
518    }
519
520    #[test]
521    fn test_detect_style_underscore() {
522        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
523        let ctx = LintContext::new(
524            "This has __strong__ text.",
525            crate::config::MarkdownFlavor::Standard,
526            None,
527        );
528        let style = rule.detect_style(&ctx);
529
530        assert_eq!(style, Some(StrongStyle::Underscore));
531    }
532
533    #[test]
534    fn test_detect_style_none() {
535        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
536        let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
537        let style = rule.detect_style(&ctx);
538
539        assert_eq!(style, None);
540    }
541
542    #[test]
543    fn test_strong_in_code_block() {
544        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
545        let content = "```\n__strong__ in code\n```\n__strong__ outside";
546        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547        let result = rule.check(&ctx).unwrap();
548
549        // Only the strong outside code block should be flagged
550        assert_eq!(result.len(), 1);
551        assert_eq!(result[0].line, 4);
552    }
553
554    #[test]
555    fn test_strong_in_inline_code() {
556        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
557        let content = "Text with `__strong__` in code and __strong__ outside.";
558        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
559        let result = rule.check(&ctx).unwrap();
560
561        // Only the strong outside inline code should be flagged
562        assert_eq!(result.len(), 1);
563    }
564
565    #[test]
566    fn test_escaped_strong() {
567        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
568        let content = "This is \\__not strong\\__ but __this is__.";
569        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570        let result = rule.check(&ctx).unwrap();
571
572        // Only the unescaped strong should be flagged
573        assert_eq!(result.len(), 1);
574        assert_eq!(result[0].line, 1);
575        assert_eq!(result[0].column, 30);
576    }
577
578    #[test]
579    fn test_fix_asterisks_to_underscores() {
580        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
581        let content = "This is **strong** text.";
582        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583        let fixed = rule.fix(&ctx).unwrap();
584
585        assert_eq!(fixed, "This is __strong__ text.");
586    }
587
588    #[test]
589    fn test_fix_underscores_to_asterisks() {
590        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
591        let content = "This is __strong__ text.";
592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593        let fixed = rule.fix(&ctx).unwrap();
594
595        assert_eq!(fixed, "This is **strong** text.");
596    }
597
598    #[test]
599    fn test_fix_multiple_strong() {
600        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
601        let content = "First __strong__ and second __also strong__.";
602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603        let fixed = rule.fix(&ctx).unwrap();
604
605        assert_eq!(fixed, "First **strong** and second **also strong**.");
606    }
607
608    #[test]
609    fn test_fix_preserves_code_blocks() {
610        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
611        let content = "```\n__strong__ in code\n```\n__strong__ outside";
612        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613        let fixed = rule.fix(&ctx).unwrap();
614
615        assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
616    }
617
618    #[test]
619    fn test_multiline_content() {
620        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
621        let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623        let result = rule.check(&ctx).unwrap();
624
625        assert_eq!(result.len(), 2);
626        assert_eq!(result[0].line, 1);
627        assert_eq!(result[1].line, 2);
628    }
629
630    #[test]
631    fn test_nested_emphasis() {
632        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
633        let content = "This has __strong with *emphasis* inside__.";
634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635        let result = rule.check(&ctx).unwrap();
636
637        assert_eq!(result.len(), 1);
638    }
639
640    #[test]
641    fn test_empty_content() {
642        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
643        let content = "";
644        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645        let result = rule.check(&ctx).unwrap();
646
647        assert_eq!(result.len(), 0);
648    }
649
650    #[test]
651    fn test_default_config() {
652        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
653        let (name, _config) = rule.default_config_section().unwrap();
654        assert_eq!(name, "MD050");
655    }
656
657    #[test]
658    fn test_strong_in_links_not_flagged() {
659        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
660        let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
661
662Hint:
663
664- [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__`")
665
666
667[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669        let result = rule.check(&ctx).unwrap();
670
671        // None of the __ patterns in links should be flagged
672        assert_eq!(result.len(), 0);
673    }
674
675    #[test]
676    fn test_strong_in_links_vs_outside_links() {
677        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
678        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][]**.
679
680Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
681
682This is __real strong text__ that should be flagged.
683
684[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
685        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686        let result = rule.check(&ctx).unwrap();
687
688        // Only the real strong text should be flagged, not the __ in links
689        assert_eq!(result.len(), 1);
690        assert!(
691            result[0]
692                .message
693                .contains("Strong emphasis should use ** instead of __")
694        );
695        // The flagged text should be "real strong text"
696        assert!(result[0].line > 4); // Should be on the line with "real strong text"
697    }
698
699    #[test]
700    fn test_front_matter_not_flagged() {
701        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
702        let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
703        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704        let result = rule.check(&ctx).unwrap();
705
706        // Only the strong text outside front matter should be flagged
707        assert_eq!(result.len(), 1);
708        assert_eq!(result[0].line, 6);
709        assert!(
710            result[0]
711                .message
712                .contains("Strong emphasis should use ** instead of __")
713        );
714    }
715
716    #[test]
717    fn test_html_tags_not_flagged() {
718        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
719        let content = r#"# Test
720
721This has HTML with underscores:
722
723<iframe src="https://example.com/__init__/__repr__"> </iframe>
724
725This __should be flagged__ as inconsistent."#;
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 HTML tags should be flagged
730        assert_eq!(result.len(), 1);
731        assert_eq!(result[0].line, 7);
732        assert!(
733            result[0]
734                .message
735                .contains("Strong emphasis should use ** instead of __")
736        );
737    }
738
739    #[test]
740    fn test_mkdocs_keys_notation_not_flagged() {
741        // Keys notation uses ++ which shouldn't be flagged as strong emphasis
742        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
743        let content = "Press ++ctrl+alt+del++ to restart.";
744        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
745        let result = rule.check(&ctx).unwrap();
746
747        // Keys notation should not be flagged as strong emphasis
748        assert!(
749            result.is_empty(),
750            "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
751        );
752    }
753
754    #[test]
755    fn test_mkdocs_caret_notation_not_flagged() {
756        // Insert notation (^^text^^) should not be flagged as strong emphasis
757        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
758        let content = "This is ^^inserted^^ text.";
759        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
760        let result = rule.check(&ctx).unwrap();
761
762        assert!(
763            result.is_empty(),
764            "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
765        );
766    }
767
768    #[test]
769    fn test_mkdocs_mark_notation_not_flagged() {
770        // Mark notation (==highlight==) should not be flagged
771        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
772        let content = "This is ==highlighted== text.";
773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
774        let result = rule.check(&ctx).unwrap();
775
776        assert!(
777            result.is_empty(),
778            "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
779        );
780    }
781
782    #[test]
783    fn test_mkdocs_mixed_content_with_real_strong() {
784        // Mixed content: MkDocs markup + real strong emphasis that should be flagged
785        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
786        let content = "Press ++ctrl++ and __underscore strong__ here.";
787        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
788        let result = rule.check(&ctx).unwrap();
789
790        // Only the real underscore strong should be flagged (not Keys notation)
791        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
792        assert!(
793            result[0]
794                .message
795                .contains("Strong emphasis should use ** instead of __")
796        );
797    }
798
799    #[test]
800    fn test_mkdocs_icon_shortcode_not_flagged() {
801        // Icon shortcodes like :material-star: should not affect strong detection
802        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
803        let content = "Click :material-check: and __this should be flagged__.";
804        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
805        let result = rule.check(&ctx).unwrap();
806
807        // The underscore strong should still be flagged
808        assert_eq!(result.len(), 1);
809        assert!(
810            result[0]
811                .message
812                .contains("Strong emphasis should use ** instead of __")
813        );
814    }
815
816    #[test]
817    fn test_math_block_not_flagged() {
818        // Math blocks contain _ and * characters that are not emphasis
819        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
820        let content = r#"# Math Section
821
822$$
823E = mc^2
824x_1 + x_2 = y
825a**b = c
826$$
827
828This __should be flagged__ outside math.
829"#;
830        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
831        let result = rule.check(&ctx).unwrap();
832
833        // Only the strong outside math block should be flagged
834        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
835        assert!(result[0].line > 7, "Warning should be on line after math block");
836    }
837
838    #[test]
839    fn test_math_block_with_underscores_not_flagged() {
840        // LaTeX subscripts use underscores that shouldn't be flagged
841        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
842        let content = r#"$$
843x_1 + x_2 + x__3 = y
844\alpha__\beta
845$$
846"#;
847        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
848        let result = rule.check(&ctx).unwrap();
849
850        // Nothing should be flagged - all content is in math block
851        assert!(
852            result.is_empty(),
853            "Math block content should not be flagged. Got: {result:?}"
854        );
855    }
856
857    #[test]
858    fn test_math_block_with_asterisks_not_flagged() {
859        // LaTeX multiplication uses asterisks that shouldn't be flagged
860        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
861        let content = r#"$$
862a**b = c
8632 ** 3 = 8
864x***y
865$$
866"#;
867        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
868        let result = rule.check(&ctx).unwrap();
869
870        // Nothing should be flagged - all content is in math block
871        assert!(
872            result.is_empty(),
873            "Math block content should not be flagged. Got: {result:?}"
874        );
875    }
876
877    #[test]
878    fn test_math_block_fix_preserves_content() {
879        // Fix should not modify content inside math blocks
880        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
881        let content = r#"$$
882x__y = z
883$$
884
885This __word__ should change.
886"#;
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
888        let fixed = rule.fix(&ctx).unwrap();
889
890        // Math block content should be unchanged
891        assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
892        // Strong outside should be fixed
893        assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
894    }
895
896    #[test]
897    fn test_inline_math_simple() {
898        // Simple inline math without underscore patterns that could be confused with strong
899        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
900        let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
901        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
902        let result = rule.check(&ctx).unwrap();
903
904        // __this__ should be flagged (it's outside the inline math)
905        assert_eq!(
906            result.len(),
907            1,
908            "Expected 1 warning for strong outside math. Got: {result:?}"
909        );
910    }
911
912    #[test]
913    fn test_multiple_math_blocks_and_strong() {
914        // Test with multiple math blocks and strong emphasis between them
915        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
916        let content = r#"# Document
917
918$$
919a = b
920$$
921
922This __should be flagged__ text.
923
924$$
925c = d
926$$
927"#;
928        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
929        let result = rule.check(&ctx).unwrap();
930
931        // Only the strong between math blocks should be flagged
932        assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
933        assert!(result[0].message.contains("**"));
934    }
935
936    #[test]
937    fn test_html_tag_skip_consistency_between_check_and_fix() {
938        // Verify that check() and fix() share the same HTML tag boundary logic,
939        // so double underscores inside HTML attributes are skipped consistently.
940        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
941
942        let content = r#"<a href="__test__">link</a>
943
944This __should be flagged__ text."#;
945        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946
947        let check_result = rule.check(&ctx).unwrap();
948        let fix_result = rule.fix(&ctx).unwrap();
949
950        // Only the __should be flagged__ outside the HTML tag should be flagged
951        assert_eq!(
952            check_result.len(),
953            1,
954            "check() should flag exactly one emphasis outside HTML tags"
955        );
956        assert!(check_result[0].message.contains("**"));
957
958        // fix() should only transform the same emphasis that check() flagged
959        assert!(
960            fix_result.contains("**should be flagged**"),
961            "fix() should convert the flagged emphasis"
962        );
963        assert!(
964            fix_result.contains("__test__"),
965            "fix() should not modify emphasis inside HTML tags"
966        );
967    }
968
969    #[test]
970    fn test_detect_style_ignores_emphasis_in_inline_code_on_table_lines() {
971        // In Consistent mode, detect_style() should not count emphasis markers
972        // inside inline code spans on table cell lines, matching check() and fix().
973        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
974
975        // The only real emphasis is **real** (asterisks). The __code__ inside
976        // backtick code spans should be ignored by detect_style().
977        let content = "| `__code__` | **real** |\n| --- | --- |\n| data | data |";
978        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979
980        let style = rule.detect_style(&ctx);
981        // Should detect asterisk as the dominant style (underscore inside code is skipped)
982        assert_eq!(style, Some(StrongStyle::Asterisk));
983    }
984
985    #[test]
986    fn test_five_underscores_not_flagged() {
987        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
988        let content = "This is a series of underscores: _____";
989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990        let result = rule.check(&ctx).unwrap();
991        assert!(
992            result.is_empty(),
993            "_____ should not be flagged as strong emphasis. Got: {result:?}"
994        );
995    }
996
997    #[test]
998    fn test_five_asterisks_not_flagged() {
999        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
1000        let content = "This is a series of asterisks: *****";
1001        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002        let result = rule.check(&ctx).unwrap();
1003        assert!(
1004            result.is_empty(),
1005            "***** should not be flagged as strong emphasis. Got: {result:?}"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_five_underscores_with_frontmatter_not_flagged() {
1011        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1012        let content = "---\ntitle: Level 1 heading\n---\n\nThis is a series of underscores: _____\n";
1013        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1014        let result = rule.check(&ctx).unwrap();
1015        assert!(result.is_empty(), "_____ should not be flagged. Got: {result:?}");
1016    }
1017
1018    #[test]
1019    fn test_four_underscores_not_flagged() {
1020        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1021        let content = "This is: ____";
1022        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1023        let result = rule.check(&ctx).unwrap();
1024        assert!(result.is_empty(), "____ should not be flagged. Got: {result:?}");
1025    }
1026
1027    #[test]
1028    fn test_four_asterisks_not_flagged() {
1029        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
1030        let content = "This is: ****";
1031        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032        let result = rule.check(&ctx).unwrap();
1033        assert!(result.is_empty(), "**** should not be flagged. Got: {result:?}");
1034    }
1035
1036    #[test]
1037    fn test_detect_style_ignores_underscore_sequences() {
1038        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
1039        let content = "This is: _____ and also **real bold**";
1040        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1041        let style = rule.detect_style(&ctx);
1042        assert_eq!(style, Some(StrongStyle::Asterisk));
1043    }
1044
1045    #[test]
1046    fn test_fix_does_not_modify_underscore_sequences() {
1047        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1048        let content = "Some _____ sequence and __real bold__ text.";
1049        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050        let fixed = rule.fix(&ctx).unwrap();
1051        assert!(fixed.contains("_____"), "_____ should be preserved");
1052        assert!(fixed.contains("**real bold**"), "Real bold should be converted");
1053    }
1054
1055    #[test]
1056    fn test_six_or_more_consecutive_markers_not_flagged() {
1057        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1058        for count in [6, 7, 8, 10] {
1059            let underscores = "_".repeat(count);
1060            let asterisks = "*".repeat(count);
1061            let content_u = format!("Text with {underscores} here");
1062            let content_a = format!("Text with {asterisks} here");
1063
1064            let ctx_u = LintContext::new(&content_u, crate::config::MarkdownFlavor::Standard, None);
1065            let ctx_a = LintContext::new(&content_a, crate::config::MarkdownFlavor::Standard, None);
1066
1067            let result_u = rule.check(&ctx_u).unwrap();
1068            let result_a = rule.check(&ctx_a).unwrap();
1069
1070            assert!(
1071                result_u.is_empty(),
1072                "{count} underscores should not be flagged. Got: {result_u:?}"
1073            );
1074            assert!(
1075                result_a.is_empty(),
1076                "{count} asterisks should not be flagged. Got: {result_a:?}"
1077            );
1078        }
1079    }
1080
1081    #[test]
1082    fn test_thematic_break_not_flagged() {
1083        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1084        let content = "Before\n\n*****\n\nAfter";
1085        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086        let result = rule.check(&ctx).unwrap();
1087        assert!(
1088            result.is_empty(),
1089            "Thematic break (*****) should not be flagged. Got: {result:?}"
1090        );
1091
1092        let content2 = "Before\n\n_____\n\nAfter";
1093        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1094        let result2 = rule.check(&ctx2).unwrap();
1095        assert!(
1096            result2.is_empty(),
1097            "Thematic break (_____) should not be flagged. Got: {result2:?}"
1098        );
1099    }
1100}