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
9const REF_DEF_REGEX_STR: &str = r#"(?m)^[ ]{0,3}\[([^\]]+)\]:\s*([^\s]+)(?:\s+(?:"([^"]*)"|'([^']*)'))?$"#;
11
12fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
17 let bytes = line.as_bytes();
18 let mut i = 0;
19
20 while i < bytes.len() {
21 if bytes[i] == b'`' {
22 let open_start = i;
23 let mut backtick_count = 0;
24 while i < bytes.len() && bytes[i] == b'`' {
25 backtick_count += 1;
26 i += 1;
27 }
28
29 let mut j = i;
31 while j < bytes.len() {
32 if bytes[j] == b'`' {
33 let mut close_count = 0;
34 while j < bytes.len() && bytes[j] == b'`' {
35 close_count += 1;
36 j += 1;
37 }
38 if close_count == backtick_count {
39 if byte_pos >= open_start && byte_pos < j {
41 return true;
42 }
43 i = j;
44 break;
45 }
46 } else {
47 j += 1;
48 }
49 }
50
51 if j >= bytes.len() {
52 break;
54 }
55 } else {
56 i += 1;
57 }
58 }
59
60 false
61}
62
63mod md050_config;
64use md050_config::MD050Config;
65
66#[derive(Debug, Default, Clone)]
72pub struct MD050StrongStyle {
73 config: MD050Config,
74}
75
76impl MD050StrongStyle {
77 pub fn new(style: StrongStyle) -> Self {
78 Self {
79 config: MD050Config { style },
80 }
81 }
82
83 pub fn from_config_struct(config: MD050Config) -> Self {
84 Self { config }
85 }
86
87 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
89 for link in &ctx.links {
91 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
92 return true;
93 }
94 }
95
96 for image in &ctx.images {
98 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
99 return true;
100 }
101 }
102
103 if let Ok(re) = get_cached_regex(REF_DEF_REGEX_STR) {
105 for m in re.find_iter(ctx.content) {
106 if m.start() <= byte_pos && byte_pos < m.end() {
107 return true;
108 }
109 }
110 }
111
112 false
113 }
114
115 fn is_in_html_tag(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
117 for html_tag in ctx.html_tags().iter() {
119 if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
122 return true;
123 }
124 }
125 false
126 }
127
128 fn is_in_html_code_content(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
131 let html_tags = ctx.html_tags();
132 let mut open_code_pos: Option<usize> = None;
133
134 for tag in html_tags.iter() {
135 if tag.byte_offset > byte_pos {
137 return open_code_pos.is_some();
138 }
139
140 if tag.tag_name == "code" {
141 if tag.is_self_closing {
142 continue;
144 } else if !tag.is_closing {
145 open_code_pos = Some(tag.byte_end);
147 } else if tag.is_closing && open_code_pos.is_some() {
148 if let Some(open_pos) = open_code_pos
150 && byte_pos >= open_pos
151 && byte_pos < tag.byte_offset
152 {
153 return true;
155 }
156 open_code_pos = None;
157 }
158 }
159 }
160
161 open_code_pos.is_some() && byte_pos >= open_code_pos.unwrap()
163 }
164
165 fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
166 let content = ctx.content;
167 let lines = ctx.raw_lines();
168
169 let mut asterisk_count = 0;
171 for m in BOLD_ASTERISK_REGEX.find_iter(content) {
172 let (line_num, col) = ctx.offset_to_line_col(m.start());
174 let skip_context = ctx
175 .line_info(line_num)
176 .map(|info| info.in_front_matter)
177 .unwrap_or(false);
178
179 let in_mkdocs_markup = lines
181 .get(line_num.saturating_sub(1))
182 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
183
184 let in_inline_code = lines
186 .get(line_num.saturating_sub(1))
187 .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
188
189 if !skip_context
190 && !ctx.is_in_code_block_or_span(m.start())
191 && !in_inline_code
192 && !self.is_in_link(ctx, m.start())
193 && !self.is_in_html_tag(ctx, m.start())
194 && !self.is_in_html_code_content(ctx, m.start())
195 && !in_mkdocs_markup
196 && !is_in_math_context(ctx, m.start())
197 {
198 asterisk_count += 1;
199 }
200 }
201
202 let mut underscore_count = 0;
203 for m in BOLD_UNDERSCORE_REGEX.find_iter(content) {
204 let (line_num, col) = ctx.offset_to_line_col(m.start());
206 let skip_context = ctx
207 .line_info(line_num)
208 .map(|info| info.in_front_matter)
209 .unwrap_or(false);
210
211 let in_mkdocs_markup = lines
213 .get(line_num.saturating_sub(1))
214 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
215
216 let in_inline_code = lines
218 .get(line_num.saturating_sub(1))
219 .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
220
221 if !skip_context
222 && !ctx.is_in_code_block_or_span(m.start())
223 && !in_inline_code
224 && !self.is_in_link(ctx, m.start())
225 && !self.is_in_html_tag(ctx, m.start())
226 && !self.is_in_html_code_content(ctx, m.start())
227 && !in_mkdocs_markup
228 && !is_in_math_context(ctx, m.start())
229 {
230 underscore_count += 1;
231 }
232 }
233
234 match (asterisk_count, underscore_count) {
235 (0, 0) => None,
236 (_, 0) => Some(StrongStyle::Asterisk),
237 (0, _) => Some(StrongStyle::Underscore),
238 (a, u) => {
239 if a >= u {
242 Some(StrongStyle::Asterisk)
243 } else {
244 Some(StrongStyle::Underscore)
245 }
246 }
247 }
248 }
249
250 fn is_escaped(&self, text: &str, pos: usize) -> bool {
251 if pos == 0 {
252 return false;
253 }
254
255 let mut backslash_count = 0;
256 let mut i = pos;
257 let bytes = text.as_bytes();
258 while i > 0 {
259 i -= 1;
260 if i < bytes.len() && bytes[i] != b'\\' {
262 break;
263 }
264 backslash_count += 1;
265 }
266 backslash_count % 2 == 1
267 }
268}
269
270impl Rule for MD050StrongStyle {
271 fn name(&self) -> &'static str {
272 "MD050"
273 }
274
275 fn description(&self) -> &'static str {
276 "Strong emphasis style should be consistent"
277 }
278
279 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
280 let content = ctx.content;
281 let line_index = &ctx.line_index;
282
283 let mut warnings = Vec::new();
284
285 let target_style = match self.config.style {
286 StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
287 _ => self.config.style,
288 };
289
290 let strong_regex = match target_style {
291 StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
292 StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
293 StrongStyle::Consistent => {
294 &*BOLD_UNDERSCORE_REGEX
297 }
298 };
299
300 for (line_num, line) in content.lines().enumerate() {
301 if let Some(line_info) = ctx.line_info(line_num + 1)
303 && line_info.in_front_matter
304 {
305 continue;
306 }
307
308 let byte_pos = line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
309
310 for m in strong_regex.find_iter(line) {
311 let match_byte_pos = byte_pos + m.start();
313
314 if ctx.is_in_code_block_or_span(match_byte_pos)
316 || is_in_inline_code_on_line(line, m.start())
317 || self.is_in_link(ctx, match_byte_pos)
318 || self.is_in_html_code_content(ctx, match_byte_pos)
319 || is_in_mkdocs_markup(line, m.start(), ctx.flavor)
320 || is_in_math_context(ctx, match_byte_pos)
321 {
322 continue;
323 }
324
325 if self.is_in_html_tag(ctx, match_byte_pos) {
327 continue;
328 }
329
330 if !self.is_escaped(line, m.start()) {
331 let text = &line[m.start() + 2..m.end() - 2];
332
333 let message = match target_style {
340 StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
341 StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
342 StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
343 };
344
345 let (start_line, start_col, end_line, end_col) =
347 calculate_match_range(line_num + 1, line, m.start(), m.len());
348
349 warnings.push(LintWarning {
350 rule_name: Some(self.name().to_string()),
351 line: start_line,
352 column: start_col,
353 end_line,
354 end_column: end_col,
355 message: message.to_string(),
356 severity: Severity::Warning,
357 fix: Some(Fix {
358 range: line_index.line_col_to_byte_range_with_length(line_num + 1, m.start() + 1, m.len()),
359 replacement: match target_style {
360 StrongStyle::Asterisk => format!("**{text}**"),
361 StrongStyle::Underscore => format!("__{text}__"),
362 StrongStyle::Consistent => format!("**{text}**"),
363 },
364 }),
365 });
366 }
367 }
368 }
369
370 Ok(warnings)
371 }
372
373 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
374 let content = ctx.content;
375
376 let target_style = match self.config.style {
377 StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
378 _ => self.config.style,
379 };
380
381 let strong_regex = match target_style {
382 StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
383 StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
384 StrongStyle::Consistent => {
385 &*BOLD_UNDERSCORE_REGEX
388 }
389 };
390
391 let lines = ctx.raw_lines();
393
394 let matches: Vec<(usize, usize)> = strong_regex
395 .find_iter(content)
396 .filter(|m| {
397 let (line_num, col) = ctx.offset_to_line_col(m.start());
399 if let Some(line_info) = ctx.line_info(line_num)
400 && line_info.in_front_matter
401 {
402 return false;
403 }
404 let in_mkdocs_markup = lines
406 .get(line_num.saturating_sub(1))
407 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
408 let in_inline_code = lines
410 .get(line_num.saturating_sub(1))
411 .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
412 !ctx.is_in_code_block_or_span(m.start())
413 && !in_inline_code
414 && !self.is_in_link(ctx, m.start())
415 && !self.is_in_html_tag(ctx, m.start())
416 && !self.is_in_html_code_content(ctx, m.start())
417 && !in_mkdocs_markup
418 && !is_in_math_context(ctx, m.start())
419 })
420 .filter(|m| !self.is_escaped(content, m.start()))
421 .map(|m| (m.start(), m.end()))
422 .collect();
423
424 let mut result = content.to_string();
427 for (start, end) in matches.into_iter().rev() {
428 let text = &result[start + 2..end - 2];
429 let replacement = match target_style {
430 StrongStyle::Asterisk => format!("**{text}**"),
431 StrongStyle::Underscore => format!("__{text}__"),
432 StrongStyle::Consistent => {
433 format!("**{text}**")
436 }
437 };
438 result.replace_range(start..end, &replacement);
439 }
440
441 Ok(result)
442 }
443
444 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
446 ctx.content.is_empty() || !ctx.likely_has_emphasis()
448 }
449
450 fn as_any(&self) -> &dyn std::any::Any {
451 self
452 }
453
454 fn default_config_section(&self) -> Option<(String, toml::Value)> {
455 let json_value = serde_json::to_value(&self.config).ok()?;
456 Some((
457 self.name().to_string(),
458 crate::rule_config_serde::json_to_toml_value(&json_value)?,
459 ))
460 }
461
462 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
463 where
464 Self: Sized,
465 {
466 let rule_config = crate::rule_config_serde::load_rule_config::<MD050Config>(config);
467 Box::new(Self::from_config_struct(rule_config))
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::lint_context::LintContext;
475
476 #[test]
477 fn test_asterisk_style_with_asterisks() {
478 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
479 let content = "This is **strong text** here.";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let result = rule.check(&ctx).unwrap();
482
483 assert_eq!(result.len(), 0);
484 }
485
486 #[test]
487 fn test_asterisk_style_with_underscores() {
488 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
489 let content = "This is __strong text__ here.";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491 let result = rule.check(&ctx).unwrap();
492
493 assert_eq!(result.len(), 1);
494 assert!(
495 result[0]
496 .message
497 .contains("Strong emphasis should use ** instead of __")
498 );
499 assert_eq!(result[0].line, 1);
500 assert_eq!(result[0].column, 9);
501 }
502
503 #[test]
504 fn test_underscore_style_with_underscores() {
505 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
506 let content = "This is __strong text__ here.";
507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx).unwrap();
509
510 assert_eq!(result.len(), 0);
511 }
512
513 #[test]
514 fn test_underscore_style_with_asterisks() {
515 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
516 let content = "This is **strong text** here.";
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518 let result = rule.check(&ctx).unwrap();
519
520 assert_eq!(result.len(), 1);
521 assert!(
522 result[0]
523 .message
524 .contains("Strong emphasis should use __ instead of **")
525 );
526 }
527
528 #[test]
529 fn test_consistent_style_first_asterisk() {
530 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
531 let content = "First **strong** then __also strong__.";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534
535 assert_eq!(result.len(), 1);
537 assert!(
538 result[0]
539 .message
540 .contains("Strong emphasis should use ** instead of __")
541 );
542 }
543
544 #[test]
545 fn test_consistent_style_tie_prefers_asterisk() {
546 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
547 let content = "First __strong__ then **also strong**.";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549 let result = rule.check(&ctx).unwrap();
550
551 assert_eq!(result.len(), 1);
554 assert!(
555 result[0]
556 .message
557 .contains("Strong emphasis should use ** instead of __")
558 );
559 }
560
561 #[test]
562 fn test_detect_style_asterisk() {
563 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
564 let ctx = LintContext::new(
565 "This has **strong** text.",
566 crate::config::MarkdownFlavor::Standard,
567 None,
568 );
569 let style = rule.detect_style(&ctx);
570
571 assert_eq!(style, Some(StrongStyle::Asterisk));
572 }
573
574 #[test]
575 fn test_detect_style_underscore() {
576 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
577 let ctx = LintContext::new(
578 "This has __strong__ text.",
579 crate::config::MarkdownFlavor::Standard,
580 None,
581 );
582 let style = rule.detect_style(&ctx);
583
584 assert_eq!(style, Some(StrongStyle::Underscore));
585 }
586
587 #[test]
588 fn test_detect_style_none() {
589 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
590 let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
591 let style = rule.detect_style(&ctx);
592
593 assert_eq!(style, None);
594 }
595
596 #[test]
597 fn test_strong_in_code_block() {
598 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
599 let content = "```\n__strong__ in code\n```\n__strong__ outside";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602
603 assert_eq!(result.len(), 1);
605 assert_eq!(result[0].line, 4);
606 }
607
608 #[test]
609 fn test_strong_in_inline_code() {
610 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
611 let content = "Text with `__strong__` in code and __strong__ outside.";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614
615 assert_eq!(result.len(), 1);
617 }
618
619 #[test]
620 fn test_escaped_strong() {
621 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
622 let content = "This is \\__not strong\\__ but __this is__.";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let result = rule.check(&ctx).unwrap();
625
626 assert_eq!(result.len(), 1);
628 assert_eq!(result[0].line, 1);
629 assert_eq!(result[0].column, 30);
630 }
631
632 #[test]
633 fn test_fix_asterisks_to_underscores() {
634 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
635 let content = "This is **strong** text.";
636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637 let fixed = rule.fix(&ctx).unwrap();
638
639 assert_eq!(fixed, "This is __strong__ text.");
640 }
641
642 #[test]
643 fn test_fix_underscores_to_asterisks() {
644 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
645 let content = "This is __strong__ text.";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let fixed = rule.fix(&ctx).unwrap();
648
649 assert_eq!(fixed, "This is **strong** text.");
650 }
651
652 #[test]
653 fn test_fix_multiple_strong() {
654 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
655 let content = "First __strong__ and second __also strong__.";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let fixed = rule.fix(&ctx).unwrap();
658
659 assert_eq!(fixed, "First **strong** and second **also strong**.");
660 }
661
662 #[test]
663 fn test_fix_preserves_code_blocks() {
664 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
665 let content = "```\n__strong__ in code\n```\n__strong__ outside";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let fixed = rule.fix(&ctx).unwrap();
668
669 assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
670 }
671
672 #[test]
673 fn test_multiline_content() {
674 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
675 let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let result = rule.check(&ctx).unwrap();
678
679 assert_eq!(result.len(), 2);
680 assert_eq!(result[0].line, 1);
681 assert_eq!(result[1].line, 2);
682 }
683
684 #[test]
685 fn test_nested_emphasis() {
686 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
687 let content = "This has __strong with *emphasis* inside__.";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.check(&ctx).unwrap();
690
691 assert_eq!(result.len(), 1);
692 }
693
694 #[test]
695 fn test_empty_content() {
696 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
697 let content = "";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.check(&ctx).unwrap();
700
701 assert_eq!(result.len(), 0);
702 }
703
704 #[test]
705 fn test_default_config() {
706 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
707 let (name, _config) = rule.default_config_section().unwrap();
708 assert_eq!(name, "MD050");
709 }
710
711 #[test]
712 fn test_strong_in_links_not_flagged() {
713 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
714 let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
715
716Hint:
717
718- [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__`")
719
720
721[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx).unwrap();
724
725 assert_eq!(result.len(), 0);
727 }
728
729 #[test]
730 fn test_strong_in_links_vs_outside_links() {
731 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
732 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][]**.
733
734Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
735
736This is __real strong text__ that should be flagged.
737
738[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741
742 assert_eq!(result.len(), 1);
744 assert!(
745 result[0]
746 .message
747 .contains("Strong emphasis should use ** instead of __")
748 );
749 assert!(result[0].line > 4); }
752
753 #[test]
754 fn test_front_matter_not_flagged() {
755 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
756 let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759
760 assert_eq!(result.len(), 1);
762 assert_eq!(result[0].line, 6);
763 assert!(
764 result[0]
765 .message
766 .contains("Strong emphasis should use ** instead of __")
767 );
768 }
769
770 #[test]
771 fn test_html_tags_not_flagged() {
772 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
773 let content = r#"# Test
774
775This has HTML with underscores:
776
777<iframe src="https://example.com/__init__/__repr__"> </iframe>
778
779This __should be flagged__ as inconsistent."#;
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782
783 assert_eq!(result.len(), 1);
785 assert_eq!(result[0].line, 7);
786 assert!(
787 result[0]
788 .message
789 .contains("Strong emphasis should use ** instead of __")
790 );
791 }
792
793 #[test]
794 fn test_mkdocs_keys_notation_not_flagged() {
795 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
797 let content = "Press ++ctrl+alt+del++ to restart.";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
799 let result = rule.check(&ctx).unwrap();
800
801 assert!(
803 result.is_empty(),
804 "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
805 );
806 }
807
808 #[test]
809 fn test_mkdocs_caret_notation_not_flagged() {
810 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
812 let content = "This is ^^inserted^^ text.";
813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
814 let result = rule.check(&ctx).unwrap();
815
816 assert!(
817 result.is_empty(),
818 "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
819 );
820 }
821
822 #[test]
823 fn test_mkdocs_mark_notation_not_flagged() {
824 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
826 let content = "This is ==highlighted== text.";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
828 let result = rule.check(&ctx).unwrap();
829
830 assert!(
831 result.is_empty(),
832 "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
833 );
834 }
835
836 #[test]
837 fn test_mkdocs_mixed_content_with_real_strong() {
838 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
840 let content = "Press ++ctrl++ and __underscore strong__ here.";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
842 let result = rule.check(&ctx).unwrap();
843
844 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
846 assert!(
847 result[0]
848 .message
849 .contains("Strong emphasis should use ** instead of __")
850 );
851 }
852
853 #[test]
854 fn test_mkdocs_icon_shortcode_not_flagged() {
855 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
857 let content = "Click :material-check: and __this should be flagged__.";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
859 let result = rule.check(&ctx).unwrap();
860
861 assert_eq!(result.len(), 1);
863 assert!(
864 result[0]
865 .message
866 .contains("Strong emphasis should use ** instead of __")
867 );
868 }
869
870 #[test]
871 fn test_math_block_not_flagged() {
872 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
874 let content = r#"# Math Section
875
876$$
877E = mc^2
878x_1 + x_2 = y
879a**b = c
880$$
881
882This __should be flagged__ outside math.
883"#;
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
885 let result = rule.check(&ctx).unwrap();
886
887 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
889 assert!(result[0].line > 7, "Warning should be on line after math block");
890 }
891
892 #[test]
893 fn test_math_block_with_underscores_not_flagged() {
894 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
896 let content = r#"$$
897x_1 + x_2 + x__3 = y
898\alpha__\beta
899$$
900"#;
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
902 let result = rule.check(&ctx).unwrap();
903
904 assert!(
906 result.is_empty(),
907 "Math block content should not be flagged. Got: {result:?}"
908 );
909 }
910
911 #[test]
912 fn test_math_block_with_asterisks_not_flagged() {
913 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
915 let content = r#"$$
916a**b = c
9172 ** 3 = 8
918x***y
919$$
920"#;
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
922 let result = rule.check(&ctx).unwrap();
923
924 assert!(
926 result.is_empty(),
927 "Math block content should not be flagged. Got: {result:?}"
928 );
929 }
930
931 #[test]
932 fn test_math_block_fix_preserves_content() {
933 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
935 let content = r#"$$
936x__y = z
937$$
938
939This __word__ should change.
940"#;
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
942 let fixed = rule.fix(&ctx).unwrap();
943
944 assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
946 assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
948 }
949
950 #[test]
951 fn test_inline_math_simple() {
952 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
954 let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
956 let result = rule.check(&ctx).unwrap();
957
958 assert_eq!(
960 result.len(),
961 1,
962 "Expected 1 warning for strong outside math. Got: {result:?}"
963 );
964 }
965
966 #[test]
967 fn test_multiple_math_blocks_and_strong() {
968 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
970 let content = r#"# Document
971
972$$
973a = b
974$$
975
976This __should be flagged__ text.
977
978$$
979c = d
980$$
981"#;
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
983 let result = rule.check(&ctx).unwrap();
984
985 assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
987 assert!(result[0].message.contains("**"));
988 }
989
990 #[test]
991 fn test_html_tag_skip_consistency_between_check_and_fix() {
992 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
995
996 let content = r#"<a href="__test__">link</a>
997
998This __should be flagged__ text."#;
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000
1001 let check_result = rule.check(&ctx).unwrap();
1002 let fix_result = rule.fix(&ctx).unwrap();
1003
1004 assert_eq!(
1006 check_result.len(),
1007 1,
1008 "check() should flag exactly one emphasis outside HTML tags"
1009 );
1010 assert!(check_result[0].message.contains("**"));
1011
1012 assert!(
1014 fix_result.contains("**should be flagged**"),
1015 "fix() should convert the flagged emphasis"
1016 );
1017 assert!(
1018 fix_result.contains("__test__"),
1019 "fix() should not modify emphasis inside HTML tags"
1020 );
1021 }
1022
1023 #[test]
1024 fn test_detect_style_ignores_emphasis_in_inline_code_on_table_lines() {
1025 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
1028
1029 let content = "| `__code__` | **real** |\n| --- | --- |\n| data | data |";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033
1034 let style = rule.detect_style(&ctx);
1035 assert_eq!(style, Some(StrongStyle::Asterisk));
1037 }
1038}