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 = ctx.raw_lines();
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 skip_context = 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 !skip_context
134 && !ctx.is_in_code_block_or_span(m.start())
135 && !self.is_in_link(ctx, m.start())
136 && !self.is_in_html_tag(ctx, m.start())
137 && !self.is_in_html_code_content(ctx, m.start())
138 && !in_mkdocs_markup
139 && !is_in_math_context(ctx, m.start())
140 {
141 asterisk_count += 1;
142 }
143 }
144
145 let mut underscore_count = 0;
146 for m in BOLD_UNDERSCORE_REGEX.find_iter(content) {
147 let (line_num, col) = ctx.offset_to_line_col(m.start());
149 let skip_context = ctx
150 .line_info(line_num)
151 .map(|info| info.in_front_matter)
152 .unwrap_or(false);
153
154 let in_mkdocs_markup = lines
156 .get(line_num.saturating_sub(1))
157 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
158
159 if !skip_context
160 && !ctx.is_in_code_block_or_span(m.start())
161 && !self.is_in_link(ctx, m.start())
162 && !self.is_in_html_tag(ctx, m.start())
163 && !self.is_in_html_code_content(ctx, m.start())
164 && !in_mkdocs_markup
165 && !is_in_math_context(ctx, m.start())
166 {
167 underscore_count += 1;
168 }
169 }
170
171 match (asterisk_count, underscore_count) {
172 (0, 0) => None,
173 (_, 0) => Some(StrongStyle::Asterisk),
174 (0, _) => Some(StrongStyle::Underscore),
175 (a, u) => {
176 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 if self.is_in_html_tag(ctx, match_byte_pos) {
263 continue;
264 }
265
266 if !self.is_escaped(line, m.start()) {
267 let text = &line[m.start() + 2..m.end() - 2];
268
269 let message = match target_style {
276 StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
277 StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
278 StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
279 };
280
281 let (start_line, start_col, end_line, end_col) =
283 calculate_match_range(line_num + 1, line, m.start(), m.len());
284
285 warnings.push(LintWarning {
286 rule_name: Some(self.name().to_string()),
287 line: start_line,
288 column: start_col,
289 end_line,
290 end_column: end_col,
291 message: message.to_string(),
292 severity: Severity::Warning,
293 fix: Some(Fix {
294 range: line_index.line_col_to_byte_range_with_length(line_num + 1, m.start() + 1, m.len()),
295 replacement: match target_style {
296 StrongStyle::Asterisk => format!("**{text}**"),
297 StrongStyle::Underscore => format!("__{text}__"),
298 StrongStyle::Consistent => format!("**{text}**"),
299 },
300 }),
301 });
302 }
303 }
304 }
305
306 Ok(warnings)
307 }
308
309 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
310 let content = ctx.content;
311
312 let target_style = match self.config.style {
313 StrongStyle::Consistent => self.detect_style(ctx).unwrap_or(StrongStyle::Asterisk),
314 _ => self.config.style,
315 };
316
317 let strong_regex = match target_style {
318 StrongStyle::Asterisk => &*BOLD_UNDERSCORE_REGEX,
319 StrongStyle::Underscore => &*BOLD_ASTERISK_REGEX,
320 StrongStyle::Consistent => {
321 &*BOLD_UNDERSCORE_REGEX
324 }
325 };
326
327 let lines = ctx.raw_lines();
329
330 let matches: Vec<(usize, usize)> = strong_regex
331 .find_iter(content)
332 .filter(|m| {
333 let (line_num, col) = ctx.offset_to_line_col(m.start());
335 if let Some(line_info) = ctx.line_info(line_num)
336 && line_info.in_front_matter
337 {
338 return false;
339 }
340 let in_mkdocs_markup = lines
342 .get(line_num.saturating_sub(1))
343 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
344 !ctx.is_in_code_block_or_span(m.start())
345 && !self.is_in_link(ctx, m.start())
346 && !self.is_in_html_tag(ctx, m.start())
347 && !self.is_in_html_code_content(ctx, m.start())
348 && !in_mkdocs_markup
349 && !is_in_math_context(ctx, m.start())
350 })
351 .filter(|m| !self.is_escaped(content, m.start()))
352 .map(|m| (m.start(), m.end()))
353 .collect();
354
355 let mut result = content.to_string();
358 for (start, end) in matches.into_iter().rev() {
359 let text = &result[start + 2..end - 2];
360 let replacement = match target_style {
361 StrongStyle::Asterisk => format!("**{text}**"),
362 StrongStyle::Underscore => format!("__{text}__"),
363 StrongStyle::Consistent => {
364 format!("**{text}**")
367 }
368 };
369 result.replace_range(start..end, &replacement);
370 }
371
372 Ok(result)
373 }
374
375 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
377 ctx.content.is_empty() || !ctx.likely_has_emphasis()
379 }
380
381 fn as_any(&self) -> &dyn std::any::Any {
382 self
383 }
384
385 fn default_config_section(&self) -> Option<(String, toml::Value)> {
386 let json_value = serde_json::to_value(&self.config).ok()?;
387 Some((
388 self.name().to_string(),
389 crate::rule_config_serde::json_to_toml_value(&json_value)?,
390 ))
391 }
392
393 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
394 where
395 Self: Sized,
396 {
397 let rule_config = crate::rule_config_serde::load_rule_config::<MD050Config>(config);
398 Box::new(Self::from_config_struct(rule_config))
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::lint_context::LintContext;
406
407 #[test]
408 fn test_asterisk_style_with_asterisks() {
409 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
410 let content = "This is **strong text** here.";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let result = rule.check(&ctx).unwrap();
413
414 assert_eq!(result.len(), 0);
415 }
416
417 #[test]
418 fn test_asterisk_style_with_underscores() {
419 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
420 let content = "This is __strong text__ here.";
421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422 let result = rule.check(&ctx).unwrap();
423
424 assert_eq!(result.len(), 1);
425 assert!(
426 result[0]
427 .message
428 .contains("Strong emphasis should use ** instead of __")
429 );
430 assert_eq!(result[0].line, 1);
431 assert_eq!(result[0].column, 9);
432 }
433
434 #[test]
435 fn test_underscore_style_with_underscores() {
436 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
437 let content = "This is __strong text__ here.";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440
441 assert_eq!(result.len(), 0);
442 }
443
444 #[test]
445 fn test_underscore_style_with_asterisks() {
446 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
447 let content = "This is **strong text** here.";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.check(&ctx).unwrap();
450
451 assert_eq!(result.len(), 1);
452 assert!(
453 result[0]
454 .message
455 .contains("Strong emphasis should use __ instead of **")
456 );
457 }
458
459 #[test]
460 fn test_consistent_style_first_asterisk() {
461 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
462 let content = "First **strong** then __also strong__.";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464 let result = rule.check(&ctx).unwrap();
465
466 assert_eq!(result.len(), 1);
468 assert!(
469 result[0]
470 .message
471 .contains("Strong emphasis should use ** instead of __")
472 );
473 }
474
475 #[test]
476 fn test_consistent_style_tie_prefers_asterisk() {
477 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
478 let content = "First __strong__ then **also strong**.";
479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480 let result = rule.check(&ctx).unwrap();
481
482 assert_eq!(result.len(), 1);
485 assert!(
486 result[0]
487 .message
488 .contains("Strong emphasis should use ** instead of __")
489 );
490 }
491
492 #[test]
493 fn test_detect_style_asterisk() {
494 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
495 let ctx = LintContext::new(
496 "This has **strong** text.",
497 crate::config::MarkdownFlavor::Standard,
498 None,
499 );
500 let style = rule.detect_style(&ctx);
501
502 assert_eq!(style, Some(StrongStyle::Asterisk));
503 }
504
505 #[test]
506 fn test_detect_style_underscore() {
507 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
508 let ctx = LintContext::new(
509 "This has __strong__ text.",
510 crate::config::MarkdownFlavor::Standard,
511 None,
512 );
513 let style = rule.detect_style(&ctx);
514
515 assert_eq!(style, Some(StrongStyle::Underscore));
516 }
517
518 #[test]
519 fn test_detect_style_none() {
520 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
521 let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
522 let style = rule.detect_style(&ctx);
523
524 assert_eq!(style, None);
525 }
526
527 #[test]
528 fn test_strong_in_code_block() {
529 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
530 let content = "```\n__strong__ in code\n```\n__strong__ outside";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533
534 assert_eq!(result.len(), 1);
536 assert_eq!(result[0].line, 4);
537 }
538
539 #[test]
540 fn test_strong_in_inline_code() {
541 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
542 let content = "Text with `__strong__` in code and __strong__ outside.";
543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544 let result = rule.check(&ctx).unwrap();
545
546 assert_eq!(result.len(), 1);
548 }
549
550 #[test]
551 fn test_escaped_strong() {
552 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
553 let content = "This is \\__not strong\\__ but __this is__.";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556
557 assert_eq!(result.len(), 1);
559 assert_eq!(result[0].line, 1);
560 assert_eq!(result[0].column, 30);
561 }
562
563 #[test]
564 fn test_fix_asterisks_to_underscores() {
565 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
566 let content = "This is **strong** text.";
567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568 let fixed = rule.fix(&ctx).unwrap();
569
570 assert_eq!(fixed, "This is __strong__ text.");
571 }
572
573 #[test]
574 fn test_fix_underscores_to_asterisks() {
575 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
576 let content = "This is __strong__ text.";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let fixed = rule.fix(&ctx).unwrap();
579
580 assert_eq!(fixed, "This is **strong** text.");
581 }
582
583 #[test]
584 fn test_fix_multiple_strong() {
585 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
586 let content = "First __strong__ and second __also strong__.";
587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588 let fixed = rule.fix(&ctx).unwrap();
589
590 assert_eq!(fixed, "First **strong** and second **also strong**.");
591 }
592
593 #[test]
594 fn test_fix_preserves_code_blocks() {
595 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
596 let content = "```\n__strong__ in code\n```\n__strong__ outside";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598 let fixed = rule.fix(&ctx).unwrap();
599
600 assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
601 }
602
603 #[test]
604 fn test_multiline_content() {
605 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
606 let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609
610 assert_eq!(result.len(), 2);
611 assert_eq!(result[0].line, 1);
612 assert_eq!(result[1].line, 2);
613 }
614
615 #[test]
616 fn test_nested_emphasis() {
617 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
618 let content = "This has __strong with *emphasis* inside__.";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621
622 assert_eq!(result.len(), 1);
623 }
624
625 #[test]
626 fn test_empty_content() {
627 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
628 let content = "";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630 let result = rule.check(&ctx).unwrap();
631
632 assert_eq!(result.len(), 0);
633 }
634
635 #[test]
636 fn test_default_config() {
637 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
638 let (name, _config) = rule.default_config_section().unwrap();
639 assert_eq!(name, "MD050");
640 }
641
642 #[test]
643 fn test_strong_in_links_not_flagged() {
644 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
645 let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
646
647Hint:
648
649- [An article on something](https://blog.yuo.be/2018/08/16/__init_subclass__-a-simpler-way-to-implement-class-registries-in-python/ "Some details on using `__init_subclass__`")
650
651
652[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655
656 assert_eq!(result.len(), 0);
658 }
659
660 #[test]
661 fn test_strong_in_links_vs_outside_links() {
662 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
663 let content = r#"We're doing this because generator functions return a generator object which [is an iterator][generators are iterators] and **we need `__iter__` to return an [iterator][]**.
664
665Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
666
667This is __real strong text__ that should be flagged.
668
669[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672
673 assert_eq!(result.len(), 1);
675 assert!(
676 result[0]
677 .message
678 .contains("Strong emphasis should use ** instead of __")
679 );
680 assert!(result[0].line > 4); }
683
684 #[test]
685 fn test_front_matter_not_flagged() {
686 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
687 let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.check(&ctx).unwrap();
690
691 assert_eq!(result.len(), 1);
693 assert_eq!(result[0].line, 6);
694 assert!(
695 result[0]
696 .message
697 .contains("Strong emphasis should use ** instead of __")
698 );
699 }
700
701 #[test]
702 fn test_html_tags_not_flagged() {
703 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
704 let content = r#"# Test
705
706This has HTML with underscores:
707
708<iframe src="https://example.com/__init__/__repr__"> </iframe>
709
710This __should be flagged__ as inconsistent."#;
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713
714 assert_eq!(result.len(), 1);
716 assert_eq!(result[0].line, 7);
717 assert!(
718 result[0]
719 .message
720 .contains("Strong emphasis should use ** instead of __")
721 );
722 }
723
724 #[test]
725 fn test_mkdocs_keys_notation_not_flagged() {
726 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
728 let content = "Press ++ctrl+alt+del++ to restart.";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
730 let result = rule.check(&ctx).unwrap();
731
732 assert!(
734 result.is_empty(),
735 "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
736 );
737 }
738
739 #[test]
740 fn test_mkdocs_caret_notation_not_flagged() {
741 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
743 let content = "This is ^^inserted^^ text.";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
745 let result = rule.check(&ctx).unwrap();
746
747 assert!(
748 result.is_empty(),
749 "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
750 );
751 }
752
753 #[test]
754 fn test_mkdocs_mark_notation_not_flagged() {
755 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
757 let content = "This is ==highlighted== text.";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
759 let result = rule.check(&ctx).unwrap();
760
761 assert!(
762 result.is_empty(),
763 "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
764 );
765 }
766
767 #[test]
768 fn test_mkdocs_mixed_content_with_real_strong() {
769 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
771 let content = "Press ++ctrl++ and __underscore strong__ here.";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
773 let result = rule.check(&ctx).unwrap();
774
775 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
777 assert!(
778 result[0]
779 .message
780 .contains("Strong emphasis should use ** instead of __")
781 );
782 }
783
784 #[test]
785 fn test_mkdocs_icon_shortcode_not_flagged() {
786 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
788 let content = "Click :material-check: and __this should be flagged__.";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
790 let result = rule.check(&ctx).unwrap();
791
792 assert_eq!(result.len(), 1);
794 assert!(
795 result[0]
796 .message
797 .contains("Strong emphasis should use ** instead of __")
798 );
799 }
800
801 #[test]
802 fn test_math_block_not_flagged() {
803 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
805 let content = r#"# Math Section
806
807$$
808E = mc^2
809x_1 + x_2 = y
810a**b = c
811$$
812
813This __should be flagged__ outside math.
814"#;
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
816 let result = rule.check(&ctx).unwrap();
817
818 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
820 assert!(result[0].line > 7, "Warning should be on line after math block");
821 }
822
823 #[test]
824 fn test_math_block_with_underscores_not_flagged() {
825 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
827 let content = r#"$$
828x_1 + x_2 + x__3 = y
829\alpha__\beta
830$$
831"#;
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
833 let result = rule.check(&ctx).unwrap();
834
835 assert!(
837 result.is_empty(),
838 "Math block content should not be flagged. Got: {result:?}"
839 );
840 }
841
842 #[test]
843 fn test_math_block_with_asterisks_not_flagged() {
844 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
846 let content = r#"$$
847a**b = c
8482 ** 3 = 8
849x***y
850$$
851"#;
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
853 let result = rule.check(&ctx).unwrap();
854
855 assert!(
857 result.is_empty(),
858 "Math block content should not be flagged. Got: {result:?}"
859 );
860 }
861
862 #[test]
863 fn test_math_block_fix_preserves_content() {
864 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
866 let content = r#"$$
867x__y = z
868$$
869
870This __word__ should change.
871"#;
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
873 let fixed = rule.fix(&ctx).unwrap();
874
875 assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
877 assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
879 }
880
881 #[test]
882 fn test_inline_math_simple() {
883 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
885 let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
887 let result = rule.check(&ctx).unwrap();
888
889 assert_eq!(
891 result.len(),
892 1,
893 "Expected 1 warning for strong outside math. Got: {result:?}"
894 );
895 }
896
897 #[test]
898 fn test_multiple_math_blocks_and_strong() {
899 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
901 let content = r#"# Document
902
903$$
904a = b
905$$
906
907This __should be flagged__ text.
908
909$$
910c = d
911$$
912"#;
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
914 let result = rule.check(&ctx).unwrap();
915
916 assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
918 assert!(result[0].message.contains("**"));
919 }
920
921 #[test]
922 fn test_html_tag_skip_consistency_between_check_and_fix() {
923 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
926
927 let content = r#"<a href="__test__">link</a>
928
929This __should be flagged__ text."#;
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931
932 let check_result = rule.check(&ctx).unwrap();
933 let fix_result = rule.fix(&ctx).unwrap();
934
935 assert_eq!(
937 check_result.len(),
938 1,
939 "check() should flag exactly one emphasis outside HTML tags"
940 );
941 assert!(check_result[0].message.contains("**"));
942
943 assert!(
945 fix_result.contains("**should be flagged**"),
946 "fix() should convert the flagged emphasis"
947 );
948 assert!(
949 fix_result.contains("__test__"),
950 "fix() should not modify emphasis inside HTML tags"
951 );
952 }
953}