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