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