1use crate::filtered_lines::FilteredLinesExt;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::emphasis_utils::{
7 EmphasisSpan, find_emphasis_markers, find_emphasis_spans, find_valid_emphasis_ranges, has_doc_patterns,
8 replace_inline_code, replace_inline_math,
9};
10use crate::utils::kramdown_utils::has_span_ial;
11use crate::utils::range_utils::byte_to_char_count;
12use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
13use crate::utils::skip_context::{
14 is_in_inline_html_code, is_in_jsx_expression, is_in_math_context, is_in_mdx_comment, is_in_mkdocs_markup,
15};
16use crate::utils::table_utils::TableUtils;
17use std::ops::Range;
18
19#[inline]
21fn has_spacing_issues(span: &EmphasisSpan) -> bool {
22 span.has_leading_space || span.has_trailing_space
23}
24
25fn table_line_flags(ctx: &crate::lint_context::LintContext) -> Vec<bool> {
28 if ctx.table_blocks.is_empty() {
29 return Vec::new();
30 }
31
32 let mut flags = vec![false; ctx.lines.len()];
33 for block in &ctx.table_blocks {
35 for idx in block.start_line..=block.end_line {
36 if let Some(flag) = flags.get_mut(idx) {
37 *flag = true;
38 }
39 }
40 }
41 flags
42}
43
44fn table_cell_ranges(line: &str) -> Vec<Range<usize>> {
51 let masked;
54 let scan = if line.contains('\\') || line.contains('`') {
55 let escaped = TableUtils::mask_pipes_for_table_parsing(line);
56 masked = TableUtils::mask_pipes_in_inline_code(&escaped);
57 debug_assert_eq!(
58 masked.len(),
59 line.len(),
60 "pipe masking must preserve byte offsets for cell slicing"
61 );
62 masked.as_str()
63 } else {
64 line
65 };
66
67 let mut ranges = Vec::new();
68 let mut start = 0;
69 for (pipe_pos, _) in scan.match_indices('|') {
70 ranges.push(start..pipe_pos);
71 start = pipe_pos + 1;
72 }
73 ranges.push(start..line.len());
74 ranges
75}
76
77#[inline]
80fn truncate_for_display(text: &str, max_len: usize) -> String {
81 if text.len() <= max_len {
82 return text.to_string();
83 }
84
85 let prefix_len = max_len / 2 - 2; let suffix_len = max_len / 2 - 2;
87
88 let prefix_end = text.floor_char_boundary(prefix_len.min(text.len()));
90 let suffix_start = text.floor_char_boundary(text.len().saturating_sub(suffix_len));
91
92 format!("{}...{}", &text[..prefix_end], &text[suffix_start..])
93}
94
95#[derive(Clone)]
97pub struct MD037NoSpaceInEmphasis;
98
99impl Default for MD037NoSpaceInEmphasis {
100 fn default() -> Self {
101 Self
102 }
103}
104
105impl MD037NoSpaceInEmphasis {
106 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
108 for link in ctx.links() {
110 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
111 if link.is_reference && link.url.is_empty() {
112 continue;
113 }
114 return true;
115 }
116 }
117
118 for image in ctx.images() {
120 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
121 return true;
122 }
123 }
124
125 ctx.is_in_reference_def(byte_pos)
127 }
128
129 fn emphasis_span_ends(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
135 let mut ends: Vec<(usize, usize)> = ctx
136 .emphasis_spans()
137 .iter()
138 .map(|span| (span.byte_end, span.byte_offset))
139 .collect();
140 ends.sort_unstable();
141 ends
142 }
143
144 fn closes_earlier_emphasis(span_ends: &[(usize, usize)], start: usize, end: usize) -> bool {
156 let first_after_start = span_ends.partition_point(|(span_end, _)| *span_end <= start);
157 span_ends[first_after_start..]
158 .iter()
159 .take_while(|(span_end, _)| *span_end < end)
160 .any(|(_, span_start)| *span_start <= start)
161 }
162}
163
164impl Rule for MD037NoSpaceInEmphasis {
165 fn name(&self) -> &'static str {
166 "MD037"
167 }
168
169 fn description(&self) -> &'static str {
170 "Spaces inside emphasis markers"
171 }
172
173 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
174 let content = ctx.content;
175 let _timer = crate::profiling::ScopedTimer::new("MD037_check");
176
177 if !content.contains('*') && !content.contains('_') {
179 return Ok(vec![]);
180 }
181
182 let mut warnings = Vec::new();
185 let table_lines = table_line_flags(ctx);
186
187 for line in ctx
191 .filtered_lines()
192 .skip_front_matter()
193 .skip_code_blocks()
194 .skip_math_blocks()
195 .skip_html_blocks()
196 .skip_jsx_expressions()
197 .skip_mdx_comments()
198 .skip_obsidian_comments()
199 .skip_mkdocstrings()
200 {
201 if !line.content.contains('*') && !line.content.contains('_') {
203 continue;
204 }
205
206 if table_lines.get(line.line_num - 1).copied().unwrap_or(false) {
207 for cell in table_cell_ranges(line.content) {
212 let Some(cell_content) = line.content.get(cell.clone()) else {
213 continue;
214 };
215 if !cell_content.contains('*') && !cell_content.contains('_') {
216 continue;
217 }
218 if has_doc_patterns(cell_content) {
219 continue;
220 }
221 self.check_line_content_for_emphasis_fast(cell_content, line.line_num, cell.start, &mut warnings);
222 }
223 continue;
224 }
225
226 self.check_line_for_emphasis_issues_fast(line.content, line.line_num, &mut warnings);
228 }
229
230 let mut filtered_warnings = Vec::new();
232 let lines = ctx.raw_lines();
233 let span_ends = if warnings.is_empty() {
234 Vec::new()
235 } else {
236 Self::emphasis_span_ends(ctx)
237 };
238
239 for (line_idx, line) in lines.iter().enumerate() {
240 let line_num = line_idx + 1;
241 let line_start_pos = ctx.line_start_byte(line_num).unwrap_or(0);
242
243 for warning in &warnings {
245 if warning.line == line_num {
246 let byte_pos = line_start_pos + (warning.column - 1);
250 let line_pos = warning.column - 1;
252 let char_col = byte_to_char_count(line, warning.column - 1);
253
254 let in_pandoc_construct = ctx.flavor.is_pandoc_compatible() && ctx.is_in_bracketed_span(byte_pos);
265 let byte_end = line_start_pos + (warning.end_column - 1);
266 if !in_pandoc_construct
267 && !Self::closes_earlier_emphasis(&span_ends, byte_pos, byte_end)
268 && !self.is_in_link(ctx, byte_pos)
269 && !ctx.is_in_html_comment(byte_pos)
270 && !ctx.is_in_shortcode(byte_pos)
271 && !is_in_math_context(ctx, byte_pos)
272 && !ctx.is_in_code_span(line_num, char_col)
273 && !is_in_inline_html_code(line, line_pos)
274 && !is_in_jsx_expression(ctx, byte_pos)
275 && !is_in_mdx_comment(ctx, byte_pos)
276 && !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
277 && !ctx.is_position_in_obsidian_comment(line_num, char_col)
278 {
279 let mut adjusted_warning = warning.clone();
280 adjusted_warning.column = char_col;
282 adjusted_warning.end_column = byte_to_char_count(line, warning.end_column - 1);
283 if let Some(fix) = &mut adjusted_warning.fix {
284 let abs_start = line_start_pos + fix.range.start;
286 let abs_end = line_start_pos + fix.range.end;
287 fix.range = abs_start..abs_end;
288 }
289 filtered_warnings.push(adjusted_warning);
290 }
291 }
292 }
293 }
294
295 Ok(filtered_warnings)
296 }
297
298 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
299 let content = ctx.content;
300 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
301
302 if !content.contains('*') && !content.contains('_') {
304 return Ok(content.to_string());
305 }
306
307 let warnings = self.check(ctx)?;
309 let warnings =
310 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
311
312 if warnings.is_empty() {
314 return Ok(content.to_string());
315 }
316
317 let mut result = content.to_string();
319 let mut offset: isize = 0;
320
321 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
323 sorted_warnings.sort_by_key(|w| (w.line, w.column));
324
325 for warning in sorted_warnings {
326 if let Some(fix) = &warning.fix {
327 let actual_start = (fix.range.start as isize + offset) as usize;
329 let actual_end = (fix.range.end as isize + offset) as usize;
330
331 if actual_start < result.len() && actual_end <= result.len() {
333 result.replace_range(actual_start..actual_end, &fix.replacement);
335 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
337 }
338 }
339 }
340
341 Ok(result)
342 }
343
344 fn category(&self) -> RuleCategory {
346 RuleCategory::Emphasis
347 }
348
349 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
351 ctx.content.is_empty() || !ctx.likely_has_emphasis()
352 }
353
354 fn as_any(&self) -> &dyn std::any::Any {
355 self
356 }
357
358 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
359 where
360 Self: Sized,
361 {
362 Box::new(MD037NoSpaceInEmphasis)
363 }
364}
365
366impl MD037NoSpaceInEmphasis {
367 #[inline]
369 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
370 if has_doc_patterns(line) {
372 return;
373 }
374
375 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
380 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
381 {
382 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
383 && let Some(full_match) = caps.get(0)
384 {
385 let list_marker_end = full_match.end();
386 if list_marker_end < line.len() {
387 let remaining_content = &line[list_marker_end..];
388
389 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
392 }
393 }
394 return;
395 }
396
397 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
399 }
400
401 fn check_line_content_for_emphasis_fast(
403 &self,
404 content: &str,
405 line_num: usize,
406 offset: usize,
407 warnings: &mut Vec<LintWarning>,
408 ) {
409 let processed_content = replace_inline_code(content);
412 let processed_content = replace_inline_math(&processed_content);
413
414 let markers = find_emphasis_markers(&processed_content);
416 if markers.is_empty() {
417 return;
418 }
419
420 let spans = find_emphasis_spans(&processed_content, &markers);
422
423 let valid_ranges = find_valid_emphasis_ranges(&processed_content, &markers);
428
429 for span in spans {
431 if has_spacing_issues(&span) {
432 let full_start = span.opening.start_pos;
433 let full_end = span.closing.end_pos();
434
435 if valid_ranges
437 .iter()
438 .any(|&(start, end)| start <= full_start && full_end <= end)
439 {
440 continue;
441 }
442
443 let full_text = &content[full_start..full_end];
444
445 if full_end < content.len() {
448 let remaining = &content[full_end..];
449 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
451 continue;
452 }
453 }
454
455 let marker_char = span.opening.as_char();
457 let marker_str = if span.opening.count == 1 {
458 marker_char.to_string()
459 } else {
460 format!("{marker_char}{marker_char}")
461 };
462
463 let original_content = &content[span.opening.end_pos()..span.closing.start_pos];
470 let trimmed_content = original_content.trim();
471 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
472
473 let display_text = truncate_for_display(full_text, 60);
475
476 let warning = LintWarning {
477 rule_name: Some(self.name().to_string()),
478 message: format!("Spaces inside emphasis markers: {display_text:?}"),
479 line: line_num,
483 column: offset + full_start + 1,
484 end_line: line_num,
485 end_column: offset + full_end + 1,
486 severity: Severity::Warning,
487 fix: Some(Fix::new((offset + full_start)..(offset + full_end), fixed_text)),
488 };
489
490 warnings.push(warning);
491 }
492 }
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use crate::lint_context::LintContext;
500
501 #[test]
502 fn table_cell_ranges_unmasked_scan_matches_masked_scan() {
503 fn always_masked(line: &str) -> Vec<Range<usize>> {
506 let escaped = TableUtils::mask_pipes_for_table_parsing(line);
507 let masked = TableUtils::mask_pipes_in_inline_code(&escaped);
508 let mut ranges = Vec::new();
509 let mut start = 0;
510 for (pipe_pos, _) in masked.match_indices('|') {
511 ranges.push(start..pipe_pos);
512 start = pipe_pos + 1;
513 }
514 ranges.push(start..line.len());
515 ranges
516 }
517
518 for line in [
519 "| a | b |",
520 "| a * x * | b |",
521 "a | b",
522 "no pipes at all",
523 "",
524 "|||",
525 "| naïve ünïcode | 日本語 |",
526 r"| a \| b | c |",
527 "| `a | b` | c |",
528 r"| `a \| b` | c |",
529 r"| a \\ | b |",
530 "> | a * x * | b |",
531 ] {
532 assert_eq!(table_cell_ranges(line), always_masked(line), "line: {line:?}");
533 }
534 }
535
536 #[test]
537 fn test_emphasis_marker_parsing() {
538 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
539 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
542 assert_eq!(markers.len(), 4); }
544
545 #[test]
546 fn test_emphasis_span_detection() {
547 let markers = find_emphasis_markers("This has *valid* emphasis");
548 let spans = find_emphasis_spans("This has *valid* emphasis", &markers);
549 assert_eq!(spans.len(), 1);
550 assert_eq!(spans[0].content, "valid");
551 assert!(!spans[0].has_leading_space);
552 assert!(!spans[0].has_trailing_space);
553
554 let markers = find_emphasis_markers("This has * invalid * emphasis");
555 let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
556 assert_eq!(spans.len(), 1);
557 assert_eq!(spans[0].content, " invalid ");
558 assert!(spans[0].has_leading_space);
559 assert!(spans[0].has_trailing_space);
560 }
561
562 #[test]
563 fn test_with_document_structure() {
564 let rule = MD037NoSpaceInEmphasis;
565
566 let content = "This is *correct* emphasis and **strong emphasis**";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570 assert!(result.is_empty(), "No warnings expected for correct emphasis");
571
572 let content = "This is * text with spaces * and more content";
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let result = rule.check(&ctx).unwrap();
576 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
577
578 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx).unwrap();
582 assert!(
583 !result.is_empty(),
584 "Expected warnings for spaces in emphasis outside code block"
585 );
586 }
587
588 #[test]
589 fn test_inline_code_inside_spaced_emphasis_preserved_on_fix() {
590 let rule = MD037NoSpaceInEmphasis;
594 let content = "Set * the `id` field * below.";
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let fixed = rule.fix(&ctx).unwrap();
597 assert_eq!(fixed, "Set *the `id` field* below.");
598 assert!(!fixed.contains('X'), "masking placeholder leaked into fix: {fixed}");
599 }
600
601 #[test]
602 fn test_emphasis_in_links_not_flagged() {
603 let rule = MD037NoSpaceInEmphasis;
604 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
605
606This has * real spaced emphasis * that should be flagged."#;
607 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609
610 assert_eq!(
614 result.len(),
615 1,
616 "Expected exactly 1 warning, but got: {:?}",
617 result.len()
618 );
619 assert!(result[0].message.contains("Spaces inside emphasis markers"));
620 assert!(result[0].line == 3); }
623
624 #[test]
625 fn test_emphasis_in_links_vs_outside_links() {
626 let rule = MD037NoSpaceInEmphasis;
627 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
628
629[* link *]: https://example.com/*path*"#;
630 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632
633 assert_eq!(result.len(), 1);
635 assert!(result[0].message.contains("Spaces inside emphasis markers"));
636 assert!(result[0].line == 1);
638 }
639
640 #[test]
641 fn test_issue_49_asterisk_in_inline_code() {
642 let rule = MD037NoSpaceInEmphasis;
644
645 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
649 assert!(
650 result.is_empty(),
651 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
652 );
653 }
654
655 #[test]
656 fn test_issue_28_inline_code_in_emphasis() {
657 let rule = MD037NoSpaceInEmphasis;
659
660 let content = "Though, we often call this an **inline `if`** because it looks sort of like an `if`-`else` statement all in *one line* of code.";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let result = rule.check(&ctx).unwrap();
664 assert!(
665 result.is_empty(),
666 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
667 );
668
669 let content2 = "The **`foo` and `bar`** methods are important.";
671 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
672 let result2 = rule.check(&ctx2).unwrap();
673 assert!(
674 result2.is_empty(),
675 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
676 );
677
678 let content3 = "This is __inline `code`__ with underscores.";
680 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
681 let result3 = rule.check(&ctx3).unwrap();
682 assert!(
683 result3.is_empty(),
684 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
685 );
686
687 let content4 = "This is *inline `test`* with single asterisks.";
689 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
690 let result4 = rule.check(&ctx4).unwrap();
691 assert!(
692 result4.is_empty(),
693 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
694 );
695
696 let content5 = "This has * real spaces * that should be flagged.";
698 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
699 let result5 = rule.check(&ctx5).unwrap();
700 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
701 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
702 }
703
704 #[test]
705 fn test_multibyte_utf8_no_panic() {
706 let rule = MD037NoSpaceInEmphasis;
710
711 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
713 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx);
715 assert!(result.is_ok(), "Greek text should not panic");
716
717 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
719 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx);
721 assert!(result.is_ok(), "Chinese text should not panic");
722
723 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
725 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx);
727 assert!(result.is_ok(), "Cyrillic text should not panic");
728
729 let mixed =
731 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
732 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
733 let result = rule.check(&ctx);
734 assert!(result.is_ok(), "Mixed CJK text should not panic");
735
736 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
738 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx);
740 assert!(result.is_ok(), "Arabic text should not panic");
741
742 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
744 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx);
746 assert!(result.is_ok(), "Emoji text should not panic");
747 }
748
749 #[test]
750 fn test_template_shortcode_syntax_not_flagged() {
751 let rule = MD037NoSpaceInEmphasis;
754
755 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert!(
760 result.is_empty(),
761 "Template shortcode syntax should not be flagged. Got: {result:?}"
762 );
763
764 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 let result = rule.check(&ctx).unwrap();
768 assert!(
769 result.is_empty(),
770 "Template shortcode syntax should not be flagged. Got: {result:?}"
771 );
772
773 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.check(&ctx).unwrap();
777 assert!(
778 result.is_empty(),
779 "Multiple template shortcodes should not be flagged. Got: {result:?}"
780 );
781
782 let content = "This has * real spaced emphasis * here.";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let result = rule.check(&ctx).unwrap();
786 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
787 }
788
789 #[test]
790 fn test_multiline_code_span_not_flagged() {
791 let rule = MD037NoSpaceInEmphasis;
794
795 let content = "# Test\n\naffects the structure. `1 + 0 + 0` is parsed as `(1 + 0) +\n0` while `1 + 0 * 0` is parsed as `1 + (0 * 0)`. Since the pattern";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert!(
800 result.is_empty(),
801 "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
802 );
803
804 let content2 = "Text with `code that\nspans * multiple * lines` here.";
806 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
807 let result2 = rule.check(&ctx2).unwrap();
808 assert!(
809 result2.is_empty(),
810 "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
811 );
812 }
813
814 #[test]
815 fn test_html_block_asterisks_not_flagged() {
816 let rule = MD037NoSpaceInEmphasis;
817
818 let content = r#"<table>
820<tr><td>Format</td><td>Size</td></tr>
821<tr><td>BC1</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 8</code></td></tr>
822<tr><td>BC2</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 16</code></td></tr>
823</table>"#;
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826 assert!(
827 result.is_empty(),
828 "Should not flag asterisks inside HTML blocks. Got: {result:?}"
829 );
830
831 let content2 = "<div>\n<p>Value is * something * here</p>\n</div>";
833 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
834 let result2 = rule.check(&ctx2).unwrap();
835 assert!(
836 result2.is_empty(),
837 "Should not flag emphasis-like patterns inside HTML div blocks. Got: {result2:?}"
838 );
839
840 let content3 = "Regular * spaced emphasis * text\n\n<div>* not emphasis *</div>";
842 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
843 let result3 = rule.check(&ctx3).unwrap();
844 assert_eq!(
845 result3.len(),
846 1,
847 "Should flag spaced emphasis in regular markdown but not inside HTML blocks. Got: {result3:?}"
848 );
849 assert_eq!(result3[0].line, 1, "Warning should be on line 1 (regular markdown)");
850 }
851
852 #[test]
853 fn test_mkdocs_icon_shortcode_not_flagged() {
854 let rule = MD037NoSpaceInEmphasis;
856
857 let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
861 let result = rule.check(&ctx).unwrap();
862 assert!(
863 result.is_empty(),
864 "Should not flag MkDocs icon shortcodes. Got: {result:?}"
865 );
866
867 let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
869 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
870 let result2 = rule.check(&ctx2).unwrap();
871 assert!(
872 !result2.is_empty(),
873 "Should still flag real spaced emphasis in MkDocs mode"
874 );
875 }
876
877 #[test]
878 fn test_mkdocs_pymdown_markup_not_flagged() {
879 let rule = MD037NoSpaceInEmphasis;
881
882 let content = "Press ++ctrl+c++ to copy.";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
885 let result = rule.check(&ctx).unwrap();
886 assert!(
887 result.is_empty(),
888 "Should not flag PyMdown Keys notation. Got: {result:?}"
889 );
890
891 let content2 = "This is ==highlighted text== for emphasis.";
893 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
894 let result2 = rule.check(&ctx2).unwrap();
895 assert!(
896 result2.is_empty(),
897 "Should not flag PyMdown Mark notation. Got: {result2:?}"
898 );
899
900 let content3 = "This is ^^inserted text^^ here.";
902 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
903 let result3 = rule.check(&ctx3).unwrap();
904 assert!(
905 result3.is_empty(),
906 "Should not flag PyMdown Insert notation. Got: {result3:?}"
907 );
908
909 let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
911 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
912 let result4 = rule.check(&ctx4).unwrap();
913 assert!(
914 !result4.is_empty(),
915 "Should still flag real spaced emphasis alongside PyMdown markup"
916 );
917 }
918
919 #[test]
922 fn test_obsidian_highlight_not_flagged() {
923 let rule = MD037NoSpaceInEmphasis;
925
926 let content = "This is ==highlighted text== here.";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
929 let result = rule.check(&ctx).unwrap();
930 assert!(
931 result.is_empty(),
932 "Should not flag Obsidian highlight syntax. Got: {result:?}"
933 );
934 }
935
936 #[test]
937 fn test_obsidian_highlight_multiple_on_line() {
938 let rule = MD037NoSpaceInEmphasis;
940
941 let content = "Both ==one== and ==two== are highlighted.";
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
943 let result = rule.check(&ctx).unwrap();
944 assert!(
945 result.is_empty(),
946 "Should not flag multiple Obsidian highlights. Got: {result:?}"
947 );
948 }
949
950 #[test]
951 fn test_obsidian_highlight_entire_paragraph() {
952 let rule = MD037NoSpaceInEmphasis;
954
955 let content = "==Entire paragraph highlighted==";
956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
957 let result = rule.check(&ctx).unwrap();
958 assert!(
959 result.is_empty(),
960 "Should not flag entire highlighted paragraph. Got: {result:?}"
961 );
962 }
963
964 #[test]
965 fn test_obsidian_highlight_with_emphasis() {
966 let rule = MD037NoSpaceInEmphasis;
968
969 let content = "**==bold highlight==**";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
972 let result = rule.check(&ctx).unwrap();
973 assert!(
974 result.is_empty(),
975 "Should not flag bold highlight combination. Got: {result:?}"
976 );
977
978 let content2 = "*==italic highlight==*";
980 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Obsidian, None);
981 let result2 = rule.check(&ctx2).unwrap();
982 assert!(
983 result2.is_empty(),
984 "Should not flag italic highlight combination. Got: {result2:?}"
985 );
986 }
987
988 #[test]
989 fn test_obsidian_highlight_in_lists() {
990 let rule = MD037NoSpaceInEmphasis;
992
993 let content = "- Item with ==highlight== text\n- Another ==highlighted== item";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
995 let result = rule.check(&ctx).unwrap();
996 assert!(
997 result.is_empty(),
998 "Should not flag highlights in list items. Got: {result:?}"
999 );
1000 }
1001
1002 #[test]
1003 fn test_obsidian_highlight_in_blockquote() {
1004 let rule = MD037NoSpaceInEmphasis;
1006
1007 let content = "> This quote has ==highlighted== text.";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1009 let result = rule.check(&ctx).unwrap();
1010 assert!(
1011 result.is_empty(),
1012 "Should not flag highlights in blockquotes. Got: {result:?}"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_obsidian_highlight_in_tables() {
1018 let rule = MD037NoSpaceInEmphasis;
1020
1021 let content = "| Header | Column |\n|--------|--------|\n| ==highlighted== | text |";
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1023 let result = rule.check(&ctx).unwrap();
1024 assert!(
1025 result.is_empty(),
1026 "Should not flag highlights in tables. Got: {result:?}"
1027 );
1028 }
1029
1030 #[test]
1031 fn test_obsidian_highlight_in_code_blocks_ignored() {
1032 let rule = MD037NoSpaceInEmphasis;
1034
1035 let content = "```\n==not highlight in code==\n```";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1037 let result = rule.check(&ctx).unwrap();
1038 assert!(
1039 result.is_empty(),
1040 "Should ignore highlights in code blocks. Got: {result:?}"
1041 );
1042 }
1043
1044 #[test]
1045 fn test_obsidian_highlight_edge_case_three_equals() {
1046 let rule = MD037NoSpaceInEmphasis;
1048
1049 let content = "Test === something === here";
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1052 let result = rule.check(&ctx).unwrap();
1053 let _ = result;
1056 }
1057
1058 #[test]
1059 fn test_obsidian_highlight_edge_case_four_equals() {
1060 let rule = MD037NoSpaceInEmphasis;
1062
1063 let content = "Test ==== here";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1065 let result = rule.check(&ctx).unwrap();
1066 let _ = result;
1068 }
1069
1070 #[test]
1071 fn test_obsidian_highlight_adjacent() {
1072 let rule = MD037NoSpaceInEmphasis;
1074
1075 let content = "==one====two==";
1076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1077 let result = rule.check(&ctx).unwrap();
1078 let _ = result;
1080 }
1081
1082 #[test]
1083 fn test_obsidian_highlight_with_special_chars() {
1084 let rule = MD037NoSpaceInEmphasis;
1086
1087 let content = "Test ==code: `test`== here";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1090 let result = rule.check(&ctx).unwrap();
1091 let _ = result;
1093 }
1094
1095 #[test]
1096 fn test_obsidian_highlight_unclosed() {
1097 let rule = MD037NoSpaceInEmphasis;
1099
1100 let content = "This ==starts but never ends";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1102 let result = rule.check(&ctx).unwrap();
1103 let _ = result;
1105 }
1106
1107 #[test]
1108 fn test_obsidian_highlight_still_flags_real_emphasis_issues() {
1109 let rule = MD037NoSpaceInEmphasis;
1111
1112 let content = "This has * spaced emphasis * and ==valid highlight==";
1113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1114 let result = rule.check(&ctx).unwrap();
1115 assert!(
1116 !result.is_empty(),
1117 "Should still flag real spaced emphasis in Obsidian mode"
1118 );
1119 assert!(
1120 result.len() == 1,
1121 "Should flag exactly one issue (the spaced emphasis). Got: {result:?}"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_standard_flavor_does_not_recognize_highlight() {
1127 let rule = MD037NoSpaceInEmphasis;
1130
1131 let content = "This is ==highlighted text== here.";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 let result = rule.check(&ctx).unwrap();
1134 let _ = result; }
1139
1140 #[test]
1141 fn test_obsidian_highlight_mixed_with_regular_emphasis() {
1142 let rule = MD037NoSpaceInEmphasis;
1144
1145 let content = "==highlighted== and *italic* and **bold** text";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1147 let result = rule.check(&ctx).unwrap();
1148 assert!(
1149 result.is_empty(),
1150 "Should not flag valid highlight and emphasis. Got: {result:?}"
1151 );
1152 }
1153
1154 #[test]
1155 fn test_obsidian_highlight_unicode() {
1156 let rule = MD037NoSpaceInEmphasis;
1158
1159 let content = "Text ==日本語 highlighted== here";
1160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1161 let result = rule.check(&ctx).unwrap();
1162 assert!(
1163 result.is_empty(),
1164 "Should handle Unicode in highlights. Got: {result:?}"
1165 );
1166 }
1167
1168 #[test]
1169 fn test_obsidian_highlight_with_html() {
1170 let rule = MD037NoSpaceInEmphasis;
1172
1173 let content = "<!-- ==not highlight in comment== --> ==actual highlight==";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1175 let result = rule.check(&ctx).unwrap();
1176 let _ = result;
1178 }
1179
1180 #[test]
1181 fn test_obsidian_inline_comment_emphasis_ignored() {
1182 let rule = MD037NoSpaceInEmphasis;
1184
1185 let content = "Visible %%* spaced emphasis *%% still visible.";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1187 let result = rule.check(&ctx).unwrap();
1188
1189 assert!(
1190 result.is_empty(),
1191 "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_inline_html_code_not_flagged() {
1197 let rule = MD037NoSpaceInEmphasis;
1198
1199 let content = "The formula is <code>a * b * c</code> in math.";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 let result = rule.check(&ctx).unwrap();
1203 assert!(
1204 result.is_empty(),
1205 "Should not flag asterisks inside inline <code> tags. Got: {result:?}"
1206 );
1207
1208 let content2 = "Use <kbd>Ctrl * A</kbd> and <samp>x * y</samp> here.";
1210 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1211 let result2 = rule.check(&ctx2).unwrap();
1212 assert!(
1213 result2.is_empty(),
1214 "Should not flag asterisks inside inline <kbd> and <samp> tags. Got: {result2:?}"
1215 );
1216
1217 let content3 = r#"Result: <code class="math">a * b</code> done."#;
1219 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1220 let result3 = rule.check(&ctx3).unwrap();
1221 assert!(
1222 result3.is_empty(),
1223 "Should not flag asterisks inside <code> with attributes. Got: {result3:?}"
1224 );
1225
1226 let content4 = "Text * spaced * and <code>a * b</code>.";
1228 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1229 let result4 = rule.check(&ctx4).unwrap();
1230 assert_eq!(
1231 result4.len(),
1232 1,
1233 "Should flag real spaced emphasis but not code content. Got: {result4:?}"
1234 );
1235 assert_eq!(result4[0].column, 6);
1236 }
1237
1238 #[test]
1241 fn test_pandoc_bracketed_span_guard() {
1242 use crate::config::MarkdownFlavor;
1243 let rule = MD037NoSpaceInEmphasis;
1244 let content = "See [* important *]{.highlight} for details.\n";
1246 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1247 let result = rule.check(&ctx).unwrap();
1248 assert!(
1249 result.is_empty(),
1250 "MD037 should not flag emphasis-like patterns inside Pandoc bracketed spans: {result:?}"
1251 );
1252
1253 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1255 let result_std = rule.check(&ctx_std).unwrap();
1256 assert!(
1257 !result_std.is_empty(),
1258 "MD037 should still flag spaces in emphasis under Standard flavor: {result_std:?}"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_spaced_bold_metadata_pattern_detected() {
1264 let rule = MD037NoSpaceInEmphasis;
1265
1266 let content = "# Test\n\n** Explicit Import**: Convert markdownlint configs to rumdl format:";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx).unwrap();
1270 assert_eq!(
1271 result.len(),
1272 1,
1273 "Should flag '** Explicit Import**' as spaced emphasis. Got: {result:?}"
1274 );
1275 assert_eq!(result[0].line, 3);
1276
1277 let content2 = "# Test\n\n**trailing only **: some text";
1279 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1280 let result2 = rule.check(&ctx2).unwrap();
1281 assert_eq!(
1282 result2.len(),
1283 1,
1284 "Should flag '**trailing only **' as spaced emphasis. Got: {result2:?}"
1285 );
1286
1287 let content3 = "# Test\n\n** both spaces **: some text";
1289 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1290 let result3 = rule.check(&ctx3).unwrap();
1291 assert_eq!(
1292 result3.len(),
1293 1,
1294 "Should flag '** both spaces **' as spaced emphasis. Got: {result3:?}"
1295 );
1296
1297 let content4 = "# Test\n\n**Key**: value";
1299 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1300 let result4 = rule.check(&ctx4).unwrap();
1301 assert!(
1302 result4.is_empty(),
1303 "Should not flag valid bold metadata '**Key**: value'. Got: {result4:?}"
1304 );
1305 }
1306}