1use crate::filtered_lines::FilteredLinesExt;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rules::emphasis_style::EmphasisStyle;
4use crate::utils::emphasis_utils::{find_emphasis_markers, find_single_emphasis_spans, replace_inline_code};
5use crate::utils::skip_context::is_in_mkdocs_markup;
6
7mod md049_config;
8use md049_config::MD049Config;
9
10#[derive(Debug, Default, Clone)]
20pub struct MD049EmphasisStyle {
21 config: MD049Config,
22}
23
24impl MD049EmphasisStyle {
25 pub fn new(style: EmphasisStyle) -> Self {
27 MD049EmphasisStyle {
28 config: MD049Config { style },
29 }
30 }
31
32 pub fn from_config_struct(config: MD049Config) -> Self {
33 Self { config }
34 }
35
36 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
39 ctx.is_in_link(byte_pos)
40 }
41
42 fn collect_emphasis_from_line(
44 &self,
45 line: &str,
46 line_num: usize,
47 line_start_pos: usize,
48 emphasis_info: &mut Vec<(usize, usize, usize, char, String)>, ) {
50 let line_no_code = replace_inline_code(line);
55
56 let markers = find_emphasis_markers(&line_no_code);
58 if markers.is_empty() {
59 return;
60 }
61
62 let spans = find_single_emphasis_spans(&line_no_code, &markers);
64
65 for span in spans {
66 let marker_char = span.opening.as_char();
67 let col = span.opening.start_pos + 1; let abs_pos = line_start_pos + span.opening.start_pos;
69
70 let content_start = span.opening.end_pos();
76 let content_end = span.closing.start_pos;
77 let original_content = line[content_start..content_end].to_string();
78
79 emphasis_info.push((line_num, col, abs_pos, marker_char, original_content));
80 }
81 }
82}
83
84impl Rule for MD049EmphasisStyle {
85 fn name(&self) -> &'static str {
86 "MD049"
87 }
88
89 fn description(&self) -> &'static str {
90 "Emphasis style should be consistent"
91 }
92
93 fn category(&self) -> RuleCategory {
94 RuleCategory::Emphasis
95 }
96
97 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
98 let mut warnings = vec![];
99
100 if !ctx.likely_has_emphasis() {
102 return Ok(warnings);
103 }
104
105 let mut emphasis_info = vec![];
110
111 for line in ctx
116 .filtered_lines()
117 .skip_front_matter()
118 .skip_code_blocks()
119 .skip_html_blocks()
120 .skip_html_comments()
121 .skip_esm_blocks()
122 .skip_math_blocks()
123 .skip_obsidian_comments()
124 .skip_mkdocstrings()
125 {
126 if line.line_info.is_myst_comment {
127 continue;
128 }
129 if !line.content.contains('*') && !line.content.contains('_') {
131 continue;
132 }
133
134 let line_start = ctx.line_start_byte(line.line_num).unwrap_or(0);
136 let masked = crate::utils::skip_context::mask_mdx_inline_code(ctx, line.content, line_start);
137 self.collect_emphasis_from_line(&masked, line.line_num, line_start, &mut emphasis_info);
138 }
139
140 let lines = ctx.raw_lines();
142 let math_ranges: Vec<(usize, usize)> = {
160 let code_spans = ctx.code_spans();
166 let math_source: std::borrow::Cow<'_, str> = if ctx.code_blocks.is_empty() && code_spans.is_empty() {
167 std::borrow::Cow::Borrowed(ctx.content)
168 } else {
169 let mut bytes = ctx.content.as_bytes().to_vec();
170 let len = bytes.len();
171 let mut mask = |start: usize, end: usize| {
172 for b in &mut bytes[start.min(len)..end.min(len)] {
173 if *b == b'$' {
174 *b = b' ';
175 }
176 }
177 };
178 for &(start, end) in &ctx.code_blocks {
179 mask(start, end);
180 }
181 for span in code_spans.iter() {
182 mask(span.byte_offset, span.byte_end);
183 }
184 std::borrow::Cow::Owned(String::from_utf8(bytes).expect("ASCII-only substitution"))
187 };
188 let mut r = crate::utils::skip_context::math_byte_ranges(&math_source);
189 r.sort_unstable_by_key(|&(start, _)| start);
190 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(r.len());
191 for (start, end) in r {
192 match merged.last_mut() {
193 Some(last) if start <= last.1 => last.1 = last.1.max(end),
194 _ => merged.push((start, end)),
195 }
196 }
197 merged
198 };
199 let has_html = ctx.content.contains('<');
200 emphasis_info.retain(|(line_num, col, abs_pos, _, emphasis_content)| {
201 let closing_pos = *abs_pos + emphasis_content.len() + 1;
202 if ctx.overlaps_mdx_inline_code(*abs_pos, closing_pos + 1)
203 || ctx.is_in_jinja_string(*abs_pos)
204 || ctx.is_in_jinja_string(closing_pos)
205 || ctx.is_in_shortcode(*abs_pos)
206 || ctx.is_in_shortcode(closing_pos)
207 {
208 return false;
209 }
210 if has_html
213 && [*abs_pos, closing_pos].into_iter().any(|pos| {
214 ctx.is_in_html_comment(pos) || ctx.is_in_html_tag(pos) || ctx.is_in_jsx_component_tag(pos)
215 })
216 {
217 return false;
218 }
219 if ctx.is_in_code_span_byte(*abs_pos) || ctx.is_in_code_span_byte(closing_pos) {
222 return false;
223 }
224 let idx = math_ranges.partition_point(|&(start, _)| start <= *abs_pos);
228 if idx > 0 && *abs_pos < math_ranges[idx - 1].1 {
229 return false;
230 }
231 if ctx.is_in_obsidian_comment(*abs_pos) {
233 return false;
234 }
235 if Self::is_in_link(ctx, *abs_pos) {
237 return false;
238 }
239 if let Some(line) = lines.get(*line_num - 1) {
241 let line_pos = col.saturating_sub(1); if is_in_mkdocs_markup(line, line_pos, ctx.flavor) {
243 return false;
244 }
245 }
246 true
247 });
248
249 match self.config.style {
250 EmphasisStyle::Consistent => {
251 if emphasis_info.len() < 2 {
253 return Ok(warnings);
254 }
255
256 let asterisk_count = emphasis_info.iter().filter(|(_, _, _, m, _)| *m == '*').count();
258 let underscore_count = emphasis_info.iter().filter(|(_, _, _, m, _)| *m == '_').count();
259
260 let target_marker = if asterisk_count >= underscore_count { '*' } else { '_' };
263
264 for (line_num, _col, abs_pos, marker, content) in &emphasis_info {
266 if *marker != target_marker {
267 let emphasis_len = 1 + content.len() + 1;
271 let (_, char_col) = ctx.offset_to_line_col(*abs_pos);
272
273 warnings.push(LintWarning {
274 rule_name: Some(self.name().to_string()),
275 line: *line_num,
276 column: char_col,
277 end_line: *line_num,
278 end_column: char_col + content.chars().count() + 2,
279 message: format!("Emphasis should use {target_marker} instead of {marker}"),
280 fix: Some(Fix::new(
281 *abs_pos..*abs_pos + emphasis_len,
282 format!("{target_marker}{content}{target_marker}"),
283 )),
284 severity: Severity::Warning,
285 });
286 }
287 }
288 }
289 EmphasisStyle::Asterisk | EmphasisStyle::Underscore => {
290 let (wrong_marker, correct_marker) = match self.config.style {
291 EmphasisStyle::Asterisk => ('_', '*'),
292 EmphasisStyle::Underscore => ('*', '_'),
293 EmphasisStyle::Consistent => {
294 ('_', '*')
297 }
298 };
299
300 for (line_num, _col, abs_pos, marker, content) in &emphasis_info {
301 if *marker == wrong_marker {
302 let emphasis_len = 1 + content.len() + 1;
306 let (_, char_col) = ctx.offset_to_line_col(*abs_pos);
307
308 warnings.push(LintWarning {
309 rule_name: Some(self.name().to_string()),
310 line: *line_num,
311 column: char_col,
312 end_line: *line_num,
313 end_column: char_col + content.chars().count() + 2,
314 message: format!("Emphasis should use {correct_marker} instead of {wrong_marker}"),
315 fix: Some(Fix::new(
316 *abs_pos..*abs_pos + emphasis_len,
317 format!("{correct_marker}{content}{correct_marker}"),
318 )),
319 severity: Severity::Warning,
320 });
321 }
322 }
323 }
324 }
325 Ok(warnings)
326 }
327
328 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
329 let warnings = self.check(ctx)?;
331 let warnings =
332 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
333
334 if warnings.is_empty() {
336 return Ok(ctx.content.to_string());
337 }
338
339 let mut fixes: Vec<_> = warnings
341 .iter()
342 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
343 .collect();
344 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
345
346 let mut result = ctx.content.to_string();
348 for (start, end, replacement) in fixes {
349 if start < result.len() && end <= result.len() && start <= end {
350 result.replace_range(start..end, replacement);
351 }
352 }
353
354 Ok(result)
355 }
356
357 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
359 ctx.content.is_empty() || !ctx.likely_has_emphasis()
360 }
361
362 fn as_any(&self) -> &dyn std::any::Any {
363 self
364 }
365
366 crate::impl_rule_config_methods!(MD049Config);
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn test_shortcode_markers_are_not_restyled() {
375 for (style, original, replacement) in [
376 (EmphasisStyle::Underscore, "*", "_"),
377 (EmphasisStyle::Asterisk, "_", "*"),
378 ] {
379 let rule = MD049EmphasisStyle::new(style);
380 for shortcode in [
381 format!("{{{{< note title=\"{original}literal{original}\" >}}}}"),
382 format!("{{{{% note title=\"{original}内容{original}\" %}}}}"),
383 format!("{{{{< note\n title=\"{original}literal{original}\" >}}}}"),
384 format!("{original}text {{{{< note title=\"{original}\" >}}}}"),
385 ] {
386 let source = format!("Before {shortcode}\n\n{original}visible{original}\n");
387 let expected = format!("Before {shortcode}\n\n{replacement}visible{replacement}\n");
388 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Hugo, None);
389 assert_eq!(
390 rule.check(&ctx).unwrap().len(),
391 1,
392 "shortcode arguments are literal: {source}"
393 );
394 assert_eq!(rule.fix(&ctx).unwrap(), expected);
395 }
396 }
397 }
398
399 #[test]
400 fn test_shortcode_arguments_do_not_determine_consistent_style() {
401 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
402 let source = "Before {{< note title=\"*a* *b* *c*\" >}}\n\n_first_ _second_ *visible*\n";
403 let expected = "Before {{< note title=\"*a* *b* *c*\" >}}\n\n_first_ _second_ _visible_\n";
404 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Hugo, None);
405 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
406 assert_eq!(rule.fix(&ctx).unwrap(), expected);
407 }
408
409 #[test]
410 fn test_myst_comments_do_not_determine_consistent_style() {
411 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
412 let source = "% *a* *b* *c*\n\n_first_ _second_ *visible*\n";
413 let expected = "% *a* *b* *c*\n\n_first_ _second_ _visible_\n";
414 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MyST, None);
415 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
416 assert_eq!(rule.fix(&ctx).unwrap(), expected);
417 }
418
419 #[test]
420 fn test_myst_comment_contents_are_not_linted() {
421 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
422 for prefix in ["% ", "%", " %\t", " % "] {
423 let source = format!("{prefix}*literal*\n\nBefore *visible*\n");
424 let expected = format!("{prefix}*literal*\n\nBefore _visible_\n");
425 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
426 let warnings = rule.check(&ctx).unwrap();
427 assert_eq!(warnings.len(), 1, "comments are not visible prose: {source}");
428 assert_eq!(warnings[0].line, 3);
429 assert_eq!(rule.fix(&ctx).unwrap(), expected);
430 }
431 }
432
433 #[test]
434 fn test_jinja_quoted_markers_are_not_restyled() {
435 for (style, original, replacement) in [
436 (EmphasisStyle::Underscore, "*", "_"),
437 (EmphasisStyle::Asterisk, "_", "*"),
438 ] {
439 let rule = MD049EmphasisStyle::new(style);
440 for template in [
441 format!("{{{{ \"{original}literal{original}\" }}}}"),
442 format!("{{% set text = '{original}内容{original}' %}}"),
443 format!("{{{{\n \"{original}literal{original}\"\n}}}}"),
444 format!("{original}text {{{{ \"{original}\" }}}}"),
445 ] {
446 let source = format!("Before {template}\n\n{original}visible{original}\n");
447 let expected = format!("Before {template}\n\n{replacement}visible{replacement}\n");
448 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
449 assert_eq!(
450 rule.check(&ctx).unwrap().len(),
451 1,
452 "quoted markers are literal: {source}"
453 );
454 assert_eq!(rule.fix(&ctx).unwrap(), expected);
455 }
456 }
457 }
458
459 #[test]
460 fn test_jinja_quoted_markers_do_not_determine_consistent_style() {
461 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
462 let source = "Before {{ \"*a* *b* *c*\" }}\n\n_first_ _second_ *visible*\n";
463 let expected = "Before {{ \"*a* *b* *c*\" }}\n\n_first_ _second_ _visible_\n";
464 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
465 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
466 assert_eq!(rule.fix(&ctx).unwrap(), expected);
467 }
468
469 #[test]
470 fn test_mdx_module_strings_are_not_restyled() {
471 for (style, original, replacement) in [
472 (EmphasisStyle::Underscore, "*", "_"),
473 (EmphasisStyle::Asterisk, "_", "*"),
474 ] {
475 let rule = MD049EmphasisStyle::new(style);
476 for module in [
477 format!("export const text = \"{original}literal{original}\""),
478 format!("export const data = {{\n text: \"{original}内容{original}\"\n}}"),
479 format!("import text from \"./{original}literal{original}.js\""),
480 ] {
481 let source = format!("{module}\n\n{original}visible{original}\n");
482 let expected = format!("{module}\n\n{replacement}visible{replacement}\n");
483 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
484 let warnings = rule.check(&ctx).unwrap();
485 assert_eq!(warnings.len(), 1, "module strings must stay literal: {source}");
486 assert!(warnings[0].line > module.lines().count());
487 assert_eq!(rule.fix(&ctx).unwrap(), expected);
488 }
489 }
490 }
491
492 #[test]
493 fn test_mdx_module_strings_do_not_determine_consistent_style() {
494 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
495 let source = "export const text = \"*a* *b* *c*\"\n\n_first_ _second_ *visible*\n";
496 let expected = "export const text = \"*a* *b* *c*\"\n\n_first_ _second_ _visible_\n";
497 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
498 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
499 assert_eq!(rule.fix(&ctx).unwrap(), expected);
500 }
501
502 #[test]
503 fn test_inline_html_literals_are_not_restyled() {
504 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
505 for html in [
506 "Before <!-- *literal* -->",
507 "Before <span title=\"*literal*\">visible</span>",
508 "Before <span\n title=\"*literal*\">visible</span>",
509 "日本語 <span title=\"*内容*\">表示</span>",
510 "Before *text <span title=\"*\">visible</span>",
511 ] {
512 let source = format!("_one_ _two_\n\n{html}\n");
513 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
514 assert!(
515 rule.check(&ctx).unwrap().is_empty(),
516 "HTML markers are literal: {source}"
517 );
518 assert_eq!(rule.fix(&ctx).unwrap(), source);
519 }
520 }
521
522 #[test]
523 fn test_emphasis_beside_html_attributes_still_gets_fixed() {
524 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
525 let source = "Before <span title=\"*literal*\">*visible*</span> <!-- *hidden* -->\n";
526 let expected = "Before <span title=\"*literal*\">_visible_</span> <!-- *hidden* -->\n";
527 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
528 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
529 assert_eq!(rule.fix(&ctx).unwrap(), expected);
530 let mut original_html = String::new();
531 let mut fixed_html = String::new();
532 pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new(source));
533 pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new(expected));
534 assert_eq!(original_html, fixed_html);
535 }
536
537 #[test]
538 fn test_multiline_code_span_contents_are_not_restyled() {
539 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
540 for code in [
541 "`code\n*literal*\nend`",
542 "``start `tick`\n*literal*\nend``",
543 "`start\n*code` after*",
544 "日本語 `開始\n*内容*\n終了`",
545 ] {
546 let content = format!("_one_ _two_\n\n{code}\n");
547 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
548 assert!(
549 rule.check(&ctx).unwrap().is_empty(),
550 "the asterisks are code: {content}"
551 );
552 assert_eq!(rule.fix(&ctx).unwrap(), content);
553 }
554 }
555
556 #[test]
557 fn test_emphasis_beside_multiline_code_still_gets_fixed() {
558 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
559 let source = "_one_ _two_\n\n`start\n*literal*\nend` and *visible*\n";
560 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
561 let warnings = rule.check(&ctx).unwrap();
562 assert_eq!(warnings.len(), 1);
563 assert_eq!(warnings[0].line, 5);
564 let expected = "_one_ _two_\n\n`start\n*literal*\nend` and _visible_\n";
565 assert_eq!(rule.fix(&ctx).unwrap(), expected);
566 let fixed_ctx = crate::lint_context::LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
567 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
568 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
569 let mut original_html = String::new();
570 let mut fixed_html = String::new();
571 pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new(source));
572 pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new(expected));
573 assert_eq!(original_html, fixed_html);
574 }
575
576 #[test]
577 fn test_name() {
578 let rule = MD049EmphasisStyle::default();
579 assert_eq!(rule.name(), "MD049");
580 }
581
582 #[test]
583 fn test_style_from_str() {
584 assert_eq!(EmphasisStyle::from("asterisk"), EmphasisStyle::Asterisk);
585 assert_eq!(EmphasisStyle::from("underscore"), EmphasisStyle::Underscore);
586 assert_eq!(EmphasisStyle::from("other"), EmphasisStyle::Consistent);
587 }
588
589 #[test]
590 fn test_emphasis_in_links_not_flagged() {
591 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
592 let content = r#"Check this [*asterisk*](https://example.com/*pattern*) link and [_underscore_](https://example.com/_private_).
593
594Also see the [`__init__`][__init__] reference.
595
596This should be _flagged_ since we're using asterisk style.
597
598[__init__]: https://example.com/__init__.py"#;
599 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601
602 assert_eq!(result.len(), 1);
604 assert!(result[0].message.contains("Emphasis should use * instead of _"));
605 assert!(result[0].line == 5); }
608
609 #[test]
610 fn test_emphasis_in_links_vs_outside_links() {
611 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
612 let content = r#"Check [*emphasis*](https://example.com/*test*) and inline *real emphasis* text.
613
614[*link*]: https://example.com/*path*"#;
615 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616 let result = rule.check(&ctx).unwrap();
617
618 assert_eq!(result.len(), 1);
620 assert!(result[0].message.contains("Emphasis should use _ instead of *"));
621 assert!(result[0].line == 1);
623 }
624
625 #[test]
626 fn test_mkdocs_keys_notation_not_flagged() {
627 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
629 let content = "Press ++ctrl+alt+del++ to restart.";
630 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
631 let result = rule.check(&ctx).unwrap();
632
633 assert!(
635 result.is_empty(),
636 "Keys notation should not be flagged as emphasis. Got: {result:?}"
637 );
638 }
639
640 #[test]
641 fn test_mkdocs_caret_notation_not_flagged() {
642 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
644 let content = "This is ^superscript^ and ^^inserted^^ text.";
645 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
646 let result = rule.check(&ctx).unwrap();
647
648 assert!(
649 result.is_empty(),
650 "Caret notation should not be flagged as emphasis. Got: {result:?}"
651 );
652 }
653
654 #[test]
655 fn test_mkdocs_mark_notation_not_flagged() {
656 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
658 let content = "This is ==highlighted== text.";
659 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
660 let result = rule.check(&ctx).unwrap();
661
662 assert!(
663 result.is_empty(),
664 "Mark notation should not be flagged as emphasis. Got: {result:?}"
665 );
666 }
667
668 #[test]
669 fn test_mkdocs_mixed_content_with_real_emphasis() {
670 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
672 let content = "Press ++ctrl++ and _underscore emphasis_ here.";
673 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
674 let result = rule.check(&ctx).unwrap();
675
676 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
678 assert!(result[0].message.contains("Emphasis should use * instead of _"));
679 }
680
681 #[test]
682 fn test_mkdocs_icon_shortcode_not_flagged() {
683 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
685 let content = "Click :material-check: and _this should be flagged_.";
686 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
687 let result = rule.check(&ctx).unwrap();
688
689 assert_eq!(result.len(), 1);
691 assert!(result[0].message.contains("Emphasis should use * instead of _"));
692 }
693
694 #[test]
695 fn test_mkdocstrings_block_not_flagged() {
696 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
697 let content = "# Example\n\n::: my_module.MyClass\n options:\n members:\n - _private_method\n";
698 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
699 let result = rule.check(&ctx).unwrap();
700
701 assert!(
702 result.is_empty(),
703 "_private_method_ inside mkdocstrings block should not be flagged. Got: {result:?}"
704 );
705 }
706
707 #[test]
708 fn test_mkdocstrings_block_with_emphasis_outside() {
709 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
710 let content = "::: my_module.MyClass\n options:\n members:\n - _init\n\nThis _should be flagged_ outside.\n";
711 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
712 let result = rule.check(&ctx).unwrap();
713
714 assert_eq!(
715 result.len(),
716 1,
717 "Only emphasis outside mkdocstrings should be flagged. Got: {result:?}"
718 );
719 assert_eq!(result[0].line, 6);
720 }
721
722 #[test]
723 fn test_inline_code_inside_emphasis_preserved_on_fix() {
724 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
728 let content = "- _An item with `inline code` inside._ Trailing text.";
729 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730
731 let fixed = rule.fix(&ctx).unwrap();
732 assert_eq!(fixed, "- *An item with `inline code` inside.* Trailing text.");
733 assert!(!fixed.contains('X'), "masking placeholder leaked into fix: {fixed}");
734
735 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
737 assert_eq!(rule.fix(&ctx2).unwrap(), fixed);
738 }
739
740 #[test]
741 fn test_inline_code_inside_emphasis_underscore_style() {
742 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
744 let content = "See *the `id` field* below.";
745 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let fixed = rule.fix(&ctx).unwrap();
747 assert_eq!(fixed, "See _the `id` field_ below.");
748 }
749
750 #[test]
751 fn test_obsidian_inline_comment_emphasis_ignored() {
752 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
754 let content = "Visible %%_hidden_%% text.";
755 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
756 let result = rule.check(&ctx).unwrap();
757
758 assert!(
759 result.is_empty(),
760 "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
761 );
762 }
763
764 #[test]
765 fn test_asterisks_in_an_html_block_are_literal() {
766 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
769 let content =
770 "Use _a_ and _b_.\n\n<table>\n <tr>\n <td><code>*://*.example.org/*</code></td>\n </tr>\n</table>\n";
771 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 assert!(rule.check(&ctx).unwrap().is_empty());
773 assert_eq!(rule.fix(&ctx).unwrap(), content);
774
775 let content = "Use _a_.\n\n<div>\n*b* and *c*\n</div>\n";
776 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 assert!(rule.check(&ctx).unwrap().is_empty());
778 }
779
780 #[test]
781 fn test_emphasis_after_an_html_block_is_still_checked() {
782 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
784 let content = "<div>\n*a*\n</div>\n\nText *b* here.\n";
785 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert_eq!(result.iter().map(|w| w.line).collect::<Vec<_>>(), vec![5]);
788 }
789}
790
791#[cfg(test)]
792mod mdx_adjacent_prose_tests {
793 use super::*;
794 #[test]
795 fn md049_mdx_code_does_not_hide_adjacent_prose() {
796 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
797 for code in [
798 "{value}",
799 "{/* _literal_ */}",
800 "{\"_literal_\"}",
801 "<Card onClick={() => null} title='_literal_'>text</Card>",
802 ] {
803 for ending in ["\n", "\r\n"] {
804 let source = format!("内容 Before {code} after _visible_{ending}");
805 let expected = format!("内容 Before {code} after *visible*{ending}");
806 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
807 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
808 assert_eq!(rule.fix(&ctx).unwrap(), expected);
809 }
810 }
811 }
812 #[test]
813 fn md049_mdx_component_values_are_literal() {
814 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
815 for name in ["span", "Card", "ui.Card", "svg:path"] {
816 let tag = format!("<{name} title='_literal_'>text</{name}>");
817 let source = format!("Before {tag} after _visible_\n");
818 let expected = format!("Before {tag} after *visible*\n");
819 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
820 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
821 assert_eq!(rule.fix(&ctx).unwrap(), expected);
822 }
823 }
824 #[test]
825 fn md049_mdx_literals_do_not_choose_consistent_style() {
826 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
827 let source = "Before <ui.Card title='*a* *b* *c*'>text</ui.Card> {\"*d* *e*\"}\n\n_first_ _second_ *visible*\n";
828 let expected = source.replace("*visible*", "_visible_");
829 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
830 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
831 assert_eq!(rule.fix(&ctx).unwrap(), expected);
832 }
833 #[test]
834 fn md049_mdx_code_cannot_supply_the_other_emphasis_marker() {
835 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
836 for source in [
837 "Before _text {\"_\"} after\n",
838 "Before _text {/* _ */} after\n",
839 "Before _text {value} end_\n",
840 ] {
841 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
842 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
843 assert_eq!(rule.fix(&ctx).unwrap(), source);
844 }
845 }
846}