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