Skip to main content

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_math_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 = ctx.raw_lines();
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 skip_context = 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 !skip_context
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                && !is_in_math_context(ctx, m.start())
140            {
141                asterisk_count += 1;
142            }
143        }
144
145        let mut underscore_count = 0;
146        for m in BOLD_UNDERSCORE_REGEX.find_iter(content) {
147            // Skip matches in front matter
148            let (line_num, col) = ctx.offset_to_line_col(m.start());
149            let skip_context = ctx
150                .line_info(line_num)
151                .map(|info| info.in_front_matter)
152                .unwrap_or(false);
153
154            // Check MkDocs markup
155            let in_mkdocs_markup = lines
156                .get(line_num.saturating_sub(1))
157                .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
158
159            if !skip_context
160                && !ctx.is_in_code_block_or_span(m.start())
161                && !self.is_in_link(ctx, m.start())
162                && !self.is_in_html_tag(ctx, m.start())
163                && !self.is_in_html_code_content(ctx, m.start())
164                && !in_mkdocs_markup
165                && !is_in_math_context(ctx, m.start())
166            {
167                underscore_count += 1;
168            }
169        }
170
171        match (asterisk_count, underscore_count) {
172            (0, 0) => None,
173            (_, 0) => Some(StrongStyle::Asterisk),
174            (0, _) => Some(StrongStyle::Underscore),
175            (a, u) => {
176                // Use the most prevalent marker as the target style
177                // In case of a tie, prefer asterisk (matches CommonMark recommendation)
178                if a >= u {
179                    Some(StrongStyle::Asterisk)
180                } else {
181                    Some(StrongStyle::Underscore)
182                }
183            }
184        }
185    }
186
187    fn is_escaped(&self, text: &str, pos: usize) -> bool {
188        if pos == 0 {
189            return false;
190        }
191
192        let mut backslash_count = 0;
193        let mut i = pos;
194        let bytes = text.as_bytes();
195        while i > 0 {
196            i -= 1;
197            // Safe for ASCII backslash
198            if i < bytes.len() && bytes[i] != b'\\' {
199                break;
200            }
201            backslash_count += 1;
202        }
203        backslash_count % 2 == 1
204    }
205}
206
207impl Rule for MD050StrongStyle {
208    fn name(&self) -> &'static str {
209        "MD050"
210    }
211
212    fn description(&self) -> &'static str {
213        "Strong emphasis style should be consistent"
214    }
215
216    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
217        let content = ctx.content;
218        let line_index = &ctx.line_index;
219
220        let mut warnings = Vec::new();
221
222        let target_style = match self.config.style {
223            StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
224            _ => self.config.style,
225        };
226
227        let strong_regex = match target_style {
228            StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
229            StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
230            StrongStyle::Consistent => {
231                // This case is handled separately in the calling code
232                // but fallback to asterisk style for safety
233                &*BOLD_UNDERSCORE_REGEX
234            }
235        };
236
237        for (line_num, line) in content.lines().enumerate() {
238            // Skip if this line is in front matter
239            if let Some(line_info) = ctx.line_info(line_num + 1)
240                && line_info.in_front_matter
241            {
242                continue;
243            }
244
245            let byte_pos = line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
246
247            for m in strong_regex.find_iter(line) {
248                // Calculate the byte position of this match in the document
249                let match_byte_pos = byte_pos + m.start();
250
251                // Skip if this strong text is inside a code block, code span, link, HTML code content, MkDocs markup, or math block
252                if ctx.is_in_code_block_or_span(match_byte_pos)
253                    || self.is_in_link(ctx, match_byte_pos)
254                    || self.is_in_html_code_content(ctx, match_byte_pos)
255                    || is_in_mkdocs_markup(line, m.start(), ctx.flavor)
256                    || is_in_math_context(ctx, match_byte_pos)
257                {
258                    continue;
259                }
260
261                // Skip strong emphasis inside HTML tags
262                if self.is_in_html_tag(ctx, match_byte_pos) {
263                    continue;
264                }
265
266                if !self.is_escaped(line, m.start()) {
267                    let text = &line[m.start() + 2..m.end() - 2];
268
269                    // NOTE: Intentional deviation from markdownlint behavior.
270                    // markdownlint reports two warnings per emphasis (one for opening marker,
271                    // one for closing marker). We report one warning per emphasis block because:
272                    // 1. The markers are semantically one unit - you can't fix one without the other
273                    // 2. Cleaner output - "10 issues" vs "20 issues" for 10 bold words
274                    // 3. The fix is atomic - replacing the entire emphasis at once
275                    let message = match target_style {
276                        StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
277                        StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
278                        StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
279                    };
280
281                    // Calculate precise character range for the entire strong emphasis
282                    let (start_line, start_col, end_line, end_col) =
283                        calculate_match_range(line_num + 1, line, m.start(), m.len());
284
285                    warnings.push(LintWarning {
286                        rule_name: Some(self.name().to_string()),
287                        line: start_line,
288                        column: start_col,
289                        end_line,
290                        end_column: end_col,
291                        message: message.to_string(),
292                        severity: Severity::Warning,
293                        fix: Some(Fix {
294                            range: line_index.line_col_to_byte_range_with_length(line_num + 1, m.start() + 1, m.len()),
295                            replacement: match target_style {
296                                StrongStyle::Asterisk => format!("**{text}**"),
297                                StrongStyle::Underscore => format!("__{text}__"),
298                                StrongStyle::Consistent => format!("**{text}**"),
299                            },
300                        }),
301                    });
302                }
303            }
304        }
305
306        Ok(warnings)
307    }
308
309    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
310        let content = ctx.content;
311
312        let target_style = match self.config.style {
313            StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
314            _ => self.config.style,
315        };
316
317        let strong_regex = match target_style {
318            StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
319            StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
320            StrongStyle::Consistent => {
321                // This case is handled separately in the calling code
322                // but fallback to asterisk style for safety
323                &*BOLD_UNDERSCORE_REGEX
324            }
325        };
326
327        // Store matches with their positions
328        let lines = ctx.raw_lines();
329
330        let matches: Vec<(usize, usize)> = strong_regex
331            .find_iter(content)
332            .filter(|m| {
333                // Skip matches in front matter
334                let (line_num, col) = ctx.offset_to_line_col(m.start());
335                if let Some(line_info) = ctx.line_info(line_num)
336                    && line_info.in_front_matter
337                {
338                    return false;
339                }
340                // Skip MkDocs markup and math blocks
341                let in_mkdocs_markup = lines
342                    .get(line_num.saturating_sub(1))
343                    .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
344                !ctx.is_in_code_block_or_span(m.start())
345                    && !self.is_in_link(ctx, m.start())
346                    && !self.is_in_html_tag(ctx, m.start())
347                    && !self.is_in_html_code_content(ctx, m.start())
348                    && !in_mkdocs_markup
349                    && !is_in_math_context(ctx, m.start())
350            })
351            .filter(|m| !self.is_escaped(content, m.start()))
352            .map(|m| (m.start(), m.end()))
353            .collect();
354
355        // Process matches in reverse order to maintain correct indices
356
357        let mut result = content.to_string();
358        for (start, end) in matches.into_iter().rev() {
359            let text = &result[start + 2..end - 2];
360            let replacement = match target_style {
361                StrongStyle::Asterisk => format!("**{text}**"),
362                StrongStyle::Underscore => format!("__{text}__"),
363                StrongStyle::Consistent => {
364                    // This case is handled separately in the calling code
365                    // but fallback to asterisk style for safety
366                    format!("**{text}**")
367                }
368            };
369            result.replace_range(start..end, &replacement);
370        }
371
372        Ok(result)
373    }
374
375    /// Check if this rule should be skipped
376    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
377        // Strong uses double markers, but likely_has_emphasis checks for count > 1
378        ctx.content.is_empty() || !ctx.likely_has_emphasis()
379    }
380
381    fn as_any(&self) -> &dyn std::any::Any {
382        self
383    }
384
385    fn default_config_section(&self) -> Option<(String, toml::Value)> {
386        let json_value = serde_json::to_value(&self.config).ok()?;
387        Some((
388            self.name().to_string(),
389            crate::rule_config_serde::json_to_toml_value(&json_value)?,
390        ))
391    }
392
393    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
394    where
395        Self: Sized,
396    {
397        let rule_config = crate::rule_config_serde::load_rule_config::<MD050Config>(config);
398        Box::new(Self::from_config_struct(rule_config))
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::lint_context::LintContext;
406
407    #[test]
408    fn test_asterisk_style_with_asterisks() {
409        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
410        let content = "This is **strong text** here.";
411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412        let result = rule.check(&ctx).unwrap();
413
414        assert_eq!(result.len(), 0);
415    }
416
417    #[test]
418    fn test_asterisk_style_with_underscores() {
419        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
420        let content = "This is __strong text__ here.";
421        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422        let result = rule.check(&ctx).unwrap();
423
424        assert_eq!(result.len(), 1);
425        assert!(
426            result[0]
427                .message
428                .contains("Strong emphasis should use ** instead of __")
429        );
430        assert_eq!(result[0].line, 1);
431        assert_eq!(result[0].column, 9);
432    }
433
434    #[test]
435    fn test_underscore_style_with_underscores() {
436        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
437        let content = "This is __strong text__ here.";
438        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439        let result = rule.check(&ctx).unwrap();
440
441        assert_eq!(result.len(), 0);
442    }
443
444    #[test]
445    fn test_underscore_style_with_asterisks() {
446        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
447        let content = "This is **strong text** here.";
448        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449        let result = rule.check(&ctx).unwrap();
450
451        assert_eq!(result.len(), 1);
452        assert!(
453            result[0]
454                .message
455                .contains("Strong emphasis should use __ instead of **")
456        );
457    }
458
459    #[test]
460    fn test_consistent_style_first_asterisk() {
461        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
462        let content = "First **strong** then __also strong__.";
463        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464        let result = rule.check(&ctx).unwrap();
465
466        // First strong is **, so __ should be flagged
467        assert_eq!(result.len(), 1);
468        assert!(
469            result[0]
470                .message
471                .contains("Strong emphasis should use ** instead of __")
472        );
473    }
474
475    #[test]
476    fn test_consistent_style_tie_prefers_asterisk() {
477        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
478        let content = "First __strong__ then **also strong**.";
479        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480        let result = rule.check(&ctx).unwrap();
481
482        // Equal counts (1 vs 1), so prefer asterisks per CommonMark recommendation
483        // The __ should be flagged to change to **
484        assert_eq!(result.len(), 1);
485        assert!(
486            result[0]
487                .message
488                .contains("Strong emphasis should use ** instead of __")
489        );
490    }
491
492    #[test]
493    fn test_detect_style_asterisk() {
494        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
495        let ctx = LintContext::new(
496            "This has **strong** text.",
497            crate::config::MarkdownFlavor::Standard,
498            None,
499        );
500        let style = rule.detect_style(&ctx);
501
502        assert_eq!(style, Some(StrongStyle::Asterisk));
503    }
504
505    #[test]
506    fn test_detect_style_underscore() {
507        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
508        let ctx = LintContext::new(
509            "This has __strong__ text.",
510            crate::config::MarkdownFlavor::Standard,
511            None,
512        );
513        let style = rule.detect_style(&ctx);
514
515        assert_eq!(style, Some(StrongStyle::Underscore));
516    }
517
518    #[test]
519    fn test_detect_style_none() {
520        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
521        let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
522        let style = rule.detect_style(&ctx);
523
524        assert_eq!(style, None);
525    }
526
527    #[test]
528    fn test_strong_in_code_block() {
529        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
530        let content = "```\n__strong__ in code\n```\n__strong__ outside";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532        let result = rule.check(&ctx).unwrap();
533
534        // Only the strong outside code block should be flagged
535        assert_eq!(result.len(), 1);
536        assert_eq!(result[0].line, 4);
537    }
538
539    #[test]
540    fn test_strong_in_inline_code() {
541        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
542        let content = "Text with `__strong__` in code and __strong__ outside.";
543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544        let result = rule.check(&ctx).unwrap();
545
546        // Only the strong outside inline code should be flagged
547        assert_eq!(result.len(), 1);
548    }
549
550    #[test]
551    fn test_escaped_strong() {
552        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
553        let content = "This is \\__not strong\\__ but __this is__.";
554        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555        let result = rule.check(&ctx).unwrap();
556
557        // Only the unescaped strong should be flagged
558        assert_eq!(result.len(), 1);
559        assert_eq!(result[0].line, 1);
560        assert_eq!(result[0].column, 30);
561    }
562
563    #[test]
564    fn test_fix_asterisks_to_underscores() {
565        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
566        let content = "This is **strong** text.";
567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568        let fixed = rule.fix(&ctx).unwrap();
569
570        assert_eq!(fixed, "This is __strong__ text.");
571    }
572
573    #[test]
574    fn test_fix_underscores_to_asterisks() {
575        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
576        let content = "This is __strong__ text.";
577        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578        let fixed = rule.fix(&ctx).unwrap();
579
580        assert_eq!(fixed, "This is **strong** text.");
581    }
582
583    #[test]
584    fn test_fix_multiple_strong() {
585        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
586        let content = "First __strong__ and second __also strong__.";
587        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588        let fixed = rule.fix(&ctx).unwrap();
589
590        assert_eq!(fixed, "First **strong** and second **also strong**.");
591    }
592
593    #[test]
594    fn test_fix_preserves_code_blocks() {
595        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
596        let content = "```\n__strong__ in code\n```\n__strong__ outside";
597        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598        let fixed = rule.fix(&ctx).unwrap();
599
600        assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
601    }
602
603    #[test]
604    fn test_multiline_content() {
605        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
606        let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608        let result = rule.check(&ctx).unwrap();
609
610        assert_eq!(result.len(), 2);
611        assert_eq!(result[0].line, 1);
612        assert_eq!(result[1].line, 2);
613    }
614
615    #[test]
616    fn test_nested_emphasis() {
617        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
618        let content = "This has __strong with *emphasis* inside__.";
619        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620        let result = rule.check(&ctx).unwrap();
621
622        assert_eq!(result.len(), 1);
623    }
624
625    #[test]
626    fn test_empty_content() {
627        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
628        let content = "";
629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630        let result = rule.check(&ctx).unwrap();
631
632        assert_eq!(result.len(), 0);
633    }
634
635    #[test]
636    fn test_default_config() {
637        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
638        let (name, _config) = rule.default_config_section().unwrap();
639        assert_eq!(name, "MD050");
640    }
641
642    #[test]
643    fn test_strong_in_links_not_flagged() {
644        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
645        let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
646
647Hint:
648
649- [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__`")
650
651
652[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654        let result = rule.check(&ctx).unwrap();
655
656        // None of the __ patterns in links should be flagged
657        assert_eq!(result.len(), 0);
658    }
659
660    #[test]
661    fn test_strong_in_links_vs_outside_links() {
662        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
663        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][]**.
664
665Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
666
667This is __real strong text__ that should be flagged.
668
669[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
670        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671        let result = rule.check(&ctx).unwrap();
672
673        // Only the real strong text should be flagged, not the __ in links
674        assert_eq!(result.len(), 1);
675        assert!(
676            result[0]
677                .message
678                .contains("Strong emphasis should use ** instead of __")
679        );
680        // The flagged text should be "real strong text"
681        assert!(result[0].line > 4); // Should be on the line with "real strong text"
682    }
683
684    #[test]
685    fn test_front_matter_not_flagged() {
686        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
687        let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689        let result = rule.check(&ctx).unwrap();
690
691        // Only the strong text outside front matter should be flagged
692        assert_eq!(result.len(), 1);
693        assert_eq!(result[0].line, 6);
694        assert!(
695            result[0]
696                .message
697                .contains("Strong emphasis should use ** instead of __")
698        );
699    }
700
701    #[test]
702    fn test_html_tags_not_flagged() {
703        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
704        let content = r#"# Test
705
706This has HTML with underscores:
707
708<iframe src="https://example.com/__init__/__repr__"> </iframe>
709
710This __should be flagged__ as inconsistent."#;
711        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712        let result = rule.check(&ctx).unwrap();
713
714        // Only the strong text outside HTML tags should be flagged
715        assert_eq!(result.len(), 1);
716        assert_eq!(result[0].line, 7);
717        assert!(
718            result[0]
719                .message
720                .contains("Strong emphasis should use ** instead of __")
721        );
722    }
723
724    #[test]
725    fn test_mkdocs_keys_notation_not_flagged() {
726        // Keys notation uses ++ which shouldn't be flagged as strong emphasis
727        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
728        let content = "Press ++ctrl+alt+del++ to restart.";
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
730        let result = rule.check(&ctx).unwrap();
731
732        // Keys notation should not be flagged as strong emphasis
733        assert!(
734            result.is_empty(),
735            "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
736        );
737    }
738
739    #[test]
740    fn test_mkdocs_caret_notation_not_flagged() {
741        // Insert notation (^^text^^) should not be flagged as strong emphasis
742        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
743        let content = "This is ^^inserted^^ text.";
744        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
745        let result = rule.check(&ctx).unwrap();
746
747        assert!(
748            result.is_empty(),
749            "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
750        );
751    }
752
753    #[test]
754    fn test_mkdocs_mark_notation_not_flagged() {
755        // Mark notation (==highlight==) should not be flagged
756        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
757        let content = "This is ==highlighted== text.";
758        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
759        let result = rule.check(&ctx).unwrap();
760
761        assert!(
762            result.is_empty(),
763            "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
764        );
765    }
766
767    #[test]
768    fn test_mkdocs_mixed_content_with_real_strong() {
769        // Mixed content: MkDocs markup + real strong emphasis that should be flagged
770        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
771        let content = "Press ++ctrl++ and __underscore strong__ here.";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
773        let result = rule.check(&ctx).unwrap();
774
775        // Only the real underscore strong should be flagged (not Keys notation)
776        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
777        assert!(
778            result[0]
779                .message
780                .contains("Strong emphasis should use ** instead of __")
781        );
782    }
783
784    #[test]
785    fn test_mkdocs_icon_shortcode_not_flagged() {
786        // Icon shortcodes like :material-star: should not affect strong detection
787        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
788        let content = "Click :material-check: and __this should be flagged__.";
789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
790        let result = rule.check(&ctx).unwrap();
791
792        // The underscore strong should still be flagged
793        assert_eq!(result.len(), 1);
794        assert!(
795            result[0]
796                .message
797                .contains("Strong emphasis should use ** instead of __")
798        );
799    }
800
801    #[test]
802    fn test_math_block_not_flagged() {
803        // Math blocks contain _ and * characters that are not emphasis
804        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
805        let content = r#"# Math Section
806
807$$
808E = mc^2
809x_1 + x_2 = y
810a**b = c
811$$
812
813This __should be flagged__ outside math.
814"#;
815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
816        let result = rule.check(&ctx).unwrap();
817
818        // Only the strong outside math block should be flagged
819        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
820        assert!(result[0].line > 7, "Warning should be on line after math block");
821    }
822
823    #[test]
824    fn test_math_block_with_underscores_not_flagged() {
825        // LaTeX subscripts use underscores that shouldn't be flagged
826        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
827        let content = r#"$$
828x_1 + x_2 + x__3 = y
829\alpha__\beta
830$$
831"#;
832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
833        let result = rule.check(&ctx).unwrap();
834
835        // Nothing should be flagged - all content is in math block
836        assert!(
837            result.is_empty(),
838            "Math block content should not be flagged. Got: {result:?}"
839        );
840    }
841
842    #[test]
843    fn test_math_block_with_asterisks_not_flagged() {
844        // LaTeX multiplication uses asterisks that shouldn't be flagged
845        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
846        let content = r#"$$
847a**b = c
8482 ** 3 = 8
849x***y
850$$
851"#;
852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
853        let result = rule.check(&ctx).unwrap();
854
855        // Nothing should be flagged - all content is in math block
856        assert!(
857            result.is_empty(),
858            "Math block content should not be flagged. Got: {result:?}"
859        );
860    }
861
862    #[test]
863    fn test_math_block_fix_preserves_content() {
864        // Fix should not modify content inside math blocks
865        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
866        let content = r#"$$
867x__y = z
868$$
869
870This __word__ should change.
871"#;
872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
873        let fixed = rule.fix(&ctx).unwrap();
874
875        // Math block content should be unchanged
876        assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
877        // Strong outside should be fixed
878        assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
879    }
880
881    #[test]
882    fn test_inline_math_simple() {
883        // Simple inline math without underscore patterns that could be confused with strong
884        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
885        let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
886        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
887        let result = rule.check(&ctx).unwrap();
888
889        // __this__ should be flagged (it's outside the inline math)
890        assert_eq!(
891            result.len(),
892            1,
893            "Expected 1 warning for strong outside math. Got: {result:?}"
894        );
895    }
896
897    #[test]
898    fn test_multiple_math_blocks_and_strong() {
899        // Test with multiple math blocks and strong emphasis between them
900        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
901        let content = r#"# Document
902
903$$
904a = b
905$$
906
907This __should be flagged__ text.
908
909$$
910c = d
911$$
912"#;
913        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
914        let result = rule.check(&ctx).unwrap();
915
916        // Only the strong between math blocks should be flagged
917        assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
918        assert!(result[0].message.contains("**"));
919    }
920
921    #[test]
922    fn test_html_tag_skip_consistency_between_check_and_fix() {
923        // Verify that check() and fix() share the same HTML tag boundary logic,
924        // so double underscores inside HTML attributes are skipped consistently.
925        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
926
927        let content = r#"<a href="__test__">link</a>
928
929This __should be flagged__ text."#;
930        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931
932        let check_result = rule.check(&ctx).unwrap();
933        let fix_result = rule.fix(&ctx).unwrap();
934
935        // Only the __should be flagged__ outside the HTML tag should be flagged
936        assert_eq!(
937            check_result.len(),
938            1,
939            "check() should flag exactly one emphasis outside HTML tags"
940        );
941        assert!(check_result[0].message.contains("**"));
942
943        // fix() should only transform the same emphasis that check() flagged
944        assert!(
945            fix_result.contains("**should be flagged**"),
946            "fix() should convert the flagged emphasis"
947        );
948        assert!(
949            fix_result.contains("__test__"),
950            "fix() should not modify emphasis inside HTML tags"
951        );
952    }
953}