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_math_context, 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    }
221
222    #[cfg(test)]
223    fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
224        let spans = find_strong_spans(ctx.content);
225        self.detect_style_from_spans(ctx, &spans)
226    }
227
228    fn detect_style_from_spans(
229        &self,
230        ctx: &crate::lint_context::LintContext,
231        spans: &[StrongSpan],
232    ) -> Option<StrongStyle> {
233        let mut asterisk_count = 0;
234        let mut underscore_count = 0;
235
236        for span in spans {
237            if self.should_skip_span(ctx, span.range.start) {
238                continue;
239            }
240
241            match span.style {
242                StrongStyle::Asterisk => asterisk_count += 1,
243                StrongStyle::Underscore => underscore_count += 1,
244                StrongStyle::Consistent => {}
245            }
246        }
247
248        match (asterisk_count, underscore_count) {
249            (0, 0) => None,
250            (_, 0) => Some(StrongStyle::Asterisk),
251            (0, _) => Some(StrongStyle::Underscore),
252            // In case of a tie, prefer asterisk (matches CommonMark recommendation)
253            (a, u) => {
254                if a >= u {
255                    Some(StrongStyle::Asterisk)
256                } else {
257                    Some(StrongStyle::Underscore)
258                }
259            }
260        }
261    }
262}
263
264impl Rule for MD050StrongStyle {
265    fn name(&self) -> &'static str {
266        "MD050"
267    }
268
269    fn description(&self) -> &'static str {
270        "Strong emphasis style should be consistent"
271    }
272
273    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
274        let content = ctx.content;
275        let line_index = &ctx.line_index;
276
277        let mut warnings = Vec::new();
278
279        let spans = find_strong_spans(content);
280
281        let target_style = match self.config.style {
282            StrongStyle::Consistent => self
283                .detect_style_from_spans(ctx, &spans)
284                .unwrap_or(StrongStyle::Asterisk),
285            _ => self.config.style,
286        };
287
288        for span in &spans {
289            // Only flag spans that use the wrong style
290            if span.style == target_style {
291                continue;
292            }
293
294            // Guard against spans shorter than 4 bytes (2 markers on each side)
295            if span.range.end - span.range.start < 4 {
296                continue;
297            }
298
299            if self.should_skip_span(ctx, span.range.start) {
300                continue;
301            }
302
303            let (line_num, _col) = ctx.offset_to_line_col(span.range.start);
304            let line_start = line_index.get_line_start_byte(line_num).unwrap_or(0);
305            let line_content = content.lines().nth(line_num - 1).unwrap_or("");
306            let match_start_in_line = span.range.start - line_start;
307            let match_len = span.range.end - span.range.start;
308
309            let inner_text = &content[span.range.start + 2..span.range.end - 2];
310
311            // NOTE: Intentional deviation from markdownlint behavior.
312            // markdownlint reports two warnings per emphasis (one for opening marker,
313            // one for closing marker). We report one warning per emphasis block because:
314            // 1. The markers are semantically one unit - you can't fix one without the other
315            // 2. Cleaner output - "10 issues" vs "20 issues" for 10 bold words
316            // 3. The fix is atomic - replacing the entire emphasis at once
317            let message = match target_style {
318                StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
319                StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
320                StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
321            };
322
323            let (start_line, start_col, end_line, end_col) =
324                calculate_match_range(line_num, line_content, match_start_in_line, match_len);
325
326            warnings.push(LintWarning {
327                rule_name: Some(self.name().to_string()),
328                line: start_line,
329                column: start_col,
330                end_line,
331                end_column: end_col,
332                message: message.to_string(),
333                severity: Severity::Warning,
334                fix: Some(Fix {
335                    range: span.range.start..span.range.end,
336                    replacement: match target_style {
337                        StrongStyle::Asterisk => format!("**{inner_text}**"),
338                        StrongStyle::Underscore => format!("__{inner_text}__"),
339                        StrongStyle::Consistent => format!("**{inner_text}**"),
340                    },
341                }),
342            });
343        }
344
345        Ok(warnings)
346    }
347
348    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
349        let content = ctx.content;
350
351        let spans = find_strong_spans(content);
352
353        let target_style = match self.config.style {
354            StrongStyle::Consistent => self
355                .detect_style_from_spans(ctx, &spans)
356                .unwrap_or(StrongStyle::Asterisk),
357            _ => self.config.style,
358        };
359
360        // Collect spans that need fixing (wrong style and not in a skip context)
361        let matches: Vec<std::ops::Range<usize>> = spans
362            .iter()
363            .filter(|span| span.range.end - span.range.start >= 4)
364            .filter(|span| span.style != target_style)
365            .filter(|span| !self.should_skip_span(ctx, span.range.start))
366            .map(|span| span.range.clone())
367            .collect();
368
369        // Process matches in reverse order to maintain correct indices
370        let mut result = content.to_string();
371        for range in matches.into_iter().rev() {
372            let text = &result[range.start + 2..range.end - 2];
373            let replacement = match target_style {
374                StrongStyle::Asterisk => format!("**{text}**"),
375                StrongStyle::Underscore => format!("__{text}__"),
376                StrongStyle::Consistent => format!("**{text}**"),
377            };
378            result.replace_range(range, &replacement);
379        }
380
381        Ok(result)
382    }
383
384    /// Check if this rule should be skipped
385    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
386        // Strong uses double markers, but likely_has_emphasis checks for count > 1
387        ctx.content.is_empty() || !ctx.likely_has_emphasis()
388    }
389
390    fn as_any(&self) -> &dyn std::any::Any {
391        self
392    }
393
394    fn default_config_section(&self) -> Option<(String, toml::Value)> {
395        let json_value = serde_json::to_value(&self.config).ok()?;
396        Some((
397            self.name().to_string(),
398            crate::rule_config_serde::json_to_toml_value(&json_value)?,
399        ))
400    }
401
402    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
403    where
404        Self: Sized,
405    {
406        let rule_config = crate::rule_config_serde::load_rule_config::<MD050Config>(config);
407        Box::new(Self::from_config_struct(rule_config))
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::lint_context::LintContext;
415
416    #[test]
417    fn test_asterisk_style_with_asterisks() {
418        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
419        let content = "This is **strong text** here.";
420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421        let result = rule.check(&ctx).unwrap();
422
423        assert_eq!(result.len(), 0);
424    }
425
426    #[test]
427    fn test_asterisk_style_with_underscores() {
428        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
429        let content = "This is __strong text__ here.";
430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431        let result = rule.check(&ctx).unwrap();
432
433        assert_eq!(result.len(), 1);
434        assert!(
435            result[0]
436                .message
437                .contains("Strong emphasis should use ** instead of __")
438        );
439        assert_eq!(result[0].line, 1);
440        assert_eq!(result[0].column, 9);
441    }
442
443    #[test]
444    fn test_underscore_style_with_underscores() {
445        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
446        let content = "This is __strong text__ here.";
447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448        let result = rule.check(&ctx).unwrap();
449
450        assert_eq!(result.len(), 0);
451    }
452
453    #[test]
454    fn test_underscore_style_with_asterisks() {
455        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
456        let content = "This is **strong text** here.";
457        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458        let result = rule.check(&ctx).unwrap();
459
460        assert_eq!(result.len(), 1);
461        assert!(
462            result[0]
463                .message
464                .contains("Strong emphasis should use __ instead of **")
465        );
466    }
467
468    #[test]
469    fn test_consistent_style_first_asterisk() {
470        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
471        let content = "First **strong** then __also strong__.";
472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473        let result = rule.check(&ctx).unwrap();
474
475        // First strong is **, so __ should be flagged
476        assert_eq!(result.len(), 1);
477        assert!(
478            result[0]
479                .message
480                .contains("Strong emphasis should use ** instead of __")
481        );
482    }
483
484    #[test]
485    fn test_consistent_style_tie_prefers_asterisk() {
486        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
487        let content = "First __strong__ then **also strong**.";
488        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489        let result = rule.check(&ctx).unwrap();
490
491        // Equal counts (1 vs 1), so prefer asterisks per CommonMark recommendation
492        // The __ should be flagged to change to **
493        assert_eq!(result.len(), 1);
494        assert!(
495            result[0]
496                .message
497                .contains("Strong emphasis should use ** instead of __")
498        );
499    }
500
501    #[test]
502    fn test_detect_style_asterisk() {
503        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
504        let ctx = LintContext::new(
505            "This has **strong** text.",
506            crate::config::MarkdownFlavor::Standard,
507            None,
508        );
509        let style = rule.detect_style(&ctx);
510
511        assert_eq!(style, Some(StrongStyle::Asterisk));
512    }
513
514    #[test]
515    fn test_detect_style_underscore() {
516        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
517        let ctx = LintContext::new(
518            "This has __strong__ text.",
519            crate::config::MarkdownFlavor::Standard,
520            None,
521        );
522        let style = rule.detect_style(&ctx);
523
524        assert_eq!(style, Some(StrongStyle::Underscore));
525    }
526
527    #[test]
528    fn test_detect_style_none() {
529        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
530        let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
531        let style = rule.detect_style(&ctx);
532
533        assert_eq!(style, None);
534    }
535
536    #[test]
537    fn test_strong_in_code_block() {
538        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
539        let content = "```\n__strong__ in code\n```\n__strong__ outside";
540        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541        let result = rule.check(&ctx).unwrap();
542
543        // Only the strong outside code block should be flagged
544        assert_eq!(result.len(), 1);
545        assert_eq!(result[0].line, 4);
546    }
547
548    #[test]
549    fn test_strong_in_inline_code() {
550        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
551        let content = "Text with `__strong__` in code and __strong__ outside.";
552        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553        let result = rule.check(&ctx).unwrap();
554
555        // Only the strong outside inline code should be flagged
556        assert_eq!(result.len(), 1);
557    }
558
559    #[test]
560    fn test_escaped_strong() {
561        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
562        let content = "This is \\__not strong\\__ but __this is__.";
563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564        let result = rule.check(&ctx).unwrap();
565
566        // Only the unescaped strong should be flagged
567        assert_eq!(result.len(), 1);
568        assert_eq!(result[0].line, 1);
569        assert_eq!(result[0].column, 30);
570    }
571
572    #[test]
573    fn test_fix_asterisks_to_underscores() {
574        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
575        let content = "This is **strong** text.";
576        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577        let fixed = rule.fix(&ctx).unwrap();
578
579        assert_eq!(fixed, "This is __strong__ text.");
580    }
581
582    #[test]
583    fn test_fix_underscores_to_asterisks() {
584        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
585        let content = "This is __strong__ text.";
586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587        let fixed = rule.fix(&ctx).unwrap();
588
589        assert_eq!(fixed, "This is **strong** text.");
590    }
591
592    #[test]
593    fn test_fix_multiple_strong() {
594        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
595        let content = "First __strong__ and second __also strong__.";
596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597        let fixed = rule.fix(&ctx).unwrap();
598
599        assert_eq!(fixed, "First **strong** and second **also strong**.");
600    }
601
602    #[test]
603    fn test_fix_preserves_code_blocks() {
604        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
605        let content = "```\n__strong__ in code\n```\n__strong__ outside";
606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607        let fixed = rule.fix(&ctx).unwrap();
608
609        assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
610    }
611
612    #[test]
613    fn test_multiline_content() {
614        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
615        let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
616        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617        let result = rule.check(&ctx).unwrap();
618
619        assert_eq!(result.len(), 2);
620        assert_eq!(result[0].line, 1);
621        assert_eq!(result[1].line, 2);
622    }
623
624    #[test]
625    fn test_nested_emphasis() {
626        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
627        let content = "This has __strong with *emphasis* inside__.";
628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629        let result = rule.check(&ctx).unwrap();
630
631        assert_eq!(result.len(), 1);
632    }
633
634    #[test]
635    fn test_empty_content() {
636        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
637        let content = "";
638        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639        let result = rule.check(&ctx).unwrap();
640
641        assert_eq!(result.len(), 0);
642    }
643
644    #[test]
645    fn test_default_config() {
646        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
647        let (name, _config) = rule.default_config_section().unwrap();
648        assert_eq!(name, "MD050");
649    }
650
651    #[test]
652    fn test_strong_in_links_not_flagged() {
653        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
654        let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
655
656Hint:
657
658- [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__`")
659
660
661[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
662        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663        let result = rule.check(&ctx).unwrap();
664
665        // None of the __ patterns in links should be flagged
666        assert_eq!(result.len(), 0);
667    }
668
669    #[test]
670    fn test_strong_in_links_vs_outside_links() {
671        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
672        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][]**.
673
674Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
675
676This is __real strong text__ that should be flagged.
677
678[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680        let result = rule.check(&ctx).unwrap();
681
682        // Only the real strong text should be flagged, not the __ in links
683        assert_eq!(result.len(), 1);
684        assert!(
685            result[0]
686                .message
687                .contains("Strong emphasis should use ** instead of __")
688        );
689        // The flagged text should be "real strong text"
690        assert!(result[0].line > 4); // Should be on the line with "real strong text"
691    }
692
693    #[test]
694    fn test_front_matter_not_flagged() {
695        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
696        let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
697        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698        let result = rule.check(&ctx).unwrap();
699
700        // Only the strong text outside front matter should be flagged
701        assert_eq!(result.len(), 1);
702        assert_eq!(result[0].line, 6);
703        assert!(
704            result[0]
705                .message
706                .contains("Strong emphasis should use ** instead of __")
707        );
708    }
709
710    #[test]
711    fn test_html_tags_not_flagged() {
712        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
713        let content = r#"# Test
714
715This has HTML with underscores:
716
717<iframe src="https://example.com/__init__/__repr__"> </iframe>
718
719This __should be flagged__ as inconsistent."#;
720        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721        let result = rule.check(&ctx).unwrap();
722
723        // Only the strong text outside HTML tags should be flagged
724        assert_eq!(result.len(), 1);
725        assert_eq!(result[0].line, 7);
726        assert!(
727            result[0]
728                .message
729                .contains("Strong emphasis should use ** instead of __")
730        );
731    }
732
733    #[test]
734    fn test_mkdocs_keys_notation_not_flagged() {
735        // Keys notation uses ++ which shouldn't be flagged as strong emphasis
736        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
737        let content = "Press ++ctrl+alt+del++ to restart.";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
739        let result = rule.check(&ctx).unwrap();
740
741        // Keys notation should not be flagged as strong emphasis
742        assert!(
743            result.is_empty(),
744            "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
745        );
746    }
747
748    #[test]
749    fn test_mkdocs_caret_notation_not_flagged() {
750        // Insert notation (^^text^^) should not be flagged as strong emphasis
751        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
752        let content = "This is ^^inserted^^ text.";
753        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
754        let result = rule.check(&ctx).unwrap();
755
756        assert!(
757            result.is_empty(),
758            "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
759        );
760    }
761
762    #[test]
763    fn test_mkdocs_mark_notation_not_flagged() {
764        // Mark notation (==highlight==) should not be flagged
765        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
766        let content = "This is ==highlighted== text.";
767        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
768        let result = rule.check(&ctx).unwrap();
769
770        assert!(
771            result.is_empty(),
772            "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
773        );
774    }
775
776    #[test]
777    fn test_mkdocs_mixed_content_with_real_strong() {
778        // Mixed content: MkDocs markup + real strong emphasis that should be flagged
779        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
780        let content = "Press ++ctrl++ and __underscore strong__ here.";
781        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
782        let result = rule.check(&ctx).unwrap();
783
784        // Only the real underscore strong should be flagged (not Keys notation)
785        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
786        assert!(
787            result[0]
788                .message
789                .contains("Strong emphasis should use ** instead of __")
790        );
791    }
792
793    #[test]
794    fn test_mkdocs_icon_shortcode_not_flagged() {
795        // Icon shortcodes like :material-star: should not affect strong detection
796        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
797        let content = "Click :material-check: and __this should be flagged__.";
798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
799        let result = rule.check(&ctx).unwrap();
800
801        // The underscore strong should still be flagged
802        assert_eq!(result.len(), 1);
803        assert!(
804            result[0]
805                .message
806                .contains("Strong emphasis should use ** instead of __")
807        );
808    }
809
810    #[test]
811    fn test_math_block_not_flagged() {
812        // Math blocks contain _ and * characters that are not emphasis
813        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
814        let content = r#"# Math Section
815
816$$
817E = mc^2
818x_1 + x_2 = y
819a**b = c
820$$
821
822This __should be flagged__ outside math.
823"#;
824        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
825        let result = rule.check(&ctx).unwrap();
826
827        // Only the strong outside math block should be flagged
828        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
829        assert!(result[0].line > 7, "Warning should be on line after math block");
830    }
831
832    #[test]
833    fn test_math_block_with_underscores_not_flagged() {
834        // LaTeX subscripts use underscores that shouldn't be flagged
835        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
836        let content = r#"$$
837x_1 + x_2 + x__3 = y
838\alpha__\beta
839$$
840"#;
841        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
842        let result = rule.check(&ctx).unwrap();
843
844        // Nothing should be flagged - all content is in math block
845        assert!(
846            result.is_empty(),
847            "Math block content should not be flagged. Got: {result:?}"
848        );
849    }
850
851    #[test]
852    fn test_math_block_with_asterisks_not_flagged() {
853        // LaTeX multiplication uses asterisks that shouldn't be flagged
854        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
855        let content = r#"$$
856a**b = c
8572 ** 3 = 8
858x***y
859$$
860"#;
861        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
862        let result = rule.check(&ctx).unwrap();
863
864        // Nothing should be flagged - all content is in math block
865        assert!(
866            result.is_empty(),
867            "Math block content should not be flagged. Got: {result:?}"
868        );
869    }
870
871    #[test]
872    fn test_math_block_fix_preserves_content() {
873        // Fix should not modify content inside math blocks
874        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
875        let content = r#"$$
876x__y = z
877$$
878
879This __word__ should change.
880"#;
881        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
882        let fixed = rule.fix(&ctx).unwrap();
883
884        // Math block content should be unchanged
885        assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
886        // Strong outside should be fixed
887        assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
888    }
889
890    #[test]
891    fn test_inline_math_simple() {
892        // Simple inline math without underscore patterns that could be confused with strong
893        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
894        let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
895        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
896        let result = rule.check(&ctx).unwrap();
897
898        // __this__ should be flagged (it's outside the inline math)
899        assert_eq!(
900            result.len(),
901            1,
902            "Expected 1 warning for strong outside math. Got: {result:?}"
903        );
904    }
905
906    #[test]
907    fn test_multiple_math_blocks_and_strong() {
908        // Test with multiple math blocks and strong emphasis between them
909        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
910        let content = r#"# Document
911
912$$
913a = b
914$$
915
916This __should be flagged__ text.
917
918$$
919c = d
920$$
921"#;
922        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
923        let result = rule.check(&ctx).unwrap();
924
925        // Only the strong between math blocks should be flagged
926        assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
927        assert!(result[0].message.contains("**"));
928    }
929
930    #[test]
931    fn test_html_tag_skip_consistency_between_check_and_fix() {
932        // Verify that check() and fix() share the same HTML tag boundary logic,
933        // so double underscores inside HTML attributes are skipped consistently.
934        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
935
936        let content = r#"<a href="__test__">link</a>
937
938This __should be flagged__ text."#;
939        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940
941        let check_result = rule.check(&ctx).unwrap();
942        let fix_result = rule.fix(&ctx).unwrap();
943
944        // Only the __should be flagged__ outside the HTML tag should be flagged
945        assert_eq!(
946            check_result.len(),
947            1,
948            "check() should flag exactly one emphasis outside HTML tags"
949        );
950        assert!(check_result[0].message.contains("**"));
951
952        // fix() should only transform the same emphasis that check() flagged
953        assert!(
954            fix_result.contains("**should be flagged**"),
955            "fix() should convert the flagged emphasis"
956        );
957        assert!(
958            fix_result.contains("__test__"),
959            "fix() should not modify emphasis inside HTML tags"
960        );
961    }
962
963    #[test]
964    fn test_detect_style_ignores_emphasis_in_inline_code_on_table_lines() {
965        // In Consistent mode, detect_style() should not count emphasis markers
966        // inside inline code spans on table cell lines, matching check() and fix().
967        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
968
969        // The only real emphasis is **real** (asterisks). The __code__ inside
970        // backtick code spans should be ignored by detect_style().
971        let content = "| `__code__` | **real** |\n| --- | --- |\n| data | data |";
972        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973
974        let style = rule.detect_style(&ctx);
975        // Should detect asterisk as the dominant style (underscore inside code is skipped)
976        assert_eq!(style, Some(StrongStyle::Asterisk));
977    }
978
979    #[test]
980    fn test_five_underscores_not_flagged() {
981        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
982        let content = "This is a series of underscores: _____";
983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984        let result = rule.check(&ctx).unwrap();
985        assert!(
986            result.is_empty(),
987            "_____ should not be flagged as strong emphasis. Got: {result:?}"
988        );
989    }
990
991    #[test]
992    fn test_five_asterisks_not_flagged() {
993        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
994        let content = "This is a series of asterisks: *****";
995        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996        let result = rule.check(&ctx).unwrap();
997        assert!(
998            result.is_empty(),
999            "***** should not be flagged as strong emphasis. Got: {result:?}"
1000        );
1001    }
1002
1003    #[test]
1004    fn test_five_underscores_with_frontmatter_not_flagged() {
1005        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1006        let content = "---\ntitle: Level 1 heading\n---\n\nThis is a series of underscores: _____\n";
1007        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008        let result = rule.check(&ctx).unwrap();
1009        assert!(result.is_empty(), "_____ should not be flagged. Got: {result:?}");
1010    }
1011
1012    #[test]
1013    fn test_four_underscores_not_flagged() {
1014        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1015        let content = "This is: ____";
1016        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017        let result = rule.check(&ctx).unwrap();
1018        assert!(result.is_empty(), "____ should not be flagged. Got: {result:?}");
1019    }
1020
1021    #[test]
1022    fn test_four_asterisks_not_flagged() {
1023        let rule = MD050StrongStyle::new(StrongStyle::Underscore);
1024        let content = "This is: ****";
1025        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026        let result = rule.check(&ctx).unwrap();
1027        assert!(result.is_empty(), "**** should not be flagged. Got: {result:?}");
1028    }
1029
1030    #[test]
1031    fn test_detect_style_ignores_underscore_sequences() {
1032        let rule = MD050StrongStyle::new(StrongStyle::Consistent);
1033        let content = "This is: _____ and also **real bold**";
1034        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1035        let style = rule.detect_style(&ctx);
1036        assert_eq!(style, Some(StrongStyle::Asterisk));
1037    }
1038
1039    #[test]
1040    fn test_fix_does_not_modify_underscore_sequences() {
1041        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1042        let content = "Some _____ sequence and __real bold__ text.";
1043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044        let fixed = rule.fix(&ctx).unwrap();
1045        assert!(fixed.contains("_____"), "_____ should be preserved");
1046        assert!(fixed.contains("**real bold**"), "Real bold should be converted");
1047    }
1048
1049    #[test]
1050    fn test_six_or_more_consecutive_markers_not_flagged() {
1051        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1052        for count in [6, 7, 8, 10] {
1053            let underscores = "_".repeat(count);
1054            let asterisks = "*".repeat(count);
1055            let content_u = format!("Text with {underscores} here");
1056            let content_a = format!("Text with {asterisks} here");
1057
1058            let ctx_u = LintContext::new(&content_u, crate::config::MarkdownFlavor::Standard, None);
1059            let ctx_a = LintContext::new(&content_a, crate::config::MarkdownFlavor::Standard, None);
1060
1061            let result_u = rule.check(&ctx_u).unwrap();
1062            let result_a = rule.check(&ctx_a).unwrap();
1063
1064            assert!(
1065                result_u.is_empty(),
1066                "{count} underscores should not be flagged. Got: {result_u:?}"
1067            );
1068            assert!(
1069                result_a.is_empty(),
1070                "{count} asterisks should not be flagged. Got: {result_a:?}"
1071            );
1072        }
1073    }
1074
1075    #[test]
1076    fn test_thematic_break_not_flagged() {
1077        let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1078        let content = "Before\n\n*****\n\nAfter";
1079        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1080        let result = rule.check(&ctx).unwrap();
1081        assert!(
1082            result.is_empty(),
1083            "Thematic break (*****) should not be flagged. Got: {result:?}"
1084        );
1085
1086        let content2 = "Before\n\n_____\n\nAfter";
1087        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1088        let result2 = rule.check(&ctx2).unwrap();
1089        assert!(
1090            result2.is_empty(),
1091            "Thematic break (_____) should not be flagged. Got: {result2:?}"
1092        );
1093    }
1094}