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 line_index = &ctx.line_index;
184
185 let mut warnings = Vec::new();
186 let table_lines = table_line_flags(ctx);
187
188 for line in ctx
192 .filtered_lines()
193 .skip_front_matter()
194 .skip_code_blocks()
195 .skip_math_blocks()
196 .skip_html_blocks()
197 .skip_jsx_expressions()
198 .skip_mdx_comments()
199 .skip_obsidian_comments()
200 .skip_mkdocstrings()
201 {
202 if !line.content.contains('*') && !line.content.contains('_') {
204 continue;
205 }
206
207 if table_lines.get(line.line_num - 1).copied().unwrap_or(false) {
208 for cell in table_cell_ranges(line.content) {
213 let Some(cell_content) = line.content.get(cell.clone()) else {
214 continue;
215 };
216 if !cell_content.contains('*') && !cell_content.contains('_') {
217 continue;
218 }
219 if has_doc_patterns(cell_content) {
220 continue;
221 }
222 self.check_line_content_for_emphasis_fast(cell_content, line.line_num, cell.start, &mut warnings);
223 }
224 continue;
225 }
226
227 self.check_line_for_emphasis_issues_fast(line.content, line.line_num, &mut warnings);
229 }
230
231 let mut filtered_warnings = Vec::new();
233 let lines = ctx.raw_lines();
234 let span_ends = if warnings.is_empty() {
235 Vec::new()
236 } else {
237 Self::emphasis_span_ends(ctx)
238 };
239
240 for (line_idx, line) in lines.iter().enumerate() {
241 let line_num = line_idx + 1;
242 let line_start_pos = line_index.get_line_start_byte(line_num).unwrap_or(0);
243
244 for warning in &warnings {
246 if warning.line == line_num {
247 let byte_pos = line_start_pos + (warning.column - 1);
251 let line_pos = warning.column - 1;
253 let char_col = byte_to_char_count(line, warning.column - 1);
254
255 let in_pandoc_construct = ctx.flavor.is_pandoc_compatible() && ctx.is_in_bracketed_span(byte_pos);
266 let byte_end = line_start_pos + (warning.end_column - 1);
267 if !in_pandoc_construct
268 && !Self::closes_earlier_emphasis(&span_ends, byte_pos, byte_end)
269 && !self.is_in_link(ctx, byte_pos)
270 && !ctx.is_in_html_comment(byte_pos)
271 && !ctx.is_in_shortcode(byte_pos)
272 && !is_in_math_context(ctx, byte_pos)
273 && !ctx.is_in_code_span(line_num, char_col)
274 && !is_in_inline_html_code(line, line_pos)
275 && !is_in_jsx_expression(ctx, byte_pos)
276 && !is_in_mdx_comment(ctx, byte_pos)
277 && !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
278 && !ctx.is_position_in_obsidian_comment(line_num, char_col)
279 {
280 let mut adjusted_warning = warning.clone();
281 adjusted_warning.column = char_col;
283 adjusted_warning.end_column = byte_to_char_count(line, warning.end_column - 1);
284 if let Some(fix) = &mut adjusted_warning.fix {
285 let abs_start = line_start_pos + fix.range.start;
287 let abs_end = line_start_pos + fix.range.end;
288 fix.range = abs_start..abs_end;
289 }
290 filtered_warnings.push(adjusted_warning);
291 }
292 }
293 }
294 }
295
296 Ok(filtered_warnings)
297 }
298
299 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
300 let content = ctx.content;
301 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
302
303 if !content.contains('*') && !content.contains('_') {
305 return Ok(content.to_string());
306 }
307
308 let warnings = self.check(ctx)?;
310 let warnings =
311 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
312
313 if warnings.is_empty() {
315 return Ok(content.to_string());
316 }
317
318 let mut result = content.to_string();
320 let mut offset: isize = 0;
321
322 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
324 sorted_warnings.sort_by_key(|w| (w.line, w.column));
325
326 for warning in sorted_warnings {
327 if let Some(fix) = &warning.fix {
328 let actual_start = (fix.range.start as isize + offset) as usize;
330 let actual_end = (fix.range.end as isize + offset) as usize;
331
332 if actual_start < result.len() && actual_end <= result.len() {
334 result.replace_range(actual_start..actual_end, &fix.replacement);
336 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
338 }
339 }
340 }
341
342 Ok(result)
343 }
344
345 fn category(&self) -> RuleCategory {
347 RuleCategory::Emphasis
348 }
349
350 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
352 ctx.content.is_empty() || !ctx.likely_has_emphasis()
353 }
354
355 fn as_any(&self) -> &dyn std::any::Any {
356 self
357 }
358
359 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
360 where
361 Self: Sized,
362 {
363 Box::new(MD037NoSpaceInEmphasis)
364 }
365}
366
367impl MD037NoSpaceInEmphasis {
368 #[inline]
370 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
371 if has_doc_patterns(line) {
373 return;
374 }
375
376 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
381 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
382 {
383 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
384 && let Some(full_match) = caps.get(0)
385 {
386 let list_marker_end = full_match.end();
387 if list_marker_end < line.len() {
388 let remaining_content = &line[list_marker_end..];
389
390 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
393 }
394 }
395 return;
396 }
397
398 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
400 }
401
402 fn check_line_content_for_emphasis_fast(
404 &self,
405 content: &str,
406 line_num: usize,
407 offset: usize,
408 warnings: &mut Vec<LintWarning>,
409 ) {
410 let processed_content = replace_inline_code(content);
413 let processed_content = replace_inline_math(&processed_content);
414
415 let markers = find_emphasis_markers(&processed_content);
417 if markers.is_empty() {
418 return;
419 }
420
421 let spans = find_emphasis_spans(&processed_content, &markers);
423
424 let valid_ranges = find_valid_emphasis_ranges(&processed_content, &markers);
429
430 for span in spans {
432 if has_spacing_issues(&span) {
433 let full_start = span.opening.start_pos;
434 let full_end = span.closing.end_pos();
435
436 if valid_ranges
438 .iter()
439 .any(|&(start, end)| start <= full_start && full_end <= end)
440 {
441 continue;
442 }
443
444 let full_text = &content[full_start..full_end];
445
446 if full_end < content.len() {
449 let remaining = &content[full_end..];
450 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
452 continue;
453 }
454 }
455
456 let marker_char = span.opening.as_char();
458 let marker_str = if span.opening.count == 1 {
459 marker_char.to_string()
460 } else {
461 format!("{marker_char}{marker_char}")
462 };
463
464 let original_content = &content[span.opening.end_pos()..span.closing.start_pos];
471 let trimmed_content = original_content.trim();
472 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
473
474 let display_text = truncate_for_display(full_text, 60);
476
477 let warning = LintWarning {
478 rule_name: Some(self.name().to_string()),
479 message: format!("Spaces inside emphasis markers: {display_text:?}"),
480 line: line_num,
484 column: offset + full_start + 1,
485 end_line: line_num,
486 end_column: offset + full_end + 1,
487 severity: Severity::Warning,
488 fix: Some(Fix::new((offset + full_start)..(offset + full_end), fixed_text)),
489 };
490
491 warnings.push(warning);
492 }
493 }
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use crate::lint_context::LintContext;
501
502 #[test]
503 fn table_cell_ranges_unmasked_scan_matches_masked_scan() {
504 fn always_masked(line: &str) -> Vec<Range<usize>> {
507 let escaped = TableUtils::mask_pipes_for_table_parsing(line);
508 let masked = TableUtils::mask_pipes_in_inline_code(&escaped);
509 let mut ranges = Vec::new();
510 let mut start = 0;
511 for (pipe_pos, _) in masked.match_indices('|') {
512 ranges.push(start..pipe_pos);
513 start = pipe_pos + 1;
514 }
515 ranges.push(start..line.len());
516 ranges
517 }
518
519 for line in [
520 "| a | b |",
521 "| a * x * | b |",
522 "a | b",
523 "no pipes at all",
524 "",
525 "|||",
526 "| naïve ünïcode | 日本語 |",
527 r"| a \| b | c |",
528 "| `a | b` | c |",
529 r"| `a \| b` | c |",
530 r"| a \\ | b |",
531 "> | a * x * | b |",
532 ] {
533 assert_eq!(table_cell_ranges(line), always_masked(line), "line: {line:?}");
534 }
535 }
536
537 #[test]
538 fn test_emphasis_marker_parsing() {
539 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
540 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
543 assert_eq!(markers.len(), 4); }
545
546 #[test]
547 fn test_emphasis_span_detection() {
548 let markers = find_emphasis_markers("This has *valid* emphasis");
549 let spans = find_emphasis_spans("This has *valid* emphasis", &markers);
550 assert_eq!(spans.len(), 1);
551 assert_eq!(spans[0].content, "valid");
552 assert!(!spans[0].has_leading_space);
553 assert!(!spans[0].has_trailing_space);
554
555 let markers = find_emphasis_markers("This has * invalid * emphasis");
556 let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
557 assert_eq!(spans.len(), 1);
558 assert_eq!(spans[0].content, " invalid ");
559 assert!(spans[0].has_leading_space);
560 assert!(spans[0].has_trailing_space);
561 }
562
563 #[test]
564 fn test_with_document_structure() {
565 let rule = MD037NoSpaceInEmphasis;
566
567 let content = "This is *correct* emphasis and **strong emphasis**";
569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570 let result = rule.check(&ctx).unwrap();
571 assert!(result.is_empty(), "No warnings expected for correct emphasis");
572
573 let content = "This is * text with spaces * and more content";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576 let result = rule.check(&ctx).unwrap();
577 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
578
579 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert!(
584 !result.is_empty(),
585 "Expected warnings for spaces in emphasis outside code block"
586 );
587 }
588
589 #[test]
590 fn test_inline_code_inside_spaced_emphasis_preserved_on_fix() {
591 let rule = MD037NoSpaceInEmphasis;
595 let content = "Set * the `id` field * below.";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597 let fixed = rule.fix(&ctx).unwrap();
598 assert_eq!(fixed, "Set *the `id` field* below.");
599 assert!(!fixed.contains('X'), "masking placeholder leaked into fix: {fixed}");
600 }
601
602 #[test]
603 fn test_emphasis_in_links_not_flagged() {
604 let rule = MD037NoSpaceInEmphasis;
605 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
606
607This has * real spaced emphasis * that should be flagged."#;
608 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609 let result = rule.check(&ctx).unwrap();
610
611 assert_eq!(
615 result.len(),
616 1,
617 "Expected exactly 1 warning, but got: {:?}",
618 result.len()
619 );
620 assert!(result[0].message.contains("Spaces inside emphasis markers"));
621 assert!(result[0].line == 3); }
624
625 #[test]
626 fn test_emphasis_in_links_vs_outside_links() {
627 let rule = MD037NoSpaceInEmphasis;
628 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
629
630[* link *]: https://example.com/*path*"#;
631 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let result = rule.check(&ctx).unwrap();
633
634 assert_eq!(result.len(), 1);
636 assert!(result[0].message.contains("Spaces inside emphasis markers"));
637 assert!(result[0].line == 1);
639 }
640
641 #[test]
642 fn test_issue_49_asterisk_in_inline_code() {
643 let rule = MD037NoSpaceInEmphasis;
645
646 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649 let result = rule.check(&ctx).unwrap();
650 assert!(
651 result.is_empty(),
652 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
653 );
654 }
655
656 #[test]
657 fn test_issue_28_inline_code_in_emphasis() {
658 let rule = MD037NoSpaceInEmphasis;
660
661 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.";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert!(
666 result.is_empty(),
667 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
668 );
669
670 let content2 = "The **`foo` and `bar`** methods are important.";
672 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
673 let result2 = rule.check(&ctx2).unwrap();
674 assert!(
675 result2.is_empty(),
676 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
677 );
678
679 let content3 = "This is __inline `code`__ with underscores.";
681 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
682 let result3 = rule.check(&ctx3).unwrap();
683 assert!(
684 result3.is_empty(),
685 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
686 );
687
688 let content4 = "This is *inline `test`* with single asterisks.";
690 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
691 let result4 = rule.check(&ctx4).unwrap();
692 assert!(
693 result4.is_empty(),
694 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
695 );
696
697 let content5 = "This has * real spaces * that should be flagged.";
699 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
700 let result5 = rule.check(&ctx5).unwrap();
701 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
702 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
703 }
704
705 #[test]
706 fn test_multibyte_utf8_no_panic() {
707 let rule = MD037NoSpaceInEmphasis;
711
712 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
714 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
715 let result = rule.check(&ctx);
716 assert!(result.is_ok(), "Greek text should not panic");
717
718 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
720 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
721 let result = rule.check(&ctx);
722 assert!(result.is_ok(), "Chinese text should not panic");
723
724 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
726 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
727 let result = rule.check(&ctx);
728 assert!(result.is_ok(), "Cyrillic text should not panic");
729
730 let mixed =
732 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
733 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
734 let result = rule.check(&ctx);
735 assert!(result.is_ok(), "Mixed CJK text should not panic");
736
737 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
739 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx);
741 assert!(result.is_ok(), "Arabic text should not panic");
742
743 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
745 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx);
747 assert!(result.is_ok(), "Emoji text should not panic");
748 }
749
750 #[test]
751 fn test_template_shortcode_syntax_not_flagged() {
752 let rule = MD037NoSpaceInEmphasis;
755
756 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760 assert!(
761 result.is_empty(),
762 "Template shortcode syntax should not be flagged. Got: {result:?}"
763 );
764
765 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769 assert!(
770 result.is_empty(),
771 "Template shortcode syntax should not be flagged. Got: {result:?}"
772 );
773
774 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778 assert!(
779 result.is_empty(),
780 "Multiple template shortcodes should not be flagged. Got: {result:?}"
781 );
782
783 let content = "This has * real spaced emphasis * here.";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
788 }
789
790 #[test]
791 fn test_multiline_code_span_not_flagged() {
792 let rule = MD037NoSpaceInEmphasis;
795
796 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";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let result = rule.check(&ctx).unwrap();
800 assert!(
801 result.is_empty(),
802 "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
803 );
804
805 let content2 = "Text with `code that\nspans * multiple * lines` here.";
807 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
808 let result2 = rule.check(&ctx2).unwrap();
809 assert!(
810 result2.is_empty(),
811 "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
812 );
813 }
814
815 #[test]
816 fn test_html_block_asterisks_not_flagged() {
817 let rule = MD037NoSpaceInEmphasis;
818
819 let content = r#"<table>
821<tr><td>Format</td><td>Size</td></tr>
822<tr><td>BC1</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 8</code></td></tr>
823<tr><td>BC2</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 16</code></td></tr>
824</table>"#;
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let result = rule.check(&ctx).unwrap();
827 assert!(
828 result.is_empty(),
829 "Should not flag asterisks inside HTML blocks. Got: {result:?}"
830 );
831
832 let content2 = "<div>\n<p>Value is * something * here</p>\n</div>";
834 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
835 let result2 = rule.check(&ctx2).unwrap();
836 assert!(
837 result2.is_empty(),
838 "Should not flag emphasis-like patterns inside HTML div blocks. Got: {result2:?}"
839 );
840
841 let content3 = "Regular * spaced emphasis * text\n\n<div>* not emphasis *</div>";
843 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
844 let result3 = rule.check(&ctx3).unwrap();
845 assert_eq!(
846 result3.len(),
847 1,
848 "Should flag spaced emphasis in regular markdown but not inside HTML blocks. Got: {result3:?}"
849 );
850 assert_eq!(result3[0].line, 1, "Warning should be on line 1 (regular markdown)");
851 }
852
853 #[test]
854 fn test_mkdocs_icon_shortcode_not_flagged() {
855 let rule = MD037NoSpaceInEmphasis;
857
858 let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
862 let result = rule.check(&ctx).unwrap();
863 assert!(
864 result.is_empty(),
865 "Should not flag MkDocs icon shortcodes. Got: {result:?}"
866 );
867
868 let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
870 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
871 let result2 = rule.check(&ctx2).unwrap();
872 assert!(
873 !result2.is_empty(),
874 "Should still flag real spaced emphasis in MkDocs mode"
875 );
876 }
877
878 #[test]
879 fn test_mkdocs_pymdown_markup_not_flagged() {
880 let rule = MD037NoSpaceInEmphasis;
882
883 let content = "Press ++ctrl+c++ to copy.";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
886 let result = rule.check(&ctx).unwrap();
887 assert!(
888 result.is_empty(),
889 "Should not flag PyMdown Keys notation. Got: {result:?}"
890 );
891
892 let content2 = "This is ==highlighted text== for emphasis.";
894 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
895 let result2 = rule.check(&ctx2).unwrap();
896 assert!(
897 result2.is_empty(),
898 "Should not flag PyMdown Mark notation. Got: {result2:?}"
899 );
900
901 let content3 = "This is ^^inserted text^^ here.";
903 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
904 let result3 = rule.check(&ctx3).unwrap();
905 assert!(
906 result3.is_empty(),
907 "Should not flag PyMdown Insert notation. Got: {result3:?}"
908 );
909
910 let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
912 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
913 let result4 = rule.check(&ctx4).unwrap();
914 assert!(
915 !result4.is_empty(),
916 "Should still flag real spaced emphasis alongside PyMdown markup"
917 );
918 }
919
920 #[test]
923 fn test_obsidian_highlight_not_flagged() {
924 let rule = MD037NoSpaceInEmphasis;
926
927 let content = "This is ==highlighted text== here.";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
930 let result = rule.check(&ctx).unwrap();
931 assert!(
932 result.is_empty(),
933 "Should not flag Obsidian highlight syntax. Got: {result:?}"
934 );
935 }
936
937 #[test]
938 fn test_obsidian_highlight_multiple_on_line() {
939 let rule = MD037NoSpaceInEmphasis;
941
942 let content = "Both ==one== and ==two== are highlighted.";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
944 let result = rule.check(&ctx).unwrap();
945 assert!(
946 result.is_empty(),
947 "Should not flag multiple Obsidian highlights. Got: {result:?}"
948 );
949 }
950
951 #[test]
952 fn test_obsidian_highlight_entire_paragraph() {
953 let rule = MD037NoSpaceInEmphasis;
955
956 let content = "==Entire paragraph highlighted==";
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
958 let result = rule.check(&ctx).unwrap();
959 assert!(
960 result.is_empty(),
961 "Should not flag entire highlighted paragraph. Got: {result:?}"
962 );
963 }
964
965 #[test]
966 fn test_obsidian_highlight_with_emphasis() {
967 let rule = MD037NoSpaceInEmphasis;
969
970 let content = "**==bold highlight==**";
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
973 let result = rule.check(&ctx).unwrap();
974 assert!(
975 result.is_empty(),
976 "Should not flag bold highlight combination. Got: {result:?}"
977 );
978
979 let content2 = "*==italic highlight==*";
981 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Obsidian, None);
982 let result2 = rule.check(&ctx2).unwrap();
983 assert!(
984 result2.is_empty(),
985 "Should not flag italic highlight combination. Got: {result2:?}"
986 );
987 }
988
989 #[test]
990 fn test_obsidian_highlight_in_lists() {
991 let rule = MD037NoSpaceInEmphasis;
993
994 let content = "- Item with ==highlight== text\n- Another ==highlighted== item";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
996 let result = rule.check(&ctx).unwrap();
997 assert!(
998 result.is_empty(),
999 "Should not flag highlights in list items. Got: {result:?}"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_obsidian_highlight_in_blockquote() {
1005 let rule = MD037NoSpaceInEmphasis;
1007
1008 let content = "> This quote has ==highlighted== text.";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert!(
1012 result.is_empty(),
1013 "Should not flag highlights in blockquotes. Got: {result:?}"
1014 );
1015 }
1016
1017 #[test]
1018 fn test_obsidian_highlight_in_tables() {
1019 let rule = MD037NoSpaceInEmphasis;
1021
1022 let content = "| Header | Column |\n|--------|--------|\n| ==highlighted== | text |";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1024 let result = rule.check(&ctx).unwrap();
1025 assert!(
1026 result.is_empty(),
1027 "Should not flag highlights in tables. Got: {result:?}"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_obsidian_highlight_in_code_blocks_ignored() {
1033 let rule = MD037NoSpaceInEmphasis;
1035
1036 let content = "```\n==not highlight in code==\n```";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(
1040 result.is_empty(),
1041 "Should ignore highlights in code blocks. Got: {result:?}"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_obsidian_highlight_edge_case_three_equals() {
1047 let rule = MD037NoSpaceInEmphasis;
1049
1050 let content = "Test === something === here";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1053 let result = rule.check(&ctx).unwrap();
1054 let _ = result;
1057 }
1058
1059 #[test]
1060 fn test_obsidian_highlight_edge_case_four_equals() {
1061 let rule = MD037NoSpaceInEmphasis;
1063
1064 let content = "Test ==== here";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1066 let result = rule.check(&ctx).unwrap();
1067 let _ = result;
1069 }
1070
1071 #[test]
1072 fn test_obsidian_highlight_adjacent() {
1073 let rule = MD037NoSpaceInEmphasis;
1075
1076 let content = "==one====two==";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1078 let result = rule.check(&ctx).unwrap();
1079 let _ = result;
1081 }
1082
1083 #[test]
1084 fn test_obsidian_highlight_with_special_chars() {
1085 let rule = MD037NoSpaceInEmphasis;
1087
1088 let content = "Test ==code: `test`== here";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1091 let result = rule.check(&ctx).unwrap();
1092 let _ = result;
1094 }
1095
1096 #[test]
1097 fn test_obsidian_highlight_unclosed() {
1098 let rule = MD037NoSpaceInEmphasis;
1100
1101 let content = "This ==starts but never ends";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1103 let result = rule.check(&ctx).unwrap();
1104 let _ = result;
1106 }
1107
1108 #[test]
1109 fn test_obsidian_highlight_still_flags_real_emphasis_issues() {
1110 let rule = MD037NoSpaceInEmphasis;
1112
1113 let content = "This has * spaced emphasis * and ==valid highlight==";
1114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1115 let result = rule.check(&ctx).unwrap();
1116 assert!(
1117 !result.is_empty(),
1118 "Should still flag real spaced emphasis in Obsidian mode"
1119 );
1120 assert!(
1121 result.len() == 1,
1122 "Should flag exactly one issue (the spaced emphasis). Got: {result:?}"
1123 );
1124 }
1125
1126 #[test]
1127 fn test_standard_flavor_does_not_recognize_highlight() {
1128 let rule = MD037NoSpaceInEmphasis;
1131
1132 let content = "This is ==highlighted text== here.";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134 let result = rule.check(&ctx).unwrap();
1135 let _ = result; }
1140
1141 #[test]
1142 fn test_obsidian_highlight_mixed_with_regular_emphasis() {
1143 let rule = MD037NoSpaceInEmphasis;
1145
1146 let content = "==highlighted== and *italic* and **bold** text";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert!(
1150 result.is_empty(),
1151 "Should not flag valid highlight and emphasis. Got: {result:?}"
1152 );
1153 }
1154
1155 #[test]
1156 fn test_obsidian_highlight_unicode() {
1157 let rule = MD037NoSpaceInEmphasis;
1159
1160 let content = "Text ==日本語 highlighted== here";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1162 let result = rule.check(&ctx).unwrap();
1163 assert!(
1164 result.is_empty(),
1165 "Should handle Unicode in highlights. Got: {result:?}"
1166 );
1167 }
1168
1169 #[test]
1170 fn test_obsidian_highlight_with_html() {
1171 let rule = MD037NoSpaceInEmphasis;
1173
1174 let content = "<!-- ==not highlight in comment== --> ==actual highlight==";
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1176 let result = rule.check(&ctx).unwrap();
1177 let _ = result;
1179 }
1180
1181 #[test]
1182 fn test_obsidian_inline_comment_emphasis_ignored() {
1183 let rule = MD037NoSpaceInEmphasis;
1185
1186 let content = "Visible %%* spaced emphasis *%% still visible.";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1188 let result = rule.check(&ctx).unwrap();
1189
1190 assert!(
1191 result.is_empty(),
1192 "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_inline_html_code_not_flagged() {
1198 let rule = MD037NoSpaceInEmphasis;
1199
1200 let content = "The formula is <code>a * b * c</code> in math.";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203 let result = rule.check(&ctx).unwrap();
1204 assert!(
1205 result.is_empty(),
1206 "Should not flag asterisks inside inline <code> tags. Got: {result:?}"
1207 );
1208
1209 let content2 = "Use <kbd>Ctrl * A</kbd> and <samp>x * y</samp> here.";
1211 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1212 let result2 = rule.check(&ctx2).unwrap();
1213 assert!(
1214 result2.is_empty(),
1215 "Should not flag asterisks inside inline <kbd> and <samp> tags. Got: {result2:?}"
1216 );
1217
1218 let content3 = r#"Result: <code class="math">a * b</code> done."#;
1220 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1221 let result3 = rule.check(&ctx3).unwrap();
1222 assert!(
1223 result3.is_empty(),
1224 "Should not flag asterisks inside <code> with attributes. Got: {result3:?}"
1225 );
1226
1227 let content4 = "Text * spaced * and <code>a * b</code>.";
1229 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1230 let result4 = rule.check(&ctx4).unwrap();
1231 assert_eq!(
1232 result4.len(),
1233 1,
1234 "Should flag real spaced emphasis but not code content. Got: {result4:?}"
1235 );
1236 assert_eq!(result4[0].column, 6);
1237 }
1238
1239 #[test]
1242 fn test_pandoc_bracketed_span_guard() {
1243 use crate::config::MarkdownFlavor;
1244 let rule = MD037NoSpaceInEmphasis;
1245 let content = "See [* important *]{.highlight} for details.\n";
1247 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1248 let result = rule.check(&ctx).unwrap();
1249 assert!(
1250 result.is_empty(),
1251 "MD037 should not flag emphasis-like patterns inside Pandoc bracketed spans: {result:?}"
1252 );
1253
1254 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1256 let result_std = rule.check(&ctx_std).unwrap();
1257 assert!(
1258 !result_std.is_empty(),
1259 "MD037 should still flag spaces in emphasis under Standard flavor: {result_std:?}"
1260 );
1261 }
1262
1263 #[test]
1264 fn test_spaced_bold_metadata_pattern_detected() {
1265 let rule = MD037NoSpaceInEmphasis;
1266
1267 let content = "# Test\n\n** Explicit Import**: Convert markdownlint configs to rumdl format:";
1269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270 let result = rule.check(&ctx).unwrap();
1271 assert_eq!(
1272 result.len(),
1273 1,
1274 "Should flag '** Explicit Import**' as spaced emphasis. Got: {result:?}"
1275 );
1276 assert_eq!(result[0].line, 3);
1277
1278 let content2 = "# Test\n\n**trailing only **: some text";
1280 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1281 let result2 = rule.check(&ctx2).unwrap();
1282 assert_eq!(
1283 result2.len(),
1284 1,
1285 "Should flag '**trailing only **' as spaced emphasis. Got: {result2:?}"
1286 );
1287
1288 let content3 = "# Test\n\n** both spaces **: some text";
1290 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1291 let result3 = rule.check(&ctx3).unwrap();
1292 assert_eq!(
1293 result3.len(),
1294 1,
1295 "Should flag '** both spaces **' as spaced emphasis. Got: {result3:?}"
1296 );
1297
1298 let content4 = "# Test\n\n**Key**: value";
1300 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1301 let result4 = rule.check(&ctx4).unwrap();
1302 assert!(
1303 result4.is_empty(),
1304 "Should not flag valid bold metadata '**Key**: value'. Got: {result4:?}"
1305 );
1306 }
1307}