1use crate::lint_context::SourcePositions;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rules::emphasis_style::EmphasisStyle;
4use crate::utils::emphasis_utils::find_single_emphasis_edges;
5use crate::utils::skip_context::MarkupExclusions;
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) || ctx.is_in_unresolved_autoref_identifier(byte_pos)
40 }
41
42 fn collect_emphasis_from_line<'a>(
43 &self,
44 syntax: crate::lint_context::EmphasisLine<'a>,
45 line_start_pos: usize,
46 emphasis_info: &mut Vec<(usize, usize, usize, char, &'a str)>,
47 ) {
48 for span in find_single_emphasis_edges(syntax.without_code(), syntax.markers) {
49 let original_content = &syntax.source()[span.opening.end_pos()..span.closing.start_pos];
50 emphasis_info.push((
51 syntax.line_num,
52 span.opening.start_pos + 1,
53 line_start_pos + span.opening.start_pos,
54 span.opening.as_char(),
55 original_content,
56 ));
57 }
58 }
59}
60
61impl Rule for MD049EmphasisStyle {
62 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
63 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(49))
64 }
65
66 fn name(&self) -> &'static str {
67 "MD049"
68 }
69
70 fn description(&self) -> &'static str {
71 "Emphasis style should be consistent"
72 }
73
74 fn category(&self) -> RuleCategory {
75 RuleCategory::Emphasis
76 }
77
78 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
79 let mut warnings = vec![];
80
81 if !ctx.likely_has_emphasis() {
83 return Ok(warnings);
84 }
85
86 let mut emphasis_info = vec![];
91
92 for syntax in ctx.lexical_emphasis() {
93 if ctx.lines[syntax.line_num - 1].in_html_comment {
96 continue;
97 }
98 let line_start = ctx.line_start_byte(syntax.line_num).unwrap_or(0);
99 self.collect_emphasis_from_line(syntax, line_start, &mut emphasis_info);
100 }
101
102 let mut markup = MarkupExclusions::new(ctx);
104 let math_ranges: Vec<(usize, usize)> = if emphasis_info.is_empty() || !ctx.has_char('$') {
122 Vec::new()
123 } else {
124 let code_spans = ctx.source_code_spans();
130 let math_source: std::borrow::Cow<'_, str> = if ctx.code_blocks.is_empty() && code_spans.is_empty() {
131 std::borrow::Cow::Borrowed(ctx.content)
132 } else {
133 let mut bytes = ctx.content.as_bytes().to_vec();
134 let len = bytes.len();
135 let mut mask = |start: usize, end: usize| {
136 for b in &mut bytes[start.min(len)..end.min(len)] {
137 if *b == b'$' {
138 *b = b' ';
139 }
140 }
141 };
142 for &(start, end) in &ctx.code_blocks {
143 mask(start, end);
144 }
145 for span in code_spans {
146 mask(span.byte_offset, span.byte_end);
147 }
148 std::borrow::Cow::Owned(String::from_utf8(bytes).expect("ASCII-only substitution"))
151 };
152 let mut r = crate::utils::skip_context::math_byte_ranges(&math_source);
153 r.sort_unstable_by_key(|&(start, _)| start);
154 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(r.len());
155 for (start, end) in r {
156 match merged.last_mut() {
157 Some(last) if start <= last.1 => last.1 = last.1.max(end),
158 _ => merged.push((start, end)),
159 }
160 }
161 merged
162 };
163 let has_html = ctx.has_char('<');
164 emphasis_info.retain(|(line_num, col, abs_pos, _, emphasis_content)| {
165 let closing_pos = *abs_pos + emphasis_content.len() + 1;
166 if ctx.overlaps_mdx_inline_code(*abs_pos, closing_pos + 1)
167 || ctx.is_in_jinja_string(*abs_pos)
168 || ctx.is_in_jinja_string(closing_pos)
169 || ctx.is_in_shortcode(*abs_pos)
170 || ctx.is_in_shortcode(closing_pos)
171 {
172 return false;
173 }
174 if has_html
177 && [*abs_pos, closing_pos].into_iter().any(|pos| {
178 ctx.is_in_html_comment(pos) || ctx.is_in_html_tag(pos) || ctx.is_in_jsx_component_tag(pos)
179 })
180 {
181 return false;
182 }
183 if ctx.is_in_code_span_byte(*abs_pos) || ctx.is_in_code_span_byte(closing_pos) {
186 return false;
187 }
188 let idx = math_ranges.partition_point(|&(start, _)| start <= *abs_pos);
192 if idx > 0 && *abs_pos < math_ranges[idx - 1].1 {
193 return false;
194 }
195 if ctx.is_in_obsidian_comment(*abs_pos) {
197 return false;
198 }
199 if Self::is_in_link(ctx, *abs_pos) {
201 return false;
202 }
203 let line_pos = col.saturating_sub(1); if markup.contains(*line_num - 1, line_pos) {
206 return false;
207 }
208 true
209 });
210
211 let mut positions = SourcePositions::new(ctx.content, &ctx.lines);
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 = positions.column_on_line(*line_num - 1, *abs_pos) + 1;
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 = positions.column_on_line(*line_num - 1, *abs_pos) + 1;
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 indexed_columns_preserve_unicode_locations_and_fix_ranges() {
338 for count in [1, 128] {
339 let line = format!(
340 "é日本🙂e\u{301}\t`é日本🙂` $é日$ {}\n",
341 "*first* _second_ ".repeat(count)
342 );
343 for ending in ["\n", "\r\n"] {
344 let content = line.repeat(2).replace('\n', ending);
345 for flavor in crate::format_proof::ALL_FLAVORS {
346 let ctx = crate::lint_context::LintContext::new(&content, flavor, None);
347 for style in [
348 EmphasisStyle::Consistent,
349 EmphasisStyle::Asterisk,
350 EmphasisStyle::Underscore,
351 ] {
352 let warnings = MD049EmphasisStyle::new(style).check(&ctx).unwrap();
353 assert_eq!(warnings.len(), count * 2, "{flavor:?}, {style:?}");
354 for warning in warnings {
355 let fix = warning.fix.unwrap();
356 let (line, column) = ctx.offset_to_line_col(fix.range.start);
357 assert_eq!((warning.line, warning.column), (line, column));
358 assert_eq!(warning.end_line, line);
359 assert_eq!(warning.end_column, column + content[fix.range.clone()].chars().count());
360 assert!(matches!(&content[fix.range], "*first*" | "_second_"));
361 }
362 }
363 }
364 }
365 }
366 }
367
368 #[test]
369 fn test_shortcode_markers_are_not_restyled() {
370 for (style, original, replacement) in [
371 (EmphasisStyle::Underscore, "*", "_"),
372 (EmphasisStyle::Asterisk, "_", "*"),
373 ] {
374 let rule = MD049EmphasisStyle::new(style);
375 for shortcode in [
376 format!("{{{{< note title=\"{original}literal{original}\" >}}}}"),
377 format!("{{{{% note title=\"{original}内容{original}\" %}}}}"),
378 format!("{{{{< note\n title=\"{original}literal{original}\" >}}}}"),
379 format!("{original}text {{{{< note title=\"{original}\" >}}}}"),
380 ] {
381 let source = format!("Before {shortcode}\n\n{original}visible{original}\n");
382 let expected = format!("Before {shortcode}\n\n{replacement}visible{replacement}\n");
383 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Hugo, None);
384 assert_eq!(
385 rule.check(&ctx).unwrap().len(),
386 1,
387 "shortcode arguments are literal: {source}"
388 );
389 assert_eq!(rule.fix(&ctx).unwrap(), expected);
390 }
391 }
392 }
393
394 #[test]
395 fn test_shortcode_arguments_do_not_determine_consistent_style() {
396 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
397 let source = "Before {{< note title=\"*a* *b* *c*\" >}}\n\n_first_ _second_ *visible*\n";
398 let expected = "Before {{< note title=\"*a* *b* *c*\" >}}\n\n_first_ _second_ _visible_\n";
399 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Hugo, None);
400 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
401 assert_eq!(rule.fix(&ctx).unwrap(), expected);
402 }
403
404 #[test]
405 fn test_myst_comments_do_not_determine_consistent_style() {
406 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
407 let source = "% *a* *b* *c*\n\n_first_ _second_ *visible*\n";
408 let expected = "% *a* *b* *c*\n\n_first_ _second_ _visible_\n";
409 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MyST, None);
410 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
411 assert_eq!(rule.fix(&ctx).unwrap(), expected);
412 }
413
414 #[test]
415 fn test_myst_comment_contents_are_not_linted() {
416 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
417 for prefix in ["% ", "%", " %\t", " % "] {
418 let source = format!("{prefix}*literal*\n\nBefore *visible*\n");
419 let expected = format!("{prefix}*literal*\n\nBefore _visible_\n");
420 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
421 let warnings = rule.check(&ctx).unwrap();
422 assert_eq!(warnings.len(), 1, "comments are not visible prose: {source}");
423 assert_eq!(warnings[0].line, 3);
424 assert_eq!(rule.fix(&ctx).unwrap(), expected);
425 }
426 }
427
428 #[test]
429 fn test_jinja_quoted_markers_are_not_restyled() {
430 for (style, original, replacement) in [
431 (EmphasisStyle::Underscore, "*", "_"),
432 (EmphasisStyle::Asterisk, "_", "*"),
433 ] {
434 let rule = MD049EmphasisStyle::new(style);
435 for template in [
436 format!("{{{{ \"{original}literal{original}\" }}}}"),
437 format!("{{% set text = '{original}内容{original}' %}}"),
438 format!("{{{{\n \"{original}literal{original}\"\n}}}}"),
439 format!("{original}text {{{{ \"{original}\" }}}}"),
440 ] {
441 let source = format!("Before {template}\n\n{original}visible{original}\n");
442 let expected = format!("Before {template}\n\n{replacement}visible{replacement}\n");
443 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
444 assert_eq!(
445 rule.check(&ctx).unwrap().len(),
446 1,
447 "quoted markers are literal: {source}"
448 );
449 assert_eq!(rule.fix(&ctx).unwrap(), expected);
450 }
451 }
452 }
453
454 #[test]
455 fn test_jinja_quoted_markers_do_not_determine_consistent_style() {
456 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
457 let source = "Before {{ \"*a* *b* *c*\" }}\n\n_first_ _second_ *visible*\n";
458 let expected = "Before {{ \"*a* *b* *c*\" }}\n\n_first_ _second_ _visible_\n";
459 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
460 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
461 assert_eq!(rule.fix(&ctx).unwrap(), expected);
462 }
463
464 #[test]
465 fn test_mdx_module_strings_are_not_restyled() {
466 for (style, original, replacement) in [
467 (EmphasisStyle::Underscore, "*", "_"),
468 (EmphasisStyle::Asterisk, "_", "*"),
469 ] {
470 let rule = MD049EmphasisStyle::new(style);
471 for module in [
472 format!("export const text = \"{original}literal{original}\""),
473 format!("export const data = {{\n text: \"{original}内容{original}\"\n}}"),
474 format!("import text from \"./{original}literal{original}.js\""),
475 ] {
476 let source = format!("{module}\n\n{original}visible{original}\n");
477 let expected = format!("{module}\n\n{replacement}visible{replacement}\n");
478 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
479 let warnings = rule.check(&ctx).unwrap();
480 assert_eq!(warnings.len(), 1, "module strings must stay literal: {source}");
481 assert!(warnings[0].line > module.lines().count());
482 assert_eq!(rule.fix(&ctx).unwrap(), expected);
483 }
484 }
485 }
486
487 #[test]
488 fn test_mdx_module_strings_do_not_determine_consistent_style() {
489 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
490 let source = "export const text = \"*a* *b* *c*\"\n\n_first_ _second_ *visible*\n";
491 let expected = "export const text = \"*a* *b* *c*\"\n\n_first_ _second_ _visible_\n";
492 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
493 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
494 assert_eq!(rule.fix(&ctx).unwrap(), expected);
495 }
496
497 #[test]
498 fn test_inline_html_literals_are_not_restyled() {
499 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
500 for html in [
501 "Before <!-- *literal* -->",
502 "Before <span title=\"*literal*\">visible</span>",
503 "Before <span\n title=\"*literal*\">visible</span>",
504 "日本語 <span title=\"*内容*\">表示</span>",
505 "Before *text <span title=\"*\">visible</span>",
506 ] {
507 let source = format!("_one_ _two_\n\n{html}\n");
508 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
509 assert!(
510 rule.check(&ctx).unwrap().is_empty(),
511 "HTML markers are literal: {source}"
512 );
513 assert_eq!(rule.fix(&ctx).unwrap(), source);
514 }
515 }
516
517 #[test]
518 fn test_emphasis_beside_html_attributes_still_gets_fixed() {
519 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
520 let source = "Before <span title=\"*literal*\">*visible*</span> <!-- *hidden* -->\n";
521 let expected = "Before <span title=\"*literal*\">_visible_</span> <!-- *hidden* -->\n";
522 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
523 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
524 assert_eq!(rule.fix(&ctx).unwrap(), expected);
525 let mut original_html = String::new();
526 let mut fixed_html = String::new();
527 pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new(source));
528 pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new(expected));
529 assert_eq!(original_html, fixed_html);
530 }
531
532 #[test]
533 fn test_multiline_code_span_contents_are_not_restyled() {
534 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
535 for code in [
536 "`code\n*literal*\nend`",
537 "``start `tick`\n*literal*\nend``",
538 "`start\n*code` after*",
539 "日本語 `開始\n*内容*\n終了`",
540 ] {
541 let content = format!("_one_ _two_\n\n{code}\n");
542 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
543 assert!(
544 rule.check(&ctx).unwrap().is_empty(),
545 "the asterisks are code: {content}"
546 );
547 assert_eq!(rule.fix(&ctx).unwrap(), content);
548 }
549 }
550
551 #[test]
552 fn test_emphasis_beside_multiline_code_still_gets_fixed() {
553 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
554 let source = "_one_ _two_\n\n`start\n*literal*\nend` and *visible*\n";
555 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
556 let warnings = rule.check(&ctx).unwrap();
557 assert_eq!(warnings.len(), 1);
558 assert_eq!(warnings[0].line, 5);
559 let expected = "_one_ _two_\n\n`start\n*literal*\nend` and _visible_\n";
560 assert_eq!(rule.fix(&ctx).unwrap(), expected);
561 let fixed_ctx = crate::lint_context::LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
562 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
563 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
564 let mut original_html = String::new();
565 let mut fixed_html = String::new();
566 pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new(source));
567 pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new(expected));
568 assert_eq!(original_html, fixed_html);
569 }
570
571 #[test]
572 fn test_name() {
573 let rule = MD049EmphasisStyle::default();
574 assert_eq!(rule.name(), "MD049");
575 }
576
577 #[test]
578 fn test_style_from_str() {
579 assert_eq!(EmphasisStyle::from("asterisk"), EmphasisStyle::Asterisk);
580 assert_eq!(EmphasisStyle::from("underscore"), EmphasisStyle::Underscore);
581 assert_eq!(EmphasisStyle::from("other"), EmphasisStyle::Consistent);
582 }
583
584 #[test]
585 fn test_emphasis_in_links_not_flagged() {
586 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
587 let content = r#"Check this [*asterisk*](https://example.com/*pattern*) link and [_underscore_](https://example.com/_private_).
588
589Also see the [`__init__`][__init__] reference.
590
591This should be _flagged_ since we're using asterisk style.
592
593[__init__]: https://example.com/__init__.py"#;
594 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let result = rule.check(&ctx).unwrap();
596
597 assert_eq!(result.len(), 1);
599 assert!(result[0].message.contains("Emphasis should use * instead of _"));
600 assert!(result[0].line == 5); }
603
604 #[test]
605 fn test_emphasis_in_links_vs_outside_links() {
606 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
607 let content = r#"Check [*emphasis*](https://example.com/*test*) and inline *real emphasis* text.
608
609[*link*]: https://example.com/*path*"#;
610 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let result = rule.check(&ctx).unwrap();
612
613 assert_eq!(result.len(), 1);
615 assert!(result[0].message.contains("Emphasis should use _ instead of *"));
616 assert!(result[0].line == 1);
618 }
619
620 #[test]
621 fn test_mkdocs_keys_notation_not_flagged() {
622 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
624 let content = "Press ++ctrl+alt+del++ to restart.";
625 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
626 let result = rule.check(&ctx).unwrap();
627
628 assert!(
630 result.is_empty(),
631 "Keys notation should not be flagged as emphasis. Got: {result:?}"
632 );
633 }
634
635 #[test]
636 fn test_mkdocs_caret_notation_not_flagged() {
637 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
639 let content = "This is ^superscript^ and ^^inserted^^ text.";
640 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
641 let result = rule.check(&ctx).unwrap();
642
643 assert!(
644 result.is_empty(),
645 "Caret notation should not be flagged as emphasis. Got: {result:?}"
646 );
647 }
648
649 #[test]
650 fn test_mkdocs_mark_notation_not_flagged() {
651 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
653 let content = "This is ==highlighted== text.";
654 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
655 let result = rule.check(&ctx).unwrap();
656
657 assert!(
658 result.is_empty(),
659 "Mark notation should not be flagged as emphasis. Got: {result:?}"
660 );
661 }
662
663 #[test]
664 fn test_mkdocs_mixed_content_with_real_emphasis() {
665 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
667 let content = "Press ++ctrl++ and _underscore emphasis_ here.";
668 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
669 let result = rule.check(&ctx).unwrap();
670
671 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
673 assert!(result[0].message.contains("Emphasis should use * instead of _"));
674 }
675
676 #[test]
677 fn test_mkdocs_icon_shortcode_not_flagged() {
678 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
680 let content = "Click :material-check: and _this should be flagged_.";
681 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
682 let result = rule.check(&ctx).unwrap();
683
684 assert_eq!(result.len(), 1);
686 assert!(result[0].message.contains("Emphasis should use * instead of _"));
687 }
688
689 #[test]
690 fn test_mkdocstrings_block_not_flagged() {
691 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
692 let content = "# Example\n\n::: my_module.MyClass\n options:\n members:\n - _private_method\n";
693 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
694 let result = rule.check(&ctx).unwrap();
695
696 assert!(
697 result.is_empty(),
698 "_private_method_ inside mkdocstrings block should not be flagged. Got: {result:?}"
699 );
700 }
701
702 #[test]
703 fn test_mkdocstrings_block_with_emphasis_outside() {
704 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
705 let content = "::: my_module.MyClass\n options:\n members:\n - _init\n\nThis _should be flagged_ outside.\n";
706 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
707 let result = rule.check(&ctx).unwrap();
708
709 assert_eq!(
710 result.len(),
711 1,
712 "Only emphasis outside mkdocstrings should be flagged. Got: {result:?}"
713 );
714 assert_eq!(result[0].line, 6);
715 }
716
717 #[test]
718 fn test_inline_code_inside_emphasis_preserved_on_fix() {
719 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
723 let content = "- _An item with `inline code` inside._ Trailing text.";
724 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725
726 let fixed = rule.fix(&ctx).unwrap();
727 assert_eq!(fixed, "- *An item with `inline code` inside.* Trailing text.");
728 assert!(!fixed.contains('X'), "masking placeholder leaked into fix: {fixed}");
729
730 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
732 assert_eq!(rule.fix(&ctx2).unwrap(), fixed);
733 }
734
735 #[test]
736 fn test_inline_code_inside_emphasis_underscore_style() {
737 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
739 let content = "See *the `id` field* below.";
740 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let fixed = rule.fix(&ctx).unwrap();
742 assert_eq!(fixed, "See _the `id` field_ below.");
743 }
744
745 #[test]
746 fn test_obsidian_inline_comment_emphasis_ignored() {
747 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
749 let content = "Visible %%_hidden_%% text.";
750 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
751 let result = rule.check(&ctx).unwrap();
752
753 assert!(
754 result.is_empty(),
755 "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
756 );
757 }
758
759 #[test]
760 fn test_asterisks_in_an_html_block_are_literal() {
761 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
764 let content =
765 "Use _a_ and _b_.\n\n<table>\n <tr>\n <td><code>*://*.example.org/*</code></td>\n </tr>\n</table>\n";
766 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 assert!(rule.check(&ctx).unwrap().is_empty());
768 assert_eq!(rule.fix(&ctx).unwrap(), content);
769
770 let content = "Use _a_.\n\n<div>\n*b* and *c*\n</div>\n";
771 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 assert!(rule.check(&ctx).unwrap().is_empty());
773 }
774
775 #[test]
776 fn test_emphasis_after_an_html_block_is_still_checked() {
777 let rule = MD049EmphasisStyle::new(EmphasisStyle::Underscore);
779 let content = "<div>\n*a*\n</div>\n\nText *b* here.\n";
780 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782 assert_eq!(result.iter().map(|w| w.line).collect::<Vec<_>>(), vec![5]);
783 }
784}
785
786#[cfg(test)]
787mod mdx_adjacent_prose_tests {
788 use super::*;
789 #[test]
790 fn md049_mdx_code_does_not_hide_adjacent_prose() {
791 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
792 for code in [
793 "{value}",
794 "{/* _literal_ */}",
795 "{\"_literal_\"}",
796 "<Card onClick={() => null} title='_literal_'>text</Card>",
797 ] {
798 for ending in ["\n", "\r\n"] {
799 let source = format!("内容 Before {code} after _visible_{ending}");
800 let expected = format!("内容 Before {code} after *visible*{ending}");
801 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
802 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
803 assert_eq!(rule.fix(&ctx).unwrap(), expected);
804 }
805 }
806 }
807 #[test]
808 fn md049_mdx_component_values_are_literal() {
809 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
810 for name in ["span", "Card", "ui.Card", "svg:path"] {
811 let tag = format!("<{name} title='_literal_'>text</{name}>");
812 let source = format!("Before {tag} after _visible_\n");
813 let expected = format!("Before {tag} after *visible*\n");
814 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
815 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
816 assert_eq!(rule.fix(&ctx).unwrap(), expected);
817 }
818 }
819 #[test]
820 fn md049_mdx_literals_do_not_choose_consistent_style() {
821 let rule = MD049EmphasisStyle::new(EmphasisStyle::Consistent);
822 let source = "Before <ui.Card title='*a* *b* *c*'>text</ui.Card> {\"*d* *e*\"}\n\n_first_ _second_ *visible*\n";
823 let expected = source.replace("*visible*", "_visible_");
824 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
825 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
826 assert_eq!(rule.fix(&ctx).unwrap(), expected);
827 }
828 #[test]
829 fn md049_mdx_code_cannot_supply_the_other_emphasis_marker() {
830 let rule = MD049EmphasisStyle::new(EmphasisStyle::Asterisk);
831 for source in [
832 "Before _text {\"_\"} after\n",
833 "Before _text {/* _ */} after\n",
834 "Before _text {value} end_\n",
835 ] {
836 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
837 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
838 assert_eq!(rule.fix(&ctx).unwrap(), source);
839 }
840 }
841}