rumdl_lib/rules/
md050_strong_style.rs

1use crate::utils::range_utils::calculate_match_range;
2use crate::utils::regex_cache::{BOLD_ASTERISK_REGEX, BOLD_UNDERSCORE_REGEX};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
5use crate::rules::strong_style::StrongStyle;
6use crate::utils::regex_cache::get_cached_regex;
7use crate::utils::skip_context::is_in_mkdocs_markup;
8
9// Reference definition pattern
10const REF_DEF_REGEX_STR: &str = r#"(?m)^[ ]{0,3}\[([^\]]+)\]:\s*([^\s]+)(?:\s+(?:"([^"]*)"|'([^']*)'))?$"#;
11
12mod md050_config;
13use md050_config::MD050Config;
14
15/// Rule MD050: Strong style
16///
17/// See [docs/md050.md](../../docs/md050.md) for full documentation, configuration, and examples.
18///
19/// This rule is triggered when strong markers (** or __) are used in an inconsistent way.
20#[derive(Debug, Default, Clone)]
21pub struct MD050StrongStyle {
22    config: MD050Config,
23}
24
25impl MD050StrongStyle {
26    pub fn new(style: StrongStyle) -> Self {
27        Self {
28            config: MD050Config { style },
29        }
30    }
31
32    pub fn from_config_struct(config: MD050Config) -> Self {
33        Self { config }
34    }
35
36    /// Check if a byte position is within a link (inline links, reference links, or reference definitions)
37    fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
38        // Check inline and reference links
39        for link in &ctx.links {
40            if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
41                return true;
42            }
43        }
44
45        // Check images (which use similar syntax)
46        for image in &ctx.images {
47            if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
48                return true;
49            }
50        }
51
52        // Check reference definitions [ref]: url "title" using regex pattern
53        if let Ok(re) = get_cached_regex(REF_DEF_REGEX_STR) {
54            for m in re.find_iter(ctx.content) {
55                if m.start() <= byte_pos && byte_pos < m.end() {
56                    return true;
57                }
58            }
59        }
60
61        false
62    }
63
64    /// Check if a byte position is within an HTML tag
65    fn is_in_html_tag(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
66        // Check HTML tags
67        for html_tag in ctx.html_tags().iter() {
68            // Only consider the position inside the tag if it's between the < and >
69            // Don't include positions after the tag ends
70            if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
71                return true;
72            }
73        }
74        false
75    }
76
77    /// Check if a byte position is within HTML code tags (<code>...</code>)
78    /// This is separate from is_in_html_tag because we need to check the content between tags
79    fn is_in_html_code_content(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
80        let html_tags = ctx.html_tags();
81        let mut open_code_pos: Option<usize> = None;
82
83        for tag in html_tags.iter() {
84            // If we've passed our position, check if we're in an open code block
85            if tag.byte_offset > byte_pos {
86                return open_code_pos.is_some();
87            }
88
89            if tag.tag_name == "code" {
90                if tag.is_self_closing {
91                    // Self-closing tags don't create a code context
92                    continue;
93                } else if !tag.is_closing {
94                    // Opening <code> tag
95                    open_code_pos = Some(tag.byte_end);
96                } else if tag.is_closing && open_code_pos.is_some() {
97                    // Closing </code> tag
98                    if let Some(open_pos) = open_code_pos
99                        && byte_pos >= open_pos
100                        && byte_pos < tag.byte_offset
101                    {
102                        // We're between <code> and </code>
103                        return true;
104                    }
105                    open_code_pos = None;
106                }
107            }
108        }
109
110        // Check if we're still in an unclosed code tag
111        open_code_pos.is_some() && byte_pos >= open_code_pos.unwrap()
112    }
113
114    fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
115        let content = ctx.content;
116        let lines: Vec<&str> = content.lines().collect();
117
118        // Count how many times each marker appears (prevalence-based approach)
119        let mut asterisk_count = 0;
120        for m in BOLD_ASTERISK_REGEX.find_iter(content) {
121            // Skip matches in front matter
122            let (line_num, col) = ctx.offset_to_line_col(m.start());
123            let in_front_matter = ctx
124                .line_info(line_num)
125                .map(|info| info.in_front_matter)
126                .unwrap_or(false);
127
128            // Check MkDocs markup
129            let in_mkdocs_markup = lines
130                .get(line_num.saturating_sub(1))
131                .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
132
133            if !in_front_matter
134                && !ctx.is_in_code_block_or_span(m.start())
135                && !self.is_in_link(ctx, m.start())
136                && !self.is_in_html_tag(ctx, m.start())
137                && !self.is_in_html_code_content(ctx, m.start())
138                && !in_mkdocs_markup
139            {
140                asterisk_count += 1;
141            }
142        }
143
144        let mut underscore_count = 0;
145        for m in BOLD_UNDERSCORE_REGEX.find_iter(content) {
146            // Skip matches in front matter
147            let (line_num, col) = ctx.offset_to_line_col(m.start());
148            let in_front_matter = ctx
149                .line_info(line_num)
150                .map(|info| info.in_front_matter)
151                .unwrap_or(false);
152
153            // Check MkDocs markup
154            let in_mkdocs_markup = lines
155                .get(line_num.saturating_sub(1))
156                .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
157
158            if !in_front_matter
159                && !ctx.is_in_code_block_or_span(m.start())
160                && !self.is_in_link(ctx, m.start())
161                && !self.is_in_html_tag(ctx, m.start())
162                && !self.is_in_html_code_content(ctx, m.start())
163                && !in_mkdocs_markup
164            {
165                underscore_count += 1;
166            }
167        }
168
169        match (asterisk_count, underscore_count) {
170            (0, 0) => None,
171            (_, 0) => Some(StrongStyle::Asterisk),
172            (0, _) => Some(StrongStyle::Underscore),
173            (a, u) => {
174                // Use the most prevalent marker as the target style
175                // In case of a tie, prefer asterisk (matches CommonMark recommendation)
176                if a >= u {
177                    Some(StrongStyle::Asterisk)
178                } else {
179                    Some(StrongStyle::Underscore)
180                }
181            }
182        }
183    }
184
185    fn is_escaped(&self, text: &str, pos: usize) -> bool {
186        if pos == 0 {
187            return false;
188        }
189
190        let mut backslash_count = 0;
191        let mut i = pos;
192        let bytes = text.as_bytes();
193        while i > 0 {
194            i -= 1;
195            // Safe for ASCII backslash
196            if i < bytes.len() && bytes[i] != b'\\' {
197                break;
198            }
199            backslash_count += 1;
200        }
201        backslash_count % 2 == 1
202    }
203}
204
205impl Rule for MD050StrongStyle {
206    fn name(&self) -> &'static str {
207        "MD050"
208    }
209
210    fn description(&self) -> &'static str {
211        "Strong emphasis style should be consistent"
212    }
213
214    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
215        let content = ctx.content;
216        let line_index = &ctx.line_index;
217
218        let mut warnings = Vec::new();
219
220        let target_style = match self.config.style {
221            StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
222            _ => self.config.style,
223        };
224
225        let strong_regex = match target_style {
226            StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
227            StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
228            StrongStyle::Consistent => {
229                // This case is handled separately in the calling code
230                // but fallback to asterisk style for safety
231                &*BOLD_UNDERSCORE_REGEX
232            }
233        };
234
235        for (line_num, line) in content.lines().enumerate() {
236            // Skip if this line is in front matter
237            if let Some(line_info) = ctx.line_info(line_num + 1)
238                && line_info.in_front_matter
239            {
240                continue;
241            }
242
243            let byte_pos = line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
244
245            for m in strong_regex.find_iter(line) {
246                // Calculate the byte position of this match in the document
247                let match_byte_pos = byte_pos + m.start();
248
249                // Skip if this strong text is inside a code block, code span, link, HTML code content, or MkDocs markup
250                if ctx.is_in_code_block_or_span(match_byte_pos)
251                    || self.is_in_link(ctx, match_byte_pos)
252                    || self.is_in_html_code_content(ctx, match_byte_pos)
253                    || is_in_mkdocs_markup(line, m.start(), ctx.flavor)
254                {
255                    continue;
256                }
257
258                // Only skip HTML tag content if we're actually inside the tag (between < and >)
259                // not just on the same line as a tag
260                let mut inside_html_tag = false;
261                for tag in ctx.html_tags().iter() {
262                    // The emphasis must start after < and before >
263                    if tag.byte_offset < match_byte_pos && match_byte_pos < tag.byte_end - 1 {
264                        inside_html_tag = true;
265                        break;
266                    }
267                }
268                if inside_html_tag {
269                    continue;
270                }
271
272                if !self.is_escaped(line, m.start()) {
273                    let text = &line[m.start() + 2..m.end() - 2];
274
275                    // NOTE: Intentional deviation from markdownlint behavior.
276                    // markdownlint reports two warnings per emphasis (one for opening marker,
277                    // one for closing marker). We report one warning per emphasis block because:
278                    // 1. The markers are semantically one unit - you can't fix one without the other
279                    // 2. Cleaner output - "10 issues" vs "20 issues" for 10 bold words
280                    // 3. The fix is atomic - replacing the entire emphasis at once
281                    let message = match target_style {
282                        StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
283                        StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
284                        StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
285                    };
286
287                    // Calculate precise character range for the entire strong emphasis
288                    let (start_line, start_col, end_line, end_col) =
289                        calculate_match_range(line_num + 1, line, m.start(), m.len());
290
291                    warnings.push(LintWarning {
292                        rule_name: Some(self.name().to_string()),
293                        line: start_line,
294                        column: start_col,
295                        end_line,
296                        end_column: end_col,
297                        message: message.to_string(),
298                        severity: Severity::Warning,
299                        fix: Some(Fix {
300                            range: line_index.line_col_to_byte_range(line_num + 1, m.start() + 1),
301                            replacement: match target_style {
302                                StrongStyle::Asterisk => format!("**{text}**"),
303                                StrongStyle::Underscore => format!("__{text}__"),
304                                StrongStyle::Consistent => format!("**{text}**"),
305                            },
306                        }),
307                    });
308                }
309            }
310        }
311
312        Ok(warnings)
313    }
314
315    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
316        let content = ctx.content;
317
318        let target_style = match self.config.style {
319            StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
320            _ => self.config.style,
321        };
322
323        let strong_regex = match target_style {
324            StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
325            StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
326            StrongStyle::Consistent => {
327                // This case is handled separately in the calling code
328                // but fallback to asterisk style for safety
329                &*BOLD_UNDERSCORE_REGEX
330            }
331        };
332
333        // Store matches with their positions
334        let lines: Vec<&str> = content.lines().collect();
335
336        let matches: Vec<(usize, usize)> = strong_regex
337            .find_iter(content)
338            .filter(|m| {
339                // Skip matches in front matter
340                let (line_num, col) = ctx.offset_to_line_col(m.start());
341                if let Some(line_info) = ctx.line_info(line_num)
342                    && line_info.in_front_matter
343                {
344                    return false;
345                }
346                // Skip MkDocs markup
347                let in_mkdocs_markup = lines
348                    .get(line_num.saturating_sub(1))
349                    .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
350                !ctx.is_in_code_block_or_span(m.start())
351                    && !self.is_in_link(ctx, m.start())
352                    && !self.is_in_html_tag(ctx, m.start())
353                    && !self.is_in_html_code_content(ctx, m.start())
354                    && !in_mkdocs_markup
355            })
356            .filter(|m| !self.is_escaped(content, m.start()))
357            .map(|m| (m.start(), m.end()))
358            .collect();
359
360        // Process matches in reverse order to maintain correct indices
361
362        let mut result = content.to_string();
363        for (start, end) in matches.into_iter().rev() {
364            let text = &result[start + 2..end - 2];
365            let replacement = match target_style {
366                StrongStyle::Asterisk => format!("**{text}**"),
367                StrongStyle::Underscore => format!("__{text}__"),
368                StrongStyle::Consistent => {
369                    // This case is handled separately in the calling code
370                    // but fallback to asterisk style for safety
371                    format!("**{text}**")
372                }
373            };
374            result.replace_range(start..end, &replacement);
375        }
376
377        Ok(result)
378    }
379
380    /// Check if this rule should be skipped
381    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
382        // Strong uses double markers, but likely_has_emphasis checks for count > 1
383        ctx.content.is_empty() || !ctx.likely_has_emphasis()
384    }
385
386    fn as_any(&self) -> &dyn std::any::Any {
387        self
388    }
389
390    fn default_config_section(&self) -> Option<(String, toml::Value)> {
391        let json_value = serde_json::to_value(&self.config).ok()?;
392        Some((
393            self.name().to_string(),
394            crate::rule_config_serde::json_to_toml_value(&json_value)?,
395        ))
396    }
397
398    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
399    where
400        Self: Sized,
401    {
402        let rule_config = crate::rule_config_serde::load_rule_config::<MD050Config>(config);
403        Box::new(Self::from_config_struct(rule_config))
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::lint_context::LintContext;
411
412    #[test]
413    fn test_asterisk_style_with_asterisks() {
414        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
415        let content = "This is **strong text** here.";
416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417        let result = rule.check(&ctx).unwrap();
418
419        assert_eq!(result.len(), 0);
420    }
421
422    #[test]
423    fn test_asterisk_style_with_underscores() {
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(), 1);
430        assert!(
431            result[0]
432                .message
433                .contains("Strong emphasis should use ** instead of __")
434        );
435        assert_eq!(result[0].line, 1);
436        assert_eq!(result[0].column, 9);
437    }
438
439    #[test]
440    fn test_underscore_style_with_underscores() {
441        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
442        let content = "This is __strong text__ here.";
443        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444        let result = rule.check(&ctx).unwrap();
445
446        assert_eq!(result.len(), 0);
447    }
448
449    #[test]
450    fn test_underscore_style_with_asterisks() {
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(), 1);
457        assert!(
458            result[0]
459                .message
460                .contains("Strong emphasis should use __ instead of **")
461        );
462    }
463
464    #[test]
465    fn test_consistent_style_first_asterisk() {
466        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
467        let content = "First **strong** then __also strong__.";
468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
469        let result = rule.check(&ctx).unwrap();
470
471        // First strong is **, so __ should be flagged
472        assert_eq!(result.len(), 1);
473        assert!(
474            result[0]
475                .message
476                .contains("Strong emphasis should use ** instead of __")
477        );
478    }
479
480    #[test]
481    fn test_consistent_style_tie_prefers_asterisk() {
482        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
483        let content = "First __strong__ then **also strong**.";
484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485        let result = rule.check(&ctx).unwrap();
486
487        // Equal counts (1 vs 1), so prefer asterisks per CommonMark recommendation
488        // The __ should be flagged to change to **
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_detect_style_asterisk() {
499        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
500        let ctx = LintContext::new(
501            "This has **strong** text.",
502            crate::config::MarkdownFlavor::Standard,
503            None,
504        );
505        let style = rule.detect_style(&ctx);
506
507        assert_eq!(style, Some(StrongStyle::Asterisk));
508    }
509
510    #[test]
511    fn test_detect_style_underscore() {
512        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
513        let ctx = LintContext::new(
514            "This has __strong__ text.",
515            crate::config::MarkdownFlavor::Standard,
516            None,
517        );
518        let style = rule.detect_style(&ctx);
519
520        assert_eq!(style, Some(StrongStyle::Underscore));
521    }
522
523    #[test]
524    fn test_detect_style_none() {
525        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
526        let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
527        let style = rule.detect_style(&ctx);
528
529        assert_eq!(style, None);
530    }
531
532    #[test]
533    fn test_strong_in_code_block() {
534        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
535        let content = "```\n__strong__ in code\n```\n__strong__ outside";
536        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537        let result = rule.check(&ctx).unwrap();
538
539        // Only the strong outside code block should be flagged
540        assert_eq!(result.len(), 1);
541        assert_eq!(result[0].line, 4);
542    }
543
544    #[test]
545    fn test_strong_in_inline_code() {
546        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
547        let content = "Text with `__strong__` in code and __strong__ outside.";
548        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549        let result = rule.check(&ctx).unwrap();
550
551        // Only the strong outside inline code should be flagged
552        assert_eq!(result.len(), 1);
553    }
554
555    #[test]
556    fn test_escaped_strong() {
557        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
558        let content = "This is \\__not strong\\__ but __this is__.";
559        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560        let result = rule.check(&ctx).unwrap();
561
562        // Only the unescaped strong should be flagged
563        assert_eq!(result.len(), 1);
564        assert_eq!(result[0].line, 1);
565        assert_eq!(result[0].column, 30);
566    }
567
568    #[test]
569    fn test_fix_asterisks_to_underscores() {
570        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
571        let content = "This is **strong** text.";
572        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573        let fixed = rule.fix(&ctx).unwrap();
574
575        assert_eq!(fixed, "This is __strong__ text.");
576    }
577
578    #[test]
579    fn test_fix_underscores_to_asterisks() {
580        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
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_multiple_strong() {
590        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
591        let content = "First __strong__ and second __also strong__.";
592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593        let fixed = rule.fix(&ctx).unwrap();
594
595        assert_eq!(fixed, "First **strong** and second **also strong**.");
596    }
597
598    #[test]
599    fn test_fix_preserves_code_blocks() {
600        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
601        let content = "```\n__strong__ in code\n```\n__strong__ outside";
602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603        let fixed = rule.fix(&ctx).unwrap();
604
605        assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
606    }
607
608    #[test]
609    fn test_multiline_content() {
610        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
611        let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
612        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613        let result = rule.check(&ctx).unwrap();
614
615        assert_eq!(result.len(), 2);
616        assert_eq!(result[0].line, 1);
617        assert_eq!(result[1].line, 2);
618    }
619
620    #[test]
621    fn test_nested_emphasis() {
622        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
623        let content = "This has __strong with *emphasis* inside__.";
624        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625        let result = rule.check(&ctx).unwrap();
626
627        assert_eq!(result.len(), 1);
628    }
629
630    #[test]
631    fn test_empty_content() {
632        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
633        let content = "";
634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635        let result = rule.check(&ctx).unwrap();
636
637        assert_eq!(result.len(), 0);
638    }
639
640    #[test]
641    fn test_default_config() {
642        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
643        let (name, _config) = rule.default_config_section().unwrap();
644        assert_eq!(name, "MD050");
645    }
646
647    #[test]
648    fn test_strong_in_links_not_flagged() {
649        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
650        let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
651
652Hint:
653
654- [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__`")
655
656
657[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659        let result = rule.check(&ctx).unwrap();
660
661        // None of the __ patterns in links should be flagged
662        assert_eq!(result.len(), 0);
663    }
664
665    #[test]
666    fn test_strong_in_links_vs_outside_links() {
667        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
668        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][]**.
669
670Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
671
672This is __real strong text__ that should be flagged.
673
674[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
675        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
676        let result = rule.check(&ctx).unwrap();
677
678        // Only the real strong text should be flagged, not the __ in links
679        assert_eq!(result.len(), 1);
680        assert!(
681            result[0]
682                .message
683                .contains("Strong emphasis should use ** instead of __")
684        );
685        // The flagged text should be "real strong text"
686        assert!(result[0].line > 4); // Should be on the line with "real strong text"
687    }
688
689    #[test]
690    fn test_front_matter_not_flagged() {
691        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
692        let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let result = rule.check(&ctx).unwrap();
695
696        // Only the strong text outside front matter should be flagged
697        assert_eq!(result.len(), 1);
698        assert_eq!(result[0].line, 6);
699        assert!(
700            result[0]
701                .message
702                .contains("Strong emphasis should use ** instead of __")
703        );
704    }
705
706    #[test]
707    fn test_html_tags_not_flagged() {
708        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
709        let content = r#"# Test
710
711This has HTML with underscores:
712
713<iframe src="https://example.com/__init__/__repr__"> </iframe>
714
715This __should be flagged__ as inconsistent."#;
716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717        let result = rule.check(&ctx).unwrap();
718
719        // Only the strong text outside HTML tags should be flagged
720        assert_eq!(result.len(), 1);
721        assert_eq!(result[0].line, 7);
722        assert!(
723            result[0]
724                .message
725                .contains("Strong emphasis should use ** instead of __")
726        );
727    }
728
729    #[test]
730    fn test_mkdocs_keys_notation_not_flagged() {
731        // Keys notation uses ++ which shouldn't be flagged as strong emphasis
732        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
733        let content = "Press ++ctrl+alt+del++ to restart.";
734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
735        let result = rule.check(&ctx).unwrap();
736
737        // Keys notation should not be flagged as strong emphasis
738        assert!(
739            result.is_empty(),
740            "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
741        );
742    }
743
744    #[test]
745    fn test_mkdocs_caret_notation_not_flagged() {
746        // Insert notation (^^text^^) should not be flagged as strong emphasis
747        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
748        let content = "This is ^^inserted^^ text.";
749        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
750        let result = rule.check(&ctx).unwrap();
751
752        assert!(
753            result.is_empty(),
754            "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
755        );
756    }
757
758    #[test]
759    fn test_mkdocs_mark_notation_not_flagged() {
760        // Mark notation (==highlight==) should not be flagged
761        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
762        let content = "This is ==highlighted== text.";
763        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
764        let result = rule.check(&ctx).unwrap();
765
766        assert!(
767            result.is_empty(),
768            "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
769        );
770    }
771
772    #[test]
773    fn test_mkdocs_mixed_content_with_real_strong() {
774        // Mixed content: MkDocs markup + real strong emphasis that should be flagged
775        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
776        let content = "Press ++ctrl++ and __underscore strong__ here.";
777        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
778        let result = rule.check(&ctx).unwrap();
779
780        // Only the real underscore strong should be flagged (not Keys notation)
781        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
782        assert!(
783            result[0]
784                .message
785                .contains("Strong emphasis should use ** instead of __")
786        );
787    }
788
789    #[test]
790    fn test_mkdocs_icon_shortcode_not_flagged() {
791        // Icon shortcodes like :material-star: should not affect strong detection
792        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
793        let content = "Click :material-check: and __this should be flagged__.";
794        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
795        let result = rule.check(&ctx).unwrap();
796
797        // The underscore strong should still be flagged
798        assert_eq!(result.len(), 1);
799        assert!(
800            result[0]
801                .message
802                .contains("Strong emphasis should use ** instead of __")
803        );
804    }
805}