1use crate::utils::range_utils::calculate_match_range;
2
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use crate::rules::strong_style::StrongStyle;
5use crate::utils::code_block_utils::StrongSpanDetail;
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
59fn span_style(span: &StrongSpanDetail) -> StrongStyle {
61 if span.is_asterisk {
62 StrongStyle::Asterisk
63 } else {
64 StrongStyle::Underscore
65 }
66}
67
68mod md050_config;
69use md050_config::MD050Config;
70
71#[derive(Debug, Default, Clone)]
77pub struct MD050StrongStyle {
78 config: MD050Config,
79}
80
81impl MD050StrongStyle {
82 pub fn new(style: StrongStyle) -> Self {
83 Self {
84 config: MD050Config { style },
85 }
86 }
87
88 pub fn from_config_struct(config: MD050Config) -> Self {
89 Self { config }
90 }
91
92 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
95 ctx.is_in_link(byte_pos)
96 }
97
98 fn is_in_html_tag(html_tags: &[crate::lint_context::HtmlTag], byte_pos: usize) -> bool {
100 let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
101 idx > 0 && byte_pos < html_tags[idx - 1].byte_end
102 }
103
104 fn is_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
107 let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
108 idx > 0 && byte_pos < code_ranges[idx - 1].1
109 }
110
111 fn compute_html_code_ranges(html_tags: &[crate::lint_context::HtmlTag]) -> Vec<(usize, usize)> {
114 let mut ranges = Vec::new();
115 let mut open_code_end: Option<usize> = None;
116
117 for tag in html_tags {
118 if tag.tag_name == "code" {
119 if tag.is_self_closing {
120 continue;
121 } else if !tag.is_closing {
122 open_code_end = Some(tag.byte_end);
123 } else if tag.is_closing {
124 if let Some(start) = open_code_end {
125 ranges.push((start, tag.byte_offset));
126 }
127 open_code_end = None;
128 }
129 }
130 }
131 if let Some(start) = open_code_end {
133 ranges.push((start, usize::MAX));
134 }
135 ranges
136 }
137
138 fn should_skip_span(
140 &self,
141 ctx: &crate::lint_context::LintContext,
142 html_tags: &[crate::lint_context::HtmlTag],
143 html_code_ranges: &[(usize, usize)],
144 span_start: usize,
145 ) -> bool {
146 let lines = ctx.raw_lines();
147 let (line_num, col) = ctx.offset_to_line_col(span_start);
148
149 if ctx
151 .line_info(line_num)
152 .map(|info| info.in_front_matter || info.in_mkdocstrings)
153 .unwrap_or(false)
154 {
155 return true;
156 }
157
158 let in_mkdocs_markup = lines
160 .get(line_num.saturating_sub(1))
161 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
162
163 let in_inline_code = lines
165 .get(line_num.saturating_sub(1))
166 .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
167
168 ctx.is_in_code_block_or_span(span_start)
169 || in_inline_code
170 || Self::is_in_link(ctx, span_start)
171 || Self::is_in_html_tag(html_tags, span_start)
172 || Self::is_in_html_code_content(html_code_ranges, span_start)
173 || in_mkdocs_markup
174 || is_in_math_context(ctx, span_start)
175 || is_in_jsx_expression(ctx, span_start)
176 || is_in_mdx_comment(ctx, span_start)
177 }
178
179 #[cfg(test)]
180 fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
181 let html_tags = ctx.html_tags();
182 let html_code_ranges = Self::compute_html_code_ranges(&html_tags);
183 self.detect_style_from_spans(ctx, &html_tags, &html_code_ranges, &ctx.strong_spans)
184 }
185
186 fn detect_style_from_spans(
187 &self,
188 ctx: &crate::lint_context::LintContext,
189 html_tags: &[crate::lint_context::HtmlTag],
190 html_code_ranges: &[(usize, usize)],
191 spans: &[StrongSpanDetail],
192 ) -> Option<StrongStyle> {
193 let mut asterisk_count = 0;
194 let mut underscore_count = 0;
195
196 for span in spans {
197 if self.should_skip_span(ctx, html_tags, html_code_ranges, span.start) {
198 continue;
199 }
200
201 match span_style(span) {
202 StrongStyle::Asterisk => asterisk_count += 1,
203 StrongStyle::Underscore => underscore_count += 1,
204 StrongStyle::Consistent => {}
205 }
206 }
207
208 match (asterisk_count, underscore_count) {
209 (0, 0) => None,
210 (_, 0) => Some(StrongStyle::Asterisk),
211 (0, _) => Some(StrongStyle::Underscore),
212 (a, u) => {
214 if a >= u {
215 Some(StrongStyle::Asterisk)
216 } else {
217 Some(StrongStyle::Underscore)
218 }
219 }
220 }
221 }
222}
223
224impl Rule for MD050StrongStyle {
225 fn name(&self) -> &'static str {
226 "MD050"
227 }
228
229 fn description(&self) -> &'static str {
230 "Strong emphasis style should be consistent"
231 }
232
233 fn category(&self) -> RuleCategory {
234 RuleCategory::Emphasis
235 }
236
237 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
238 let content = ctx.content;
239 let line_index = &ctx.line_index;
240 let lines = ctx.raw_lines();
241
242 let mut warnings = Vec::new();
243
244 let spans = &ctx.strong_spans;
245 let html_tags = ctx.html_tags();
246 let html_code_ranges = Self::compute_html_code_ranges(&html_tags);
247
248 let target_style = match self.config.style {
249 StrongStyle::Consistent => {
250 let mut asterisk_count = 0usize;
254 let mut underscore_count = 0usize;
255 for span in spans {
256 if span.end - span.start < 4 {
257 continue;
258 }
259 match span_style(span) {
260 StrongStyle::Asterisk => asterisk_count += 1,
261 StrongStyle::Underscore => underscore_count += 1,
262 StrongStyle::Consistent => {}
263 }
264 }
265 match (asterisk_count, underscore_count) {
266 (0, 0) => StrongStyle::Asterisk,
267 (_, 0) => StrongStyle::Asterisk,
268 (0, _) => StrongStyle::Underscore,
269 (a, u) => {
270 if a >= u {
271 StrongStyle::Asterisk
272 } else {
273 StrongStyle::Underscore
274 }
275 }
276 }
277 }
278 _ => self.config.style,
279 };
280
281 for span in spans {
282 if span_style(span) == target_style {
284 continue;
285 }
286
287 if span.end - span.start < 4 {
289 continue;
290 }
291
292 if self.should_skip_span(ctx, &html_tags, &html_code_ranges, span.start) {
294 continue;
295 }
296
297 let (line_num, _col) = ctx.offset_to_line_col(span.start);
298 let line_start = line_index.get_line_start_byte(line_num).unwrap_or(0);
299 let line_content = lines.get(line_num - 1).unwrap_or(&"");
300 let match_start_in_line = span.start - line_start;
301 let match_len = span.end - span.start;
302
303 let inner_text = &content[span.start + 2..span.end - 2];
304
305 let message = match target_style {
312 StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
313 StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
314 StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
315 };
316
317 let (start_line, start_col, end_line, end_col) =
318 calculate_match_range(line_num, line_content, match_start_in_line, match_len);
319
320 warnings.push(LintWarning {
321 rule_name: Some(self.name().to_string()),
322 line: start_line,
323 column: start_col,
324 end_line,
325 end_column: end_col,
326 message: message.to_string(),
327 severity: Severity::Warning,
328 fix: Some(Fix {
329 range: span.start..span.end,
330 replacement: match target_style {
331 StrongStyle::Asterisk => format!("**{inner_text}**"),
332 StrongStyle::Underscore => format!("__{inner_text}__"),
333 StrongStyle::Consistent => format!("**{inner_text}**"),
334 },
335 }),
336 });
337 }
338
339 Ok(warnings)
340 }
341
342 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
343 let content = ctx.content;
344
345 let spans = &ctx.strong_spans;
346 let html_tags = ctx.html_tags();
347
348 let html_code_ranges = Self::compute_html_code_ranges(&html_tags);
349
350 let target_style = match self.config.style {
351 StrongStyle::Consistent => self
352 .detect_style_from_spans(ctx, &html_tags, &html_code_ranges, spans)
353 .unwrap_or(StrongStyle::Asterisk),
354 _ => self.config.style,
355 };
356
357 let matches: Vec<std::ops::Range<usize>> = spans
359 .iter()
360 .filter(|span| span.end - span.start >= 4)
361 .filter(|span| span_style(span) != target_style)
362 .filter(|span| !self.should_skip_span(ctx, &html_tags, &html_code_ranges, span.start))
363 .filter(|span| {
364 let (line_num, _) = ctx.offset_to_line_col(span.start);
365 !ctx.inline_config().is_rule_disabled(self.name(), line_num)
366 })
367 .map(|span| span.start..span.end)
368 .collect();
369
370 let mut result = content.to_string();
372 for range in matches.into_iter().rev() {
373 let text = &result[range.start + 2..range.end - 2];
374 let replacement = match target_style {
375 StrongStyle::Asterisk => format!("**{text}**"),
376 StrongStyle::Underscore => format!("__{text}__"),
377 StrongStyle::Consistent => format!("**{text}**"),
378 };
379 result.replace_range(range, &replacement);
380 }
381
382 Ok(result)
383 }
384
385 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
387 ctx.content.is_empty() || !ctx.likely_has_emphasis()
389 }
390
391 fn as_any(&self) -> &dyn std::any::Any {
392 self
393 }
394
395 fn default_config_section(&self) -> Option<(String, toml::Value)> {
396 let json_value = serde_json::to_value(&self.config).ok()?;
397 Some((
398 self.name().to_string(),
399 crate::rule_config_serde::json_to_toml_value(&json_value)?,
400 ))
401 }
402
403 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
404 where
405 Self: Sized,
406 {
407 let rule_config = crate::rule_config_serde::load_rule_config::<MD050Config>(config);
408 Box::new(Self::from_config_struct(rule_config))
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::lint_context::LintContext;
416
417 #[test]
418 fn test_asterisk_style_with_asterisks() {
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(), 0);
425 }
426
427 #[test]
428 fn test_asterisk_style_with_underscores() {
429 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
430 let content = "This is __strong text__ here.";
431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
432 let result = rule.check(&ctx).unwrap();
433
434 assert_eq!(result.len(), 1);
435 assert!(
436 result[0]
437 .message
438 .contains("Strong emphasis should use ** instead of __")
439 );
440 assert_eq!(result[0].line, 1);
441 assert_eq!(result[0].column, 9);
442 }
443
444 #[test]
445 fn test_underscore_style_with_underscores() {
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(), 0);
452 }
453
454 #[test]
455 fn test_underscore_style_with_asterisks() {
456 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
457 let content = "This is **strong text** here.";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
459 let result = rule.check(&ctx).unwrap();
460
461 assert_eq!(result.len(), 1);
462 assert!(
463 result[0]
464 .message
465 .contains("Strong emphasis should use __ instead of **")
466 );
467 }
468
469 #[test]
470 fn test_consistent_style_first_asterisk() {
471 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
472 let content = "First **strong** then __also strong__.";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475
476 assert_eq!(result.len(), 1);
478 assert!(
479 result[0]
480 .message
481 .contains("Strong emphasis should use ** instead of __")
482 );
483 }
484
485 #[test]
486 fn test_consistent_style_tie_prefers_asterisk() {
487 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
488 let content = "First __strong__ then **also strong**.";
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490 let result = rule.check(&ctx).unwrap();
491
492 assert_eq!(result.len(), 1);
495 assert!(
496 result[0]
497 .message
498 .contains("Strong emphasis should use ** instead of __")
499 );
500 }
501
502 #[test]
503 fn test_detect_style_asterisk() {
504 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
505 let ctx = LintContext::new(
506 "This has **strong** text.",
507 crate::config::MarkdownFlavor::Standard,
508 None,
509 );
510 let style = rule.detect_style(&ctx);
511
512 assert_eq!(style, Some(StrongStyle::Asterisk));
513 }
514
515 #[test]
516 fn test_detect_style_underscore() {
517 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
518 let ctx = LintContext::new(
519 "This has __strong__ text.",
520 crate::config::MarkdownFlavor::Standard,
521 None,
522 );
523 let style = rule.detect_style(&ctx);
524
525 assert_eq!(style, Some(StrongStyle::Underscore));
526 }
527
528 #[test]
529 fn test_detect_style_none() {
530 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
531 let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
532 let style = rule.detect_style(&ctx);
533
534 assert_eq!(style, None);
535 }
536
537 #[test]
538 fn test_strong_in_code_block() {
539 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
540 let content = "```\n__strong__ in code\n```\n__strong__ outside";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx).unwrap();
543
544 assert_eq!(result.len(), 1);
546 assert_eq!(result[0].line, 4);
547 }
548
549 #[test]
550 fn test_strong_in_inline_code() {
551 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
552 let content = "Text with `__strong__` in code and __strong__ outside.";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let result = rule.check(&ctx).unwrap();
555
556 assert_eq!(result.len(), 1);
558 }
559
560 #[test]
561 fn test_escaped_strong() {
562 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
563 let content = "This is \\__not strong\\__ but __this is__.";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566
567 assert_eq!(result.len(), 1);
569 assert_eq!(result[0].line, 1);
570 assert_eq!(result[0].column, 30);
571 }
572
573 #[test]
574 fn test_fix_asterisks_to_underscores() {
575 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
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_underscores_to_asterisks() {
585 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
586 let content = "This is __strong__ text.";
587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588 let fixed = rule.fix(&ctx).unwrap();
589
590 assert_eq!(fixed, "This is **strong** text.");
591 }
592
593 #[test]
594 fn test_fix_multiple_strong() {
595 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
596 let content = "First __strong__ and second __also strong__.";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598 let fixed = rule.fix(&ctx).unwrap();
599
600 assert_eq!(fixed, "First **strong** and second **also strong**.");
601 }
602
603 #[test]
604 fn test_fix_preserves_code_blocks() {
605 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
606 let content = "```\n__strong__ in code\n```\n__strong__ outside";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let fixed = rule.fix(&ctx).unwrap();
609
610 assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
611 }
612
613 #[test]
614 fn test_multiline_content() {
615 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
616 let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619
620 assert_eq!(result.len(), 2);
621 assert_eq!(result[0].line, 1);
622 assert_eq!(result[1].line, 2);
623 }
624
625 #[test]
626 fn test_nested_emphasis() {
627 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
628 let content = "This has __strong with *emphasis* inside__.";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630 let result = rule.check(&ctx).unwrap();
631
632 assert_eq!(result.len(), 1);
633 }
634
635 #[test]
636 fn test_empty_content() {
637 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
638 let content = "";
639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640 let result = rule.check(&ctx).unwrap();
641
642 assert_eq!(result.len(), 0);
643 }
644
645 #[test]
646 fn test_default_config() {
647 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
648 let (name, _config) = rule.default_config_section().unwrap();
649 assert_eq!(name, "MD050");
650 }
651
652 #[test]
653 fn test_strong_in_links_not_flagged() {
654 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
655 let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
656
657Hint:
658
659- [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__`")
660
661
662[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665
666 assert_eq!(result.len(), 0);
668 }
669
670 #[test]
671 fn test_strong_in_links_vs_outside_links() {
672 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
673 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][]**.
674
675Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
676
677This is __real strong text__ that should be flagged.
678
679[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let result = rule.check(&ctx).unwrap();
682
683 assert_eq!(result.len(), 1);
685 assert!(
686 result[0]
687 .message
688 .contains("Strong emphasis should use ** instead of __")
689 );
690 assert!(result[0].line > 4); }
693
694 #[test]
695 fn test_front_matter_not_flagged() {
696 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
697 let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.check(&ctx).unwrap();
700
701 assert_eq!(result.len(), 1);
703 assert_eq!(result[0].line, 6);
704 assert!(
705 result[0]
706 .message
707 .contains("Strong emphasis should use ** instead of __")
708 );
709 }
710
711 #[test]
712 fn test_html_tags_not_flagged() {
713 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
714 let content = r#"# Test
715
716This has HTML with underscores:
717
718<iframe src="https://example.com/__init__/__repr__"> </iframe>
719
720This __should be flagged__ as inconsistent."#;
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let result = rule.check(&ctx).unwrap();
723
724 assert_eq!(result.len(), 1);
726 assert_eq!(result[0].line, 7);
727 assert!(
728 result[0]
729 .message
730 .contains("Strong emphasis should use ** instead of __")
731 );
732 }
733
734 #[test]
735 fn test_mkdocs_keys_notation_not_flagged() {
736 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
738 let content = "Press ++ctrl+alt+del++ to restart.";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
740 let result = rule.check(&ctx).unwrap();
741
742 assert!(
744 result.is_empty(),
745 "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
746 );
747 }
748
749 #[test]
750 fn test_mkdocs_caret_notation_not_flagged() {
751 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
753 let content = "This is ^^inserted^^ text.";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
755 let result = rule.check(&ctx).unwrap();
756
757 assert!(
758 result.is_empty(),
759 "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
760 );
761 }
762
763 #[test]
764 fn test_mkdocs_mark_notation_not_flagged() {
765 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
767 let content = "This is ==highlighted== text.";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
769 let result = rule.check(&ctx).unwrap();
770
771 assert!(
772 result.is_empty(),
773 "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
774 );
775 }
776
777 #[test]
778 fn test_mkdocs_mixed_content_with_real_strong() {
779 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
781 let content = "Press ++ctrl++ and __underscore strong__ here.";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
783 let result = rule.check(&ctx).unwrap();
784
785 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
787 assert!(
788 result[0]
789 .message
790 .contains("Strong emphasis should use ** instead of __")
791 );
792 }
793
794 #[test]
795 fn test_mkdocs_icon_shortcode_not_flagged() {
796 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
798 let content = "Click :material-check: and __this should be flagged__.";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
800 let result = rule.check(&ctx).unwrap();
801
802 assert_eq!(result.len(), 1);
804 assert!(
805 result[0]
806 .message
807 .contains("Strong emphasis should use ** instead of __")
808 );
809 }
810
811 #[test]
812 fn test_math_block_not_flagged() {
813 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
815 let content = r#"# Math Section
816
817$$
818E = mc^2
819x_1 + x_2 = y
820a**b = c
821$$
822
823This __should be flagged__ outside math.
824"#;
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
826 let result = rule.check(&ctx).unwrap();
827
828 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
830 assert!(result[0].line > 7, "Warning should be on line after math block");
831 }
832
833 #[test]
834 fn test_math_block_with_underscores_not_flagged() {
835 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
837 let content = r#"$$
838x_1 + x_2 + x__3 = y
839\alpha__\beta
840$$
841"#;
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
843 let result = rule.check(&ctx).unwrap();
844
845 assert!(
847 result.is_empty(),
848 "Math block content should not be flagged. Got: {result:?}"
849 );
850 }
851
852 #[test]
853 fn test_math_block_with_asterisks_not_flagged() {
854 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
856 let content = r#"$$
857a**b = c
8582 ** 3 = 8
859x***y
860$$
861"#;
862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
863 let result = rule.check(&ctx).unwrap();
864
865 assert!(
867 result.is_empty(),
868 "Math block content should not be flagged. Got: {result:?}"
869 );
870 }
871
872 #[test]
873 fn test_math_block_fix_preserves_content() {
874 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
876 let content = r#"$$
877x__y = z
878$$
879
880This __word__ should change.
881"#;
882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
883 let fixed = rule.fix(&ctx).unwrap();
884
885 assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
887 assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
889 }
890
891 #[test]
892 fn test_inline_math_simple() {
893 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
895 let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
897 let result = rule.check(&ctx).unwrap();
898
899 assert_eq!(
901 result.len(),
902 1,
903 "Expected 1 warning for strong outside math. Got: {result:?}"
904 );
905 }
906
907 #[test]
908 fn test_multiple_math_blocks_and_strong() {
909 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
911 let content = r#"# Document
912
913$$
914a = b
915$$
916
917This __should be flagged__ text.
918
919$$
920c = d
921$$
922"#;
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
924 let result = rule.check(&ctx).unwrap();
925
926 assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
928 assert!(result[0].message.contains("**"));
929 }
930
931 #[test]
932 fn test_html_tag_skip_consistency_between_check_and_fix() {
933 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
936
937 let content = r#"<a href="__test__">link</a>
938
939This __should be flagged__ text."#;
940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941
942 let check_result = rule.check(&ctx).unwrap();
943 let fix_result = rule.fix(&ctx).unwrap();
944
945 assert_eq!(
947 check_result.len(),
948 1,
949 "check() should flag exactly one emphasis outside HTML tags"
950 );
951 assert!(check_result[0].message.contains("**"));
952
953 assert!(
955 fix_result.contains("**should be flagged**"),
956 "fix() should convert the flagged emphasis"
957 );
958 assert!(
959 fix_result.contains("__test__"),
960 "fix() should not modify emphasis inside HTML tags"
961 );
962 }
963
964 #[test]
965 fn test_detect_style_ignores_emphasis_in_inline_code_on_table_lines() {
966 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
969
970 let content = "| `__code__` | **real** |\n| --- | --- |\n| data | data |";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974
975 let style = rule.detect_style(&ctx);
976 assert_eq!(style, Some(StrongStyle::Asterisk));
978 }
979
980 #[test]
981 fn test_five_underscores_not_flagged() {
982 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
983 let content = "This is a series of underscores: _____";
984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985 let result = rule.check(&ctx).unwrap();
986 assert!(
987 result.is_empty(),
988 "_____ should not be flagged as strong emphasis. Got: {result:?}"
989 );
990 }
991
992 #[test]
993 fn test_five_asterisks_not_flagged() {
994 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
995 let content = "This is a series of asterisks: *****";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998 assert!(
999 result.is_empty(),
1000 "***** should not be flagged as strong emphasis. Got: {result:?}"
1001 );
1002 }
1003
1004 #[test]
1005 fn test_five_underscores_with_frontmatter_not_flagged() {
1006 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1007 let content = "---\ntitle: Level 1 heading\n---\n\nThis is a series of underscores: _____\n";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let result = rule.check(&ctx).unwrap();
1010 assert!(result.is_empty(), "_____ should not be flagged. Got: {result:?}");
1011 }
1012
1013 #[test]
1014 fn test_four_underscores_not_flagged() {
1015 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1016 let content = "This is: ____";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let result = rule.check(&ctx).unwrap();
1019 assert!(result.is_empty(), "____ should not be flagged. Got: {result:?}");
1020 }
1021
1022 #[test]
1023 fn test_four_asterisks_not_flagged() {
1024 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
1025 let content = "This is: ****";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027 let result = rule.check(&ctx).unwrap();
1028 assert!(result.is_empty(), "**** should not be flagged. Got: {result:?}");
1029 }
1030
1031 #[test]
1032 fn test_detect_style_ignores_underscore_sequences() {
1033 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
1034 let content = "This is: _____ and also **real bold**";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036 let style = rule.detect_style(&ctx);
1037 assert_eq!(style, Some(StrongStyle::Asterisk));
1038 }
1039
1040 #[test]
1041 fn test_fix_does_not_modify_underscore_sequences() {
1042 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1043 let content = "Some _____ sequence and __real bold__ text.";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let fixed = rule.fix(&ctx).unwrap();
1046 assert!(fixed.contains("_____"), "_____ should be preserved");
1047 assert!(fixed.contains("**real bold**"), "Real bold should be converted");
1048 }
1049
1050 #[test]
1051 fn test_six_or_more_consecutive_markers_not_flagged() {
1052 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1053 for count in [6, 7, 8, 10] {
1054 let underscores = "_".repeat(count);
1055 let asterisks = "*".repeat(count);
1056 let content_u = format!("Text with {underscores} here");
1057 let content_a = format!("Text with {asterisks} here");
1058
1059 let ctx_u = LintContext::new(&content_u, crate::config::MarkdownFlavor::Standard, None);
1060 let ctx_a = LintContext::new(&content_a, crate::config::MarkdownFlavor::Standard, None);
1061
1062 let result_u = rule.check(&ctx_u).unwrap();
1063 let result_a = rule.check(&ctx_a).unwrap();
1064
1065 assert!(
1066 result_u.is_empty(),
1067 "{count} underscores should not be flagged. Got: {result_u:?}"
1068 );
1069 assert!(
1070 result_a.is_empty(),
1071 "{count} asterisks should not be flagged. Got: {result_a:?}"
1072 );
1073 }
1074 }
1075
1076 #[test]
1077 fn test_mkdocstrings_block_not_flagged() {
1078 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1079 let content = "# Example\n\nWe have here some **bold text**.\n\n::: my_module.MyClass\n options:\n members:\n - __init__\n";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1081 let result = rule.check(&ctx).unwrap();
1082
1083 assert!(
1084 result.is_empty(),
1085 "__init__ inside mkdocstrings block should not be flagged. Got: {result:?}"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_mkdocstrings_block_fix_preserves_content() {
1091 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1092 let content = "# Example\n\nWe have here some **bold text**.\n\n::: my_module.MyClass\n options:\n members:\n - __init__\n - __repr__\n";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1094 let fixed = rule.fix(&ctx).unwrap();
1095
1096 assert!(
1097 fixed.contains("__init__"),
1098 "__init__ in mkdocstrings block should be preserved"
1099 );
1100 assert!(
1101 fixed.contains("__repr__"),
1102 "__repr__ in mkdocstrings block should be preserved"
1103 );
1104 assert!(fixed.contains("**bold text**"), "Real bold text should be unchanged");
1105 }
1106
1107 #[test]
1108 fn test_mkdocstrings_block_with_strong_outside() {
1109 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1110 let content = "::: my_module.MyClass\n options:\n members:\n - __init__\n\nThis __should be flagged__ outside.\n";
1111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1112 let result = rule.check(&ctx).unwrap();
1113
1114 assert_eq!(
1115 result.len(),
1116 1,
1117 "Only strong outside mkdocstrings should be flagged. Got: {result:?}"
1118 );
1119 assert_eq!(result[0].line, 6);
1120 }
1121
1122 #[test]
1123 fn test_thematic_break_not_flagged() {
1124 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1125 let content = "Before\n\n*****\n\nAfter";
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127 let result = rule.check(&ctx).unwrap();
1128 assert!(
1129 result.is_empty(),
1130 "Thematic break (*****) should not be flagged. Got: {result:?}"
1131 );
1132
1133 let content2 = "Before\n\n_____\n\nAfter";
1134 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1135 let result2 = rule.check(&ctx2).unwrap();
1136 assert!(
1137 result2.is_empty(),
1138 "Thematic break (_____) should not be flagged. Got: {result2:?}"
1139 );
1140 }
1141}