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
12mod md050_config;
13use md050_config::MD050Config;
14
15#[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 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
38 for link in &ctx.links {
40 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
41 return true;
42 }
43 }
44
45 for image in &ctx.images {
47 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
48 return true;
49 }
50 }
51
52 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 fn is_in_html_tag(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
66 for html_tag in ctx.html_tags().iter() {
68 if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
71 return true;
72 }
73 }
74 false
75 }
76
77 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 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 continue;
93 } else if !tag.is_closing {
94 open_code_pos = Some(tag.byte_end);
96 } else if tag.is_closing && open_code_pos.is_some() {
97 if let Some(open_pos) = open_code_pos
99 && byte_pos >= open_pos
100 && byte_pos < tag.byte_offset
101 {
102 return true;
104 }
105 open_code_pos = None;
106 }
107 }
108 }
109
110 open_code_pos.is_some() && byte_pos >= open_code_pos.unwrap()
112 }
113
114 fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
115 let content = ctx.content;
116 let lines: Vec<&str> = content.lines().collect();
117
118 let mut asterisk_count = 0;
120 for m in BOLD_ASTERISK_REGEX.find_iter(content) {
121 let (line_num, col) = ctx.offset_to_line_col(m.start());
123 let in_front_matter = ctx
124 .line_info(line_num)
125 .map(|info| info.in_front_matter)
126 .unwrap_or(false);
127
128 let in_mkdocs_markup = lines
130 .get(line_num.saturating_sub(1))
131 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
132
133 if !in_front_matter
134 && !ctx.is_in_code_block_or_span(m.start())
135 && !self.is_in_link(ctx, m.start())
136 && !self.is_in_html_tag(ctx, m.start())
137 && !self.is_in_html_code_content(ctx, m.start())
138 && !in_mkdocs_markup
139 && !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 let (line_num, col) = ctx.offset_to_line_col(m.start());
149 let in_front_matter = ctx
150 .line_info(line_num)
151 .map(|info| info.in_front_matter)
152 .unwrap_or(false);
153
154 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 !in_front_matter
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 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 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 &*BOLD_UNDERSCORE_REGEX
234 }
235 };
236
237 for (line_num, line) in content.lines().enumerate() {
238 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 let match_byte_pos = byte_pos + m.start();
250
251 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 let mut inside_html_tag = false;
264 for tag in ctx.html_tags().iter() {
265 if tag.byte_offset < match_byte_pos && match_byte_pos < tag.byte_end - 1 {
267 inside_html_tag = true;
268 break;
269 }
270 }
271 if inside_html_tag {
272 continue;
273 }
274
275 if !self.is_escaped(line, m.start()) {
276 let text = &line[m.start() + 2..m.end() - 2];
277
278 let message = match target_style {
285 StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
286 StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
287 StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
288 };
289
290 let (start_line, start_col, end_line, end_col) =
292 calculate_match_range(line_num + 1, line, m.start(), m.len());
293
294 warnings.push(LintWarning {
295 rule_name: Some(self.name().to_string()),
296 line: start_line,
297 column: start_col,
298 end_line,
299 end_column: end_col,
300 message: message.to_string(),
301 severity: Severity::Warning,
302 fix: Some(Fix {
303 range: line_index.line_col_to_byte_range(line_num + 1, m.start() + 1),
304 replacement: match target_style {
305 StrongStyle::Asterisk => format!("**{text}**"),
306 StrongStyle::Underscore => format!("__{text}__"),
307 StrongStyle::Consistent => format!("**{text}**"),
308 },
309 }),
310 });
311 }
312 }
313 }
314
315 Ok(warnings)
316 }
317
318 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
319 let content = ctx.content;
320
321 let target_style = match self.config.style {
322 StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
323 _ => self.config.style,
324 };
325
326 let strong_regex = match target_style {
327 StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
328 StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
329 StrongStyle::Consistent => {
330 &*BOLD_UNDERSCORE_REGEX
333 }
334 };
335
336 let lines: Vec<&str> = content.lines().collect();
338
339 let matches: Vec<(usize, usize)> = strong_regex
340 .find_iter(content)
341 .filter(|m| {
342 let (line_num, col) = ctx.offset_to_line_col(m.start());
344 if let Some(line_info) = ctx.line_info(line_num)
345 && line_info.in_front_matter
346 {
347 return false;
348 }
349 let in_mkdocs_markup = lines
351 .get(line_num.saturating_sub(1))
352 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
353 !ctx.is_in_code_block_or_span(m.start())
354 && !self.is_in_link(ctx, m.start())
355 && !self.is_in_html_tag(ctx, m.start())
356 && !self.is_in_html_code_content(ctx, m.start())
357 && !in_mkdocs_markup
358 && !is_in_math_context(ctx, m.start())
359 })
360 .filter(|m| !self.is_escaped(content, m.start()))
361 .map(|m| (m.start(), m.end()))
362 .collect();
363
364 let mut result = content.to_string();
367 for (start, end) in matches.into_iter().rev() {
368 let text = &result[start + 2..end - 2];
369 let replacement = match target_style {
370 StrongStyle::Asterisk => format!("**{text}**"),
371 StrongStyle::Underscore => format!("__{text}__"),
372 StrongStyle::Consistent => {
373 format!("**{text}**")
376 }
377 };
378 result.replace_range(start..end, &replacement);
379 }
380
381 Ok(result)
382 }
383
384 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
386 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 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 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 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 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 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 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 assert_eq!(result.len(), 1);
684 assert!(
685 result[0]
686 .message
687 .contains("Strong emphasis should use ** instead of __")
688 );
689 assert!(result[0].line > 4); }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
886 assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
888 }
889
890 #[test]
891 fn test_inline_math_simple() {
892 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 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 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 assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
927 assert!(result[0].message.contains("**"));
928 }
929}