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