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