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::{compute_html_code_ranges, should_skip_emphasis_span};
7use regex::Regex;
8use std::sync::LazyLock;
9
10static UNICODE_PUNCTUATION: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\p{P}\p{S}]$").unwrap());
11
12fn can_use_underscore_strong(content: &str, start: usize, end: usize) -> bool {
15 let allows_boundary = |ch: char| {
16 ch.is_whitespace()
17 || ch.is_ascii_punctuation()
18 || (!ch.is_ascii() && UNICODE_PUNCTUATION.is_match(ch.encode_utf8(&mut [0; 4])))
19 };
20 content[..start]
21 .trim_end_matches('_')
22 .chars()
23 .next_back()
24 .is_none_or(allows_boundary)
25 && content[end..]
26 .trim_start_matches('_')
27 .chars()
28 .next()
29 .is_none_or(allows_boundary)
30}
31
32fn span_style(span: &StrongSpanDetail) -> StrongStyle {
34 if span.is_asterisk {
35 StrongStyle::Asterisk
36 } else {
37 StrongStyle::Underscore
38 }
39}
40
41mod md050_config;
42use md050_config::MD050Config;
43
44#[derive(Debug, Default, Clone)]
50pub struct MD050StrongStyle {
51 config: MD050Config,
52}
53
54impl MD050StrongStyle {
55 pub fn new(style: StrongStyle) -> Self {
56 Self {
57 config: MD050Config { style },
58 }
59 }
60
61 pub fn from_config_struct(config: MD050Config) -> Self {
62 Self { config }
63 }
64
65 #[cfg(test)]
66 fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
67 let html_tags = ctx.html_tags();
68 let html_code_ranges = compute_html_code_ranges(&html_tags);
69 self.detect_style_from_spans(ctx, &html_tags, &html_code_ranges, &ctx.strong_spans)
70 }
71
72 fn detect_style_from_spans(
73 &self,
74 ctx: &crate::lint_context::LintContext,
75 html_tags: &[crate::lint_context::HtmlTag],
76 html_code_ranges: &[(usize, usize)],
77 spans: &[StrongSpanDetail],
78 ) -> Option<StrongStyle> {
79 let mut asterisk_count = 0;
80 let mut underscore_count = 0;
81
82 for span in spans {
83 if should_skip_emphasis_span(ctx, html_tags, html_code_ranges, span.start, span.end) {
84 continue;
85 }
86
87 match span_style(span) {
88 StrongStyle::Asterisk => asterisk_count += 1,
89 StrongStyle::Underscore => underscore_count += 1,
90 StrongStyle::Consistent => {}
91 }
92 }
93
94 match (asterisk_count, underscore_count) {
95 (0, 0) => None,
96 (_, 0) => Some(StrongStyle::Asterisk),
97 (0, _) => Some(StrongStyle::Underscore),
98 (a, u) => {
100 if a >= u {
101 Some(StrongStyle::Asterisk)
102 } else {
103 Some(StrongStyle::Underscore)
104 }
105 }
106 }
107 }
108}
109
110impl Rule for MD050StrongStyle {
111 fn name(&self) -> &'static str {
112 "MD050"
113 }
114
115 fn description(&self) -> &'static str {
116 "Strong emphasis style should be consistent"
117 }
118
119 fn category(&self) -> RuleCategory {
120 RuleCategory::Emphasis
121 }
122
123 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
124 let content = ctx.content;
125 let lines = ctx.raw_lines();
126
127 let mut warnings = Vec::new();
128
129 let spans = &ctx.strong_spans;
130 let html_tags = ctx.html_tags();
131 let html_code_ranges = compute_html_code_ranges(&html_tags);
132
133 let target_style = match self.config.style {
134 StrongStyle::Consistent => self
135 .detect_style_from_spans(ctx, &html_tags, &html_code_ranges, spans)
136 .unwrap_or(StrongStyle::Asterisk),
137 _ => self.config.style,
138 };
139
140 for span in spans {
141 if span_style(span) == target_style {
143 continue;
144 }
145
146 if span.end - span.start < 4 {
148 continue;
149 }
150
151 if should_skip_emphasis_span(ctx, &html_tags, &html_code_ranges, span.start, span.end) {
153 continue;
154 }
155
156 let (line_num, _col) = ctx.offset_to_line_col(span.start);
157 let line_start = ctx.line_start_byte(line_num).unwrap_or(0);
158 let line_content = lines.get(line_num - 1).unwrap_or(&"");
159 let match_start_in_line = span.start - line_start;
160 let match_len = span.end - span.start;
161
162 let inner_text = &content[span.start + 2..span.end - 2];
163
164 let message = match target_style {
171 StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
172 StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
173 StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
174 };
175
176 let (start_line, start_col, end_line, end_col) =
177 calculate_match_range(line_num, line_content, match_start_in_line, match_len);
178
179 let fix = if target_style == StrongStyle::Underscore
180 && !can_use_underscore_strong(content, span.start, span.end)
181 {
182 None
183 } else {
184 Some(Fix::new(
185 span.start..span.end,
186 match target_style {
187 StrongStyle::Underscore => format!("__{inner_text}__"),
188 _ => format!("**{inner_text}**"),
189 },
190 ))
191 };
192
193 warnings.push(LintWarning {
194 rule_name: Some(self.name().to_string()),
195 line: start_line,
196 column: start_col,
197 end_line,
198 end_column: end_col,
199 message: message.to_string(),
200 severity: Severity::Warning,
201 fix,
202 });
203 }
204
205 Ok(warnings)
206 }
207
208 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
209 if self.should_skip(ctx) {
210 return Ok(ctx.content.to_string());
211 }
212 let warnings = self.check(ctx)?;
213 if warnings.is_empty() {
214 return Ok(ctx.content.to_string());
215 }
216 let warnings =
217 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
218 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
219 .map_err(crate::rule::LintError::InvalidInput)
220 }
221
222 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
224 ctx.content.is_empty() || !ctx.likely_has_emphasis()
226 }
227
228 fn as_any(&self) -> &dyn std::any::Any {
229 self
230 }
231
232 crate::impl_rule_config_methods!(MD050Config);
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::lint_context::LintContext;
239
240 #[test]
241 fn test_shortcode_markers_are_not_restyled() {
242 for (style, original, replacement) in [
243 (StrongStyle::Underscore, "**", "__"),
244 (StrongStyle::Asterisk, "__", "**"),
245 ] {
246 let rule = MD050StrongStyle::new(style);
247 for shortcode in [
248 format!("{{{{< note title=\"{original}literal{original}\" >}}}}"),
249 format!("{{{{% note title=\"{original}内容{original}\" %}}}}"),
250 format!("{{{{< note\n title=\"{original}literal{original}\" >}}}}"),
251 format!("{original}text {{{{< note title=\"{original}\" >}}}}"),
252 ] {
253 let source = format!("Before {shortcode}\n\n{original}visible{original}\n");
254 let expected = format!("Before {shortcode}\n\n{replacement}visible{replacement}\n");
255 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Hugo, None);
256 assert_eq!(
257 rule.check(&ctx).unwrap().len(),
258 1,
259 "shortcode arguments are literal: {source}"
260 );
261 assert_eq!(rule.fix(&ctx).unwrap(), expected);
262 }
263 }
264 }
265
266 #[test]
267 fn test_shortcode_arguments_do_not_determine_consistent_style() {
268 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
269 let source = "Before {{< note title=\"**a** **b** **c**\" >}}\n\n__first__ __second__ **visible**\n";
270 let expected = "Before {{< note title=\"**a** **b** **c**\" >}}\n\n__first__ __second__ __visible__\n";
271 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Hugo, None);
272 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
273 assert_eq!(rule.fix(&ctx).unwrap(), expected);
274 }
275
276 #[test]
277 fn test_myst_comments_do_not_determine_consistent_style() {
278 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
279 let source = "% **a** **b** **c**\n\n__first__ __second__ **visible**\n";
280 let expected = "% **a** **b** **c**\n\n__first__ __second__ __visible__\n";
281 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MyST, None);
282 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
283 assert_eq!(rule.fix(&ctx).unwrap(), expected);
284 }
285
286 #[test]
287 fn test_myst_comment_contents_are_not_linted() {
288 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
289 for prefix in ["% ", "%", " %\t", " % "] {
290 let source = format!("{prefix}**literal**\n\nBefore **visible**\n");
291 let expected = format!("{prefix}**literal**\n\nBefore __visible__\n");
292 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
293 let warnings = rule.check(&ctx).unwrap();
294 assert_eq!(warnings.len(), 1, "comments are not visible prose: {source}");
295 assert_eq!(warnings[0].line, 3);
296 assert_eq!(rule.fix(&ctx).unwrap(), expected);
297 }
298 }
299
300 #[test]
301 fn test_jinja_quoted_markers_are_not_restyled() {
302 for (style, original, replacement) in [
303 (StrongStyle::Underscore, "**", "__"),
304 (StrongStyle::Asterisk, "__", "**"),
305 ] {
306 let rule = MD050StrongStyle::new(style);
307 for template in [
308 format!("{{{{ \"{original}literal{original}\" }}}}"),
309 format!("{{% set text = '{original}内容{original}' %}}"),
310 format!("{{{{\n \"{original}literal{original}\"\n}}}}"),
311 format!("{original}text {{{{ \"{original}\" }}}}"),
312 ] {
313 let source = format!("Before {template}\n\n{original}visible{original}\n");
314 let expected = format!("Before {template}\n\n{replacement}visible{replacement}\n");
315 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
316 assert_eq!(
317 rule.check(&ctx).unwrap().len(),
318 1,
319 "quoted markers are literal: {source}"
320 );
321 assert_eq!(rule.fix(&ctx).unwrap(), expected);
322 }
323 }
324 }
325
326 #[test]
327 fn test_jinja_quoted_markers_do_not_determine_consistent_style() {
328 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
329 let source = "Before {{ \"**a** **b** **c**\" }}\n\n__first__ __second__ **visible**\n";
330 let expected = "Before {{ \"**a** **b** **c**\" }}\n\n__first__ __second__ __visible__\n";
331 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
332 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
333 assert_eq!(rule.fix(&ctx).unwrap(), expected);
334 }
335
336 #[test]
337 fn test_mdx_module_strings_are_not_restyled() {
338 for (style, original, replacement) in [
339 (StrongStyle::Underscore, "**", "__"),
340 (StrongStyle::Asterisk, "__", "**"),
341 ] {
342 let rule = MD050StrongStyle::new(style);
343 for module in [
344 format!("export const text = \"{original}literal{original}\""),
345 format!("export const data = {{\n text: \"{original}内容{original}\"\n}}"),
346 format!("import text from \"./{original}literal{original}.js\""),
347 ] {
348 let source = format!("{module}\n\n{original}visible{original}\n");
349 let expected = format!("{module}\n\n{replacement}visible{replacement}\n");
350 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
351 let warnings = rule.check(&ctx).unwrap();
352 assert_eq!(warnings.len(), 1, "module strings must stay literal: {source}");
353 assert!(warnings[0].line > module.lines().count());
354 assert_eq!(rule.fix(&ctx).unwrap(), expected);
355 }
356 }
357 }
358
359 #[test]
360 fn test_mdx_module_strings_do_not_determine_consistent_style() {
361 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
362 let source = "export const text = \"**a** **b** **c**\"\n\n__first__ __second__ **visible**\n";
363 let expected = "export const text = \"**a** **b** **c**\"\n\n__first__ __second__ __visible__\n";
364 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
365 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
366 assert_eq!(rule.fix(&ctx).unwrap(), expected);
367 }
368
369 #[test]
370 fn test_underscore_style_reports_intraword_strong_without_removing_it() {
371 for text in [
372 "pre**fix**post",
373 "**fix**post",
374 "pre**fix**",
375 "日本語**強調**後",
376 "3**fix**5",
377 "foo_**bar**_baz",
378 ] {
379 let content = format!("__one__ __two__ {text}\n");
380 for style in [StrongStyle::Underscore, StrongStyle::Consistent] {
381 let rule = MD050StrongStyle::new(style);
382 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
383 let warnings = rule.check(&ctx).unwrap();
384 assert_eq!(warnings.len(), 1, "the style mismatch remains visible: {content}");
385 assert!(warnings[0].fix.is_none(), "underscores would remove bold: {content}");
386 assert_eq!(rule.fix(&ctx).unwrap(), content);
387 let mut original_html = String::new();
388 pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new(&content));
389 assert_eq!(original_html.matches("<strong>").count(), 3);
390 }
391 }
392 }
393
394 #[test]
395 fn test_underscore_style_still_fixes_safe_boundaries() {
396 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
397 for (source, expected) in [
398 ("**word**", "__word__"),
399 ("(**word**)", "(__word__)"),
400 ("x-**word**-y", "x-__word__-y"),
401 ("“**word**”", "“__word__”"),
402 ("💡**word**💡", "💡__word__💡"),
403 ("**word**\u{a0}after", "__word__\u{a0}after"),
404 ] {
405 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
406 let warnings = rule.check(&ctx).unwrap();
407 assert_eq!(warnings.len(), 1);
408 assert!(warnings[0].fix.is_some());
409 assert_eq!(rule.fix(&ctx).unwrap(), expected);
410 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
411 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
412 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
413 let mut original_html = String::new();
414 let mut fixed_html = String::new();
415 pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new(source));
416 pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new(expected));
417 assert_eq!(original_html, fixed_html, "bold formatting must be preserved");
418 }
419 }
420
421 #[test]
422 fn test_underscore_style_fixes_safe_spans_beside_intraword_strong() {
423 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
424 let content = "__one__ __two__ __three__ pre**fix**post **safe**\n";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let warnings = rule.check(&ctx).unwrap();
427 assert_eq!(warnings.len(), 2);
428 assert!(warnings[0].fix.is_none());
429 assert!(warnings[1].fix.is_some());
430 let expected = "__one__ __two__ __three__ pre**fix**post __safe__\n";
431 assert_eq!(rule.fix(&ctx).unwrap(), expected);
432 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
433 assert_eq!(rule.check(&fixed_ctx).unwrap().len(), 1);
434 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
435 }
436
437 #[test]
438 fn test_asterisk_style_with_asterisks() {
439 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
440 let content = "This is **strong text** here.";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
442 let result = rule.check(&ctx).unwrap();
443
444 assert_eq!(result.len(), 0);
445 }
446
447 #[test]
448 fn test_asterisk_style_with_underscores() {
449 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
450 let content = "This is __strong text__ here.";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453
454 assert_eq!(result.len(), 1);
455 assert!(
456 result[0]
457 .message
458 .contains("Strong emphasis should use ** instead of __")
459 );
460 assert_eq!(result[0].line, 1);
461 assert_eq!(result[0].column, 9);
462 }
463
464 #[test]
465 fn test_underscore_style_with_underscores() {
466 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
467 let content = "This is __strong text__ here.";
468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
469 let result = rule.check(&ctx).unwrap();
470
471 assert_eq!(result.len(), 0);
472 }
473
474 #[test]
475 fn test_underscore_style_with_asterisks() {
476 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
477 let content = "This is **strong text** here.";
478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479 let result = rule.check(&ctx).unwrap();
480
481 assert_eq!(result.len(), 1);
482 assert!(
483 result[0]
484 .message
485 .contains("Strong emphasis should use __ instead of **")
486 );
487 }
488
489 #[test]
490 fn test_consistent_style_first_asterisk() {
491 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
492 let content = "First **strong** then __also strong__.";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495
496 assert_eq!(result.len(), 1);
498 assert!(
499 result[0]
500 .message
501 .contains("Strong emphasis should use ** instead of __")
502 );
503 }
504
505 #[test]
506 fn test_consistent_style_tie_prefers_asterisk() {
507 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
508 let content = "First __strong__ then **also strong**.";
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);
515 assert!(
516 result[0]
517 .message
518 .contains("Strong emphasis should use ** instead of __")
519 );
520 }
521
522 #[test]
523 fn test_detect_style_asterisk() {
524 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
525 let ctx = LintContext::new(
526 "This has **strong** text.",
527 crate::config::MarkdownFlavor::Standard,
528 None,
529 );
530 let style = rule.detect_style(&ctx);
531
532 assert_eq!(style, Some(StrongStyle::Asterisk));
533 }
534
535 #[test]
536 fn test_detect_style_underscore() {
537 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
538 let ctx = LintContext::new(
539 "This has __strong__ text.",
540 crate::config::MarkdownFlavor::Standard,
541 None,
542 );
543 let style = rule.detect_style(&ctx);
544
545 assert_eq!(style, Some(StrongStyle::Underscore));
546 }
547
548 #[test]
549 fn test_detect_style_none() {
550 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
551 let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
552 let style = rule.detect_style(&ctx);
553
554 assert_eq!(style, None);
555 }
556
557 #[test]
558 fn test_strong_in_code_block() {
559 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
560 let content = "```\n__strong__ in code\n```\n__strong__ outside";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563
564 assert_eq!(result.len(), 1);
566 assert_eq!(result[0].line, 4);
567 }
568
569 #[test]
570 fn test_strong_in_inline_code() {
571 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
572 let content = "Text with `__strong__` in code and __strong__ outside.";
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);
578 }
579
580 #[test]
581 fn test_escaped_strong() {
582 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
583 let content = "This is \\__not strong\\__ but __this is__.";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let result = rule.check(&ctx).unwrap();
586
587 assert_eq!(result.len(), 1);
589 assert_eq!(result[0].line, 1);
590 assert_eq!(result[0].column, 30);
591 }
592
593 #[test]
594 fn test_fix_asterisks_to_underscores() {
595 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
596 let content = "This is **strong** text.";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598 let fixed = rule.fix(&ctx).unwrap();
599
600 assert_eq!(fixed, "This is __strong__ text.");
601 }
602
603 #[test]
604 fn test_fix_underscores_to_asterisks() {
605 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
606 let content = "This is __strong__ text.";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let fixed = rule.fix(&ctx).unwrap();
609
610 assert_eq!(fixed, "This is **strong** text.");
611 }
612
613 #[test]
614 fn test_fix_multiple_strong() {
615 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
616 let content = "First __strong__ and second __also strong__.";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let fixed = rule.fix(&ctx).unwrap();
619
620 assert_eq!(fixed, "First **strong** and second **also strong**.");
621 }
622
623 #[test]
624 fn test_fix_preserves_code_blocks() {
625 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
626 let content = "```\n__strong__ in code\n```\n__strong__ outside";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let fixed = rule.fix(&ctx).unwrap();
629
630 assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
631 }
632
633 #[test]
634 fn test_multiline_content() {
635 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
636 let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
638 let result = rule.check(&ctx).unwrap();
639
640 assert_eq!(result.len(), 2);
641 assert_eq!(result[0].line, 1);
642 assert_eq!(result[1].line, 2);
643 }
644
645 #[test]
646 fn test_nested_emphasis() {
647 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
648 let content = "This has __strong with *emphasis* inside__.";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let result = rule.check(&ctx).unwrap();
651
652 assert_eq!(result.len(), 1);
653 }
654
655 #[test]
656 fn test_empty_content() {
657 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
658 let content = "";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661
662 assert_eq!(result.len(), 0);
663 }
664
665 #[test]
666 fn test_default_config() {
667 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
668 let (name, _config) = rule.default_config_section().unwrap();
669 assert_eq!(name, "MD050");
670 }
671
672 #[test]
673 fn test_strong_in_links_not_flagged() {
674 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
675 let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
676
677Hint:
678
679- [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__`")
680
681
682[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let result = rule.check(&ctx).unwrap();
685
686 assert_eq!(result.len(), 0);
688 }
689
690 #[test]
691 fn test_strong_in_links_vs_outside_links() {
692 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
693 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][]**.
694
695Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
696
697This is __real strong text__ that should be flagged.
698
699[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
702
703 assert_eq!(result.len(), 1);
705 assert!(
706 result[0]
707 .message
708 .contains("Strong emphasis should use ** instead of __")
709 );
710 assert!(result[0].line > 4); }
713
714 #[test]
715 fn test_front_matter_not_flagged() {
716 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
717 let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719 let result = rule.check(&ctx).unwrap();
720
721 assert_eq!(result.len(), 1);
723 assert_eq!(result[0].line, 6);
724 assert!(
725 result[0]
726 .message
727 .contains("Strong emphasis should use ** instead of __")
728 );
729 }
730
731 #[test]
732 fn test_html_tags_not_flagged() {
733 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
734 let content = r#"# Test
735
736This has HTML with underscores:
737
738<iframe src="https://example.com/__init__/__repr__"> </iframe>
739
740This __should be flagged__ as inconsistent."#;
741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742 let result = rule.check(&ctx).unwrap();
743
744 assert_eq!(result.len(), 1);
746 assert_eq!(result[0].line, 7);
747 assert!(
748 result[0]
749 .message
750 .contains("Strong emphasis should use ** instead of __")
751 );
752 }
753
754 #[test]
755 fn test_mkdocs_keys_notation_not_flagged() {
756 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
758 let content = "Press ++ctrl+alt+del++ to restart.";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
760 let result = rule.check(&ctx).unwrap();
761
762 assert!(
764 result.is_empty(),
765 "Keys notation should not be flagged as strong emphasis. Got: {result:?}"
766 );
767 }
768
769 #[test]
770 fn test_mkdocs_caret_notation_not_flagged() {
771 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
773 let content = "This is ^^inserted^^ text.";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
775 let result = rule.check(&ctx).unwrap();
776
777 assert!(
778 result.is_empty(),
779 "Insert notation should not be flagged as strong emphasis. Got: {result:?}"
780 );
781 }
782
783 #[test]
784 fn test_mkdocs_mark_notation_not_flagged() {
785 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
787 let content = "This is ==highlighted== text.";
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
789 let result = rule.check(&ctx).unwrap();
790
791 assert!(
792 result.is_empty(),
793 "Mark notation should not be flagged as strong emphasis. Got: {result:?}"
794 );
795 }
796
797 #[test]
798 fn test_mkdocs_mixed_content_with_real_strong() {
799 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
801 let content = "Press ++ctrl++ and __underscore strong__ here.";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
803 let result = rule.check(&ctx).unwrap();
804
805 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
807 assert!(
808 result[0]
809 .message
810 .contains("Strong emphasis should use ** instead of __")
811 );
812 }
813
814 #[test]
815 fn test_mkdocs_icon_shortcode_not_flagged() {
816 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
818 let content = "Click :material-check: and __this should be flagged__.";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
820 let result = rule.check(&ctx).unwrap();
821
822 assert_eq!(result.len(), 1);
824 assert!(
825 result[0]
826 .message
827 .contains("Strong emphasis should use ** instead of __")
828 );
829 }
830
831 #[test]
832 fn test_math_block_not_flagged() {
833 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
835 let content = r#"# Math Section
836
837$$
838E = mc^2
839x_1 + x_2 = y
840a**b = c
841$$
842
843This __should be flagged__ outside math.
844"#;
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
846 let result = rule.check(&ctx).unwrap();
847
848 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
850 assert!(result[0].line > 7, "Warning should be on line after math block");
851 }
852
853 #[test]
854 fn test_math_block_with_underscores_not_flagged() {
855 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
857 let content = r#"$$
858x_1 + x_2 + x__3 = y
859\alpha__\beta
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_with_asterisks_not_flagged() {
874 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
876 let content = r#"$$
877a**b = c
8782 ** 3 = 8
879x***y
880$$
881"#;
882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
883 let result = rule.check(&ctx).unwrap();
884
885 assert!(
887 result.is_empty(),
888 "Math block content should not be flagged. Got: {result:?}"
889 );
890 }
891
892 #[test]
893 fn test_math_block_fix_preserves_content() {
894 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
896 let content = r#"$$
897x__y = z
898$$
899
900This __word__ should change.
901"#;
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
903 let fixed = rule.fix(&ctx).unwrap();
904
905 assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
907 assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
909 }
910
911 #[test]
912 fn test_inline_math_simple() {
913 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
915 let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
917 let result = rule.check(&ctx).unwrap();
918
919 assert_eq!(
921 result.len(),
922 1,
923 "Expected 1 warning for strong outside math. Got: {result:?}"
924 );
925 }
926
927 #[test]
928 fn test_multiple_math_blocks_and_strong() {
929 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
931 let content = r#"# Document
932
933$$
934a = b
935$$
936
937This __should be flagged__ text.
938
939$$
940c = d
941$$
942"#;
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
944 let result = rule.check(&ctx).unwrap();
945
946 assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
948 assert!(result[0].message.contains("**"));
949 }
950
951 #[test]
952 fn test_html_tag_skip_consistency_between_check_and_fix() {
953 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
956
957 let content = r#"<a href="__test__">link</a>
958
959This __should be flagged__ text."#;
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961
962 let check_result = rule.check(&ctx).unwrap();
963 let fix_result = rule.fix(&ctx).unwrap();
964
965 assert_eq!(
967 check_result.len(),
968 1,
969 "check() should flag exactly one emphasis outside HTML tags"
970 );
971 assert!(check_result[0].message.contains("**"));
972
973 assert!(
975 fix_result.contains("**should be flagged**"),
976 "fix() should convert the flagged emphasis"
977 );
978 assert!(
979 fix_result.contains("__test__"),
980 "fix() should not modify emphasis inside HTML tags"
981 );
982 }
983
984 #[test]
985 fn test_detect_style_ignores_emphasis_in_inline_code_on_table_lines() {
986 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
989
990 let content = "| `__code__` | **real** |\n| --- | --- |\n| data | data |";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994
995 let style = rule.detect_style(&ctx);
996 assert_eq!(style, Some(StrongStyle::Asterisk));
998 }
999
1000 #[test]
1001 fn test_five_underscores_not_flagged() {
1002 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1003 let content = "This is a series of underscores: _____";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx).unwrap();
1006 assert!(
1007 result.is_empty(),
1008 "_____ should not be flagged as strong emphasis. Got: {result:?}"
1009 );
1010 }
1011
1012 #[test]
1013 fn test_five_asterisks_not_flagged() {
1014 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
1015 let content = "This is a series of asterisks: *****";
1016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017 let result = rule.check(&ctx).unwrap();
1018 assert!(
1019 result.is_empty(),
1020 "***** should not be flagged as strong emphasis. Got: {result:?}"
1021 );
1022 }
1023
1024 #[test]
1025 fn test_five_underscores_with_frontmatter_not_flagged() {
1026 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1027 let content = "---\ntitle: Level 1 heading\n---\n\nThis is a series of underscores: _____\n";
1028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029 let result = rule.check(&ctx).unwrap();
1030 assert!(result.is_empty(), "_____ should not be flagged. Got: {result:?}");
1031 }
1032
1033 #[test]
1034 fn test_four_underscores_not_flagged() {
1035 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1036 let content = "This is: ____";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(result.is_empty(), "____ should not be flagged. Got: {result:?}");
1040 }
1041
1042 #[test]
1043 fn test_four_asterisks_not_flagged() {
1044 let rule = MD050StrongStyle::new(StrongStyle::Underscore);
1045 let content = "This is: ****";
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1047 let result = rule.check(&ctx).unwrap();
1048 assert!(result.is_empty(), "**** should not be flagged. Got: {result:?}");
1049 }
1050
1051 #[test]
1052 fn test_detect_style_ignores_underscore_sequences() {
1053 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
1054 let content = "This is: _____ and also **real bold**";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let style = rule.detect_style(&ctx);
1057 assert_eq!(style, Some(StrongStyle::Asterisk));
1058 }
1059
1060 #[test]
1061 fn test_fix_does_not_modify_underscore_sequences() {
1062 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1063 let content = "Some _____ sequence and __real bold__ text.";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let fixed = rule.fix(&ctx).unwrap();
1066 assert!(fixed.contains("_____"), "_____ should be preserved");
1067 assert!(fixed.contains("**real bold**"), "Real bold should be converted");
1068 }
1069
1070 #[test]
1071 fn test_six_or_more_consecutive_markers_not_flagged() {
1072 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1073 for count in [6, 7, 8, 10] {
1074 let underscores = "_".repeat(count);
1075 let asterisks = "*".repeat(count);
1076 let content_u = format!("Text with {underscores} here");
1077 let content_a = format!("Text with {asterisks} here");
1078
1079 let ctx_u = LintContext::new(&content_u, crate::config::MarkdownFlavor::Standard, None);
1080 let ctx_a = LintContext::new(&content_a, crate::config::MarkdownFlavor::Standard, None);
1081
1082 let result_u = rule.check(&ctx_u).unwrap();
1083 let result_a = rule.check(&ctx_a).unwrap();
1084
1085 assert!(
1086 result_u.is_empty(),
1087 "{count} underscores should not be flagged. Got: {result_u:?}"
1088 );
1089 assert!(
1090 result_a.is_empty(),
1091 "{count} asterisks should not be flagged. Got: {result_a:?}"
1092 );
1093 }
1094 }
1095
1096 #[test]
1097 fn test_mkdocstrings_block_not_flagged() {
1098 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1099 let content = "# Example\n\nWe have here some **bold text**.\n\n::: my_module.MyClass\n options:\n members:\n - __init__\n";
1100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1101 let result = rule.check(&ctx).unwrap();
1102
1103 assert!(
1104 result.is_empty(),
1105 "__init__ inside mkdocstrings block should not be flagged. Got: {result:?}"
1106 );
1107 }
1108
1109 #[test]
1110 fn test_mkdocstrings_block_fix_preserves_content() {
1111 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1112 let content = "# Example\n\nWe have here some **bold text**.\n\n::: my_module.MyClass\n options:\n members:\n - __init__\n - __repr__\n";
1113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1114 let fixed = rule.fix(&ctx).unwrap();
1115
1116 assert!(
1117 fixed.contains("__init__"),
1118 "__init__ in mkdocstrings block should be preserved"
1119 );
1120 assert!(
1121 fixed.contains("__repr__"),
1122 "__repr__ in mkdocstrings block should be preserved"
1123 );
1124 assert!(fixed.contains("**bold text**"), "Real bold text should be unchanged");
1125 }
1126
1127 #[test]
1128 fn test_mkdocstrings_block_with_strong_outside() {
1129 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1130 let content = "::: my_module.MyClass\n options:\n members:\n - __init__\n\nThis __should be flagged__ outside.\n";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1132 let result = rule.check(&ctx).unwrap();
1133
1134 assert_eq!(
1135 result.len(),
1136 1,
1137 "Only strong outside mkdocstrings should be flagged. Got: {result:?}"
1138 );
1139 assert_eq!(result[0].line, 6);
1140 }
1141
1142 #[test]
1143 fn test_thematic_break_not_flagged() {
1144 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1145 let content = "Before\n\n*****\n\nAfter";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let result = rule.check(&ctx).unwrap();
1148 assert!(
1149 result.is_empty(),
1150 "Thematic break (*****) should not be flagged. Got: {result:?}"
1151 );
1152
1153 let content2 = "Before\n\n_____\n\nAfter";
1154 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1155 let result2 = rule.check(&ctx2).unwrap();
1156 assert!(
1157 result2.is_empty(),
1158 "Thematic break (_____) should not be flagged. Got: {result2:?}"
1159 );
1160 }
1161}
1162
1163#[cfg(test)]
1164mod mdx_component_value_tests {
1165 use super::*;
1166 #[test]
1167 fn md050_mdx_component_values_are_literal() {
1168 let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
1169 for name in ["span", "Card", "ui.Card", "svg:path"] {
1170 let tag = format!("<{name} title='__literal__'>text</{name}>");
1171 let source = format!("内容 Before {tag} after __visible__\n");
1172 let expected = format!("内容 Before {tag} after **visible**\n");
1173 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1174 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
1175 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1176 }
1177 }
1178 #[test]
1179 fn md050_mdx_literals_do_not_choose_consistent_style() {
1180 let rule = MD050StrongStyle::new(StrongStyle::Consistent);
1181 let source =
1182 "Before <ui.Card title='**a** **b** **c**'>text</ui.Card> after\n\n__first__ __second__ **visible**\n";
1183 let expected = source.replace("**visible**", "__visible__");
1184 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1185 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1186 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1187 }
1188}