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
8fn 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 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 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 break;
50 }
51 } else {
52 i += 1;
53 }
54 }
55
56 false
57}
58
59struct StrongSpan {
61 range: std::ops::Range<usize>,
63 style: StrongStyle,
65}
66
67fn 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#[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 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
117 for link in &ctx.links {
119 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
120 return true;
121 }
122 }
123
124 for image in &ctx.images {
126 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
127 return true;
128 }
129 }
130
131 if ctx.is_in_reference_def(byte_pos) {
133 return true;
134 }
135
136 false
137 }
138
139 fn is_in_html_tag(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
141 for html_tag in ctx.html_tags().iter() {
143 if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
146 return true;
147 }
148 }
149 false
150 }
151
152 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 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 continue;
168 } else if !tag.is_closing {
169 open_code_pos = Some(tag.byte_end);
171 } else if tag.is_closing && open_code_pos.is_some() {
172 if let Some(open_pos) = open_code_pos
174 && byte_pos >= open_pos
175 && byte_pos < tag.byte_offset
176 {
177 return true;
179 }
180 open_code_pos = None;
181 }
182 }
183 }
184
185 open_code_pos.is_some() && byte_pos >= open_code_pos.unwrap()
187 }
188
189 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 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 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 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 (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 if span.style == target_style {
291 continue;
292 }
293
294 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 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 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 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 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
930 #[test]
931 fn test_html_tag_skip_consistency_between_check_and_fix() {
932 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 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 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 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
968
969 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 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}