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