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);
263 let byte_end = line_start_pos + (warning.end_column - 1);
264 if !in_pandoc_construct
265 && !Self::closes_earlier_emphasis(&span_ends, byte_pos, byte_end)
266 && !self.is_in_link(ctx, byte_pos)
267 && !ctx.is_in_html_comment(byte_pos)
268 && !is_in_math_context(ctx, byte_pos)
269 && !ctx.is_in_code_span(line_num, char_col)
270 && !is_in_inline_html_code(line, line_pos)
271 && !is_in_jsx_expression(ctx, byte_pos)
272 && !is_in_mdx_comment(ctx, byte_pos)
273 && !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
274 && !ctx.is_position_in_obsidian_comment(line_num, char_col)
275 {
276 let mut adjusted_warning = warning.clone();
277 adjusted_warning.column = char_col;
279 adjusted_warning.end_column = byte_to_char_count(line, warning.end_column - 1);
280 if let Some(fix) = &mut adjusted_warning.fix {
281 let abs_start = line_start_pos + fix.range.start;
283 let abs_end = line_start_pos + fix.range.end;
284 fix.range = abs_start..abs_end;
285 }
286 filtered_warnings.push(adjusted_warning);
287 }
288 }
289 }
290 }
291
292 Ok(filtered_warnings)
293 }
294
295 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
296 let content = ctx.content;
297 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
298
299 if !content.contains('*') && !content.contains('_') {
301 return Ok(content.to_string());
302 }
303
304 let warnings = self.check(ctx)?;
306 let warnings =
307 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
308
309 if warnings.is_empty() {
311 return Ok(content.to_string());
312 }
313
314 let mut result = content.to_string();
316 let mut offset: isize = 0;
317
318 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
320 sorted_warnings.sort_by_key(|w| (w.line, w.column));
321
322 for warning in sorted_warnings {
323 if let Some(fix) = &warning.fix {
324 let actual_start = (fix.range.start as isize + offset) as usize;
326 let actual_end = (fix.range.end as isize + offset) as usize;
327
328 if actual_start < result.len() && actual_end <= result.len() {
330 result.replace_range(actual_start..actual_end, &fix.replacement);
332 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
334 }
335 }
336 }
337
338 Ok(result)
339 }
340
341 fn category(&self) -> RuleCategory {
343 RuleCategory::Emphasis
344 }
345
346 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
348 ctx.content.is_empty() || !ctx.likely_has_emphasis()
349 }
350
351 fn as_any(&self) -> &dyn std::any::Any {
352 self
353 }
354
355 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
356 where
357 Self: Sized,
358 {
359 Box::new(MD037NoSpaceInEmphasis)
360 }
361}
362
363impl MD037NoSpaceInEmphasis {
364 #[inline]
366 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
367 if has_doc_patterns(line) {
369 return;
370 }
371
372 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
377 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
378 {
379 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
380 && let Some(full_match) = caps.get(0)
381 {
382 let list_marker_end = full_match.end();
383 if list_marker_end < line.len() {
384 let remaining_content = &line[list_marker_end..];
385
386 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
389 }
390 }
391 return;
392 }
393
394 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
396 }
397
398 fn check_line_content_for_emphasis_fast(
400 &self,
401 content: &str,
402 line_num: usize,
403 offset: usize,
404 warnings: &mut Vec<LintWarning>,
405 ) {
406 let processed_content = replace_inline_code(content);
409 let processed_content = replace_inline_math(&processed_content);
410
411 let markers = find_emphasis_markers(&processed_content);
413 if markers.is_empty() {
414 return;
415 }
416
417 let spans = find_emphasis_spans(&processed_content, &markers);
419
420 let valid_ranges = find_valid_emphasis_ranges(&processed_content, &markers);
425
426 for span in spans {
428 if has_spacing_issues(&span) {
429 let full_start = span.opening.start_pos;
430 let full_end = span.closing.end_pos();
431
432 if valid_ranges
434 .iter()
435 .any(|&(start, end)| start <= full_start && full_end <= end)
436 {
437 continue;
438 }
439
440 let full_text = &content[full_start..full_end];
441
442 if full_end < content.len() {
445 let remaining = &content[full_end..];
446 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
448 continue;
449 }
450 }
451
452 let marker_char = span.opening.as_char();
454 let marker_str = if span.opening.count == 1 {
455 marker_char.to_string()
456 } else {
457 format!("{marker_char}{marker_char}")
458 };
459
460 let original_content = &content[span.opening.end_pos()..span.closing.start_pos];
467 let trimmed_content = original_content.trim();
468 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
469
470 let display_text = truncate_for_display(full_text, 60);
472
473 let warning = LintWarning {
474 rule_name: Some(self.name().to_string()),
475 message: format!("Spaces inside emphasis markers: {display_text:?}"),
476 line: line_num,
480 column: offset + full_start + 1,
481 end_line: line_num,
482 end_column: offset + full_end + 1,
483 severity: Severity::Warning,
484 fix: Some(Fix::new((offset + full_start)..(offset + full_end), fixed_text)),
485 };
486
487 warnings.push(warning);
488 }
489 }
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use crate::lint_context::LintContext;
497
498 #[test]
499 fn table_cell_ranges_unmasked_scan_matches_masked_scan() {
500 fn always_masked(line: &str) -> Vec<Range<usize>> {
503 let escaped = TableUtils::mask_pipes_for_table_parsing(line);
504 let masked = TableUtils::mask_pipes_in_inline_code(&escaped);
505 let mut ranges = Vec::new();
506 let mut start = 0;
507 for (pipe_pos, _) in masked.match_indices('|') {
508 ranges.push(start..pipe_pos);
509 start = pipe_pos + 1;
510 }
511 ranges.push(start..line.len());
512 ranges
513 }
514
515 for line in [
516 "| a | b |",
517 "| a * x * | b |",
518 "a | b",
519 "no pipes at all",
520 "",
521 "|||",
522 "| naïve ünïcode | 日本語 |",
523 r"| a \| b | c |",
524 "| `a | b` | c |",
525 r"| `a \| b` | c |",
526 r"| a \\ | b |",
527 "> | a * x * | b |",
528 ] {
529 assert_eq!(table_cell_ranges(line), always_masked(line), "line: {line:?}");
530 }
531 }
532
533 #[test]
534 fn test_emphasis_marker_parsing() {
535 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
536 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
539 assert_eq!(markers.len(), 4); }
541
542 #[test]
543 fn test_emphasis_span_detection() {
544 let markers = find_emphasis_markers("This has *valid* emphasis");
545 let spans = find_emphasis_spans("This has *valid* emphasis", &markers);
546 assert_eq!(spans.len(), 1);
547 assert_eq!(spans[0].content, "valid");
548 assert!(!spans[0].has_leading_space);
549 assert!(!spans[0].has_trailing_space);
550
551 let markers = find_emphasis_markers("This has * invalid * emphasis");
552 let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
553 assert_eq!(spans.len(), 1);
554 assert_eq!(spans[0].content, " invalid ");
555 assert!(spans[0].has_leading_space);
556 assert!(spans[0].has_trailing_space);
557 }
558
559 #[test]
560 fn test_with_document_structure() {
561 let rule = MD037NoSpaceInEmphasis;
562
563 let content = "This is *correct* emphasis and **strong emphasis**";
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566 let result = rule.check(&ctx).unwrap();
567 assert!(result.is_empty(), "No warnings expected for correct emphasis");
568
569 let content = "This is * text with spaces * and more content";
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 let result = rule.check(&ctx).unwrap();
573 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
574
575 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.check(&ctx).unwrap();
579 assert!(
580 !result.is_empty(),
581 "Expected warnings for spaces in emphasis outside code block"
582 );
583 }
584
585 #[test]
586 fn test_inline_code_inside_spaced_emphasis_preserved_on_fix() {
587 let rule = MD037NoSpaceInEmphasis;
591 let content = "Set * the `id` field * below.";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let fixed = rule.fix(&ctx).unwrap();
594 assert_eq!(fixed, "Set *the `id` field* below.");
595 assert!(!fixed.contains('X'), "masking placeholder leaked into fix: {fixed}");
596 }
597
598 #[test]
599 fn test_emphasis_in_links_not_flagged() {
600 let rule = MD037NoSpaceInEmphasis;
601 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
602
603This has * real spaced emphasis * that should be flagged."#;
604 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let result = rule.check(&ctx).unwrap();
606
607 assert_eq!(
611 result.len(),
612 1,
613 "Expected exactly 1 warning, but got: {:?}",
614 result.len()
615 );
616 assert!(result[0].message.contains("Spaces inside emphasis markers"));
617 assert!(result[0].line == 3); }
620
621 #[test]
622 fn test_emphasis_in_links_vs_outside_links() {
623 let rule = MD037NoSpaceInEmphasis;
624 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
625
626[* link *]: https://example.com/*path*"#;
627 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629
630 assert_eq!(result.len(), 1);
632 assert!(result[0].message.contains("Spaces inside emphasis markers"));
633 assert!(result[0].line == 1);
635 }
636
637 #[test]
638 fn test_issue_49_asterisk_in_inline_code() {
639 let rule = MD037NoSpaceInEmphasis;
641
642 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let result = rule.check(&ctx).unwrap();
646 assert!(
647 result.is_empty(),
648 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
649 );
650 }
651
652 #[test]
653 fn test_issue_28_inline_code_in_emphasis() {
654 let rule = MD037NoSpaceInEmphasis;
656
657 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.";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661 assert!(
662 result.is_empty(),
663 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
664 );
665
666 let content2 = "The **`foo` and `bar`** methods are important.";
668 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
669 let result2 = rule.check(&ctx2).unwrap();
670 assert!(
671 result2.is_empty(),
672 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
673 );
674
675 let content3 = "This is __inline `code`__ with underscores.";
677 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
678 let result3 = rule.check(&ctx3).unwrap();
679 assert!(
680 result3.is_empty(),
681 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
682 );
683
684 let content4 = "This is *inline `test`* with single asterisks.";
686 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
687 let result4 = rule.check(&ctx4).unwrap();
688 assert!(
689 result4.is_empty(),
690 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
691 );
692
693 let content5 = "This has * real spaces * that should be flagged.";
695 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
696 let result5 = rule.check(&ctx5).unwrap();
697 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
698 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
699 }
700
701 #[test]
702 fn test_multibyte_utf8_no_panic() {
703 let rule = MD037NoSpaceInEmphasis;
707
708 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
710 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
711 let result = rule.check(&ctx);
712 assert!(result.is_ok(), "Greek text should not panic");
713
714 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
716 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.check(&ctx);
718 assert!(result.is_ok(), "Chinese text should not panic");
719
720 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
722 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx);
724 assert!(result.is_ok(), "Cyrillic text should not panic");
725
726 let mixed =
728 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
729 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
730 let result = rule.check(&ctx);
731 assert!(result.is_ok(), "Mixed CJK text should not panic");
732
733 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
735 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
736 let result = rule.check(&ctx);
737 assert!(result.is_ok(), "Arabic text should not panic");
738
739 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
741 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
742 let result = rule.check(&ctx);
743 assert!(result.is_ok(), "Emoji text should not panic");
744 }
745
746 #[test]
747 fn test_template_shortcode_syntax_not_flagged() {
748 let rule = MD037NoSpaceInEmphasis;
751
752 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let result = rule.check(&ctx).unwrap();
756 assert!(
757 result.is_empty(),
758 "Template shortcode syntax should not be flagged. Got: {result:?}"
759 );
760
761 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764 let result = rule.check(&ctx).unwrap();
765 assert!(
766 result.is_empty(),
767 "Template shortcode syntax should not be flagged. Got: {result:?}"
768 );
769
770 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774 assert!(
775 result.is_empty(),
776 "Multiple template shortcodes should not be flagged. Got: {result:?}"
777 );
778
779 let content = "This has * real spaced emphasis * here.";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let result = rule.check(&ctx).unwrap();
783 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
784 }
785
786 #[test]
787 fn test_multiline_code_span_not_flagged() {
788 let rule = MD037NoSpaceInEmphasis;
791
792 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";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let result = rule.check(&ctx).unwrap();
796 assert!(
797 result.is_empty(),
798 "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
799 );
800
801 let content2 = "Text with `code that\nspans * multiple * lines` here.";
803 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
804 let result2 = rule.check(&ctx2).unwrap();
805 assert!(
806 result2.is_empty(),
807 "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
808 );
809 }
810
811 #[test]
812 fn test_html_block_asterisks_not_flagged() {
813 let rule = MD037NoSpaceInEmphasis;
814
815 let content = r#"<table>
817<tr><td>Format</td><td>Size</td></tr>
818<tr><td>BC1</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 8</code></td></tr>
819<tr><td>BC2</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 16</code></td></tr>
820</table>"#;
821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822 let result = rule.check(&ctx).unwrap();
823 assert!(
824 result.is_empty(),
825 "Should not flag asterisks inside HTML blocks. Got: {result:?}"
826 );
827
828 let content2 = "<div>\n<p>Value is * something * here</p>\n</div>";
830 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
831 let result2 = rule.check(&ctx2).unwrap();
832 assert!(
833 result2.is_empty(),
834 "Should not flag emphasis-like patterns inside HTML div blocks. Got: {result2:?}"
835 );
836
837 let content3 = "Regular * spaced emphasis * text\n\n<div>* not emphasis *</div>";
839 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
840 let result3 = rule.check(&ctx3).unwrap();
841 assert_eq!(
842 result3.len(),
843 1,
844 "Should flag spaced emphasis in regular markdown but not inside HTML blocks. Got: {result3:?}"
845 );
846 assert_eq!(result3[0].line, 1, "Warning should be on line 1 (regular markdown)");
847 }
848
849 #[test]
850 fn test_mkdocs_icon_shortcode_not_flagged() {
851 let rule = MD037NoSpaceInEmphasis;
853
854 let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
858 let result = rule.check(&ctx).unwrap();
859 assert!(
860 result.is_empty(),
861 "Should not flag MkDocs icon shortcodes. Got: {result:?}"
862 );
863
864 let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
866 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
867 let result2 = rule.check(&ctx2).unwrap();
868 assert!(
869 !result2.is_empty(),
870 "Should still flag real spaced emphasis in MkDocs mode"
871 );
872 }
873
874 #[test]
875 fn test_mkdocs_pymdown_markup_not_flagged() {
876 let rule = MD037NoSpaceInEmphasis;
878
879 let content = "Press ++ctrl+c++ to copy.";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
882 let result = rule.check(&ctx).unwrap();
883 assert!(
884 result.is_empty(),
885 "Should not flag PyMdown Keys notation. Got: {result:?}"
886 );
887
888 let content2 = "This is ==highlighted text== for emphasis.";
890 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
891 let result2 = rule.check(&ctx2).unwrap();
892 assert!(
893 result2.is_empty(),
894 "Should not flag PyMdown Mark notation. Got: {result2:?}"
895 );
896
897 let content3 = "This is ^^inserted text^^ here.";
899 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
900 let result3 = rule.check(&ctx3).unwrap();
901 assert!(
902 result3.is_empty(),
903 "Should not flag PyMdown Insert notation. Got: {result3:?}"
904 );
905
906 let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
908 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
909 let result4 = rule.check(&ctx4).unwrap();
910 assert!(
911 !result4.is_empty(),
912 "Should still flag real spaced emphasis alongside PyMdown markup"
913 );
914 }
915
916 #[test]
919 fn test_obsidian_highlight_not_flagged() {
920 let rule = MD037NoSpaceInEmphasis;
922
923 let content = "This is ==highlighted text== here.";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
926 let result = rule.check(&ctx).unwrap();
927 assert!(
928 result.is_empty(),
929 "Should not flag Obsidian highlight syntax. Got: {result:?}"
930 );
931 }
932
933 #[test]
934 fn test_obsidian_highlight_multiple_on_line() {
935 let rule = MD037NoSpaceInEmphasis;
937
938 let content = "Both ==one== and ==two== are highlighted.";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
940 let result = rule.check(&ctx).unwrap();
941 assert!(
942 result.is_empty(),
943 "Should not flag multiple Obsidian highlights. Got: {result:?}"
944 );
945 }
946
947 #[test]
948 fn test_obsidian_highlight_entire_paragraph() {
949 let rule = MD037NoSpaceInEmphasis;
951
952 let content = "==Entire paragraph highlighted==";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
954 let result = rule.check(&ctx).unwrap();
955 assert!(
956 result.is_empty(),
957 "Should not flag entire highlighted paragraph. Got: {result:?}"
958 );
959 }
960
961 #[test]
962 fn test_obsidian_highlight_with_emphasis() {
963 let rule = MD037NoSpaceInEmphasis;
965
966 let content = "**==bold highlight==**";
968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
969 let result = rule.check(&ctx).unwrap();
970 assert!(
971 result.is_empty(),
972 "Should not flag bold highlight combination. Got: {result:?}"
973 );
974
975 let content2 = "*==italic highlight==*";
977 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Obsidian, None);
978 let result2 = rule.check(&ctx2).unwrap();
979 assert!(
980 result2.is_empty(),
981 "Should not flag italic highlight combination. Got: {result2:?}"
982 );
983 }
984
985 #[test]
986 fn test_obsidian_highlight_in_lists() {
987 let rule = MD037NoSpaceInEmphasis;
989
990 let content = "- Item with ==highlight== text\n- Another ==highlighted== item";
991 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
992 let result = rule.check(&ctx).unwrap();
993 assert!(
994 result.is_empty(),
995 "Should not flag highlights in list items. Got: {result:?}"
996 );
997 }
998
999 #[test]
1000 fn test_obsidian_highlight_in_blockquote() {
1001 let rule = MD037NoSpaceInEmphasis;
1003
1004 let content = "> This quote has ==highlighted== text.";
1005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1006 let result = rule.check(&ctx).unwrap();
1007 assert!(
1008 result.is_empty(),
1009 "Should not flag highlights in blockquotes. Got: {result:?}"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_obsidian_highlight_in_tables() {
1015 let rule = MD037NoSpaceInEmphasis;
1017
1018 let content = "| Header | Column |\n|--------|--------|\n| ==highlighted== | text |";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1020 let result = rule.check(&ctx).unwrap();
1021 assert!(
1022 result.is_empty(),
1023 "Should not flag highlights in tables. Got: {result:?}"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_obsidian_highlight_in_code_blocks_ignored() {
1029 let rule = MD037NoSpaceInEmphasis;
1031
1032 let content = "```\n==not highlight in code==\n```";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1034 let result = rule.check(&ctx).unwrap();
1035 assert!(
1036 result.is_empty(),
1037 "Should ignore highlights in code blocks. Got: {result:?}"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_obsidian_highlight_edge_case_three_equals() {
1043 let rule = MD037NoSpaceInEmphasis;
1045
1046 let content = "Test === something === here";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1049 let result = rule.check(&ctx).unwrap();
1050 let _ = result;
1053 }
1054
1055 #[test]
1056 fn test_obsidian_highlight_edge_case_four_equals() {
1057 let rule = MD037NoSpaceInEmphasis;
1059
1060 let content = "Test ==== here";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1062 let result = rule.check(&ctx).unwrap();
1063 let _ = result;
1065 }
1066
1067 #[test]
1068 fn test_obsidian_highlight_adjacent() {
1069 let rule = MD037NoSpaceInEmphasis;
1071
1072 let content = "==one====two==";
1073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1074 let result = rule.check(&ctx).unwrap();
1075 let _ = result;
1077 }
1078
1079 #[test]
1080 fn test_obsidian_highlight_with_special_chars() {
1081 let rule = MD037NoSpaceInEmphasis;
1083
1084 let content = "Test ==code: `test`== here";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1087 let result = rule.check(&ctx).unwrap();
1088 let _ = result;
1090 }
1091
1092 #[test]
1093 fn test_obsidian_highlight_unclosed() {
1094 let rule = MD037NoSpaceInEmphasis;
1096
1097 let content = "This ==starts but never ends";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1099 let result = rule.check(&ctx).unwrap();
1100 let _ = result;
1102 }
1103
1104 #[test]
1105 fn test_obsidian_highlight_still_flags_real_emphasis_issues() {
1106 let rule = MD037NoSpaceInEmphasis;
1108
1109 let content = "This has * spaced emphasis * and ==valid highlight==";
1110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1111 let result = rule.check(&ctx).unwrap();
1112 assert!(
1113 !result.is_empty(),
1114 "Should still flag real spaced emphasis in Obsidian mode"
1115 );
1116 assert!(
1117 result.len() == 1,
1118 "Should flag exactly one issue (the spaced emphasis). Got: {result:?}"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_standard_flavor_does_not_recognize_highlight() {
1124 let rule = MD037NoSpaceInEmphasis;
1127
1128 let content = "This is ==highlighted text== here.";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let result = rule.check(&ctx).unwrap();
1131 let _ = result; }
1136
1137 #[test]
1138 fn test_obsidian_highlight_mixed_with_regular_emphasis() {
1139 let rule = MD037NoSpaceInEmphasis;
1141
1142 let content = "==highlighted== and *italic* and **bold** text";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1144 let result = rule.check(&ctx).unwrap();
1145 assert!(
1146 result.is_empty(),
1147 "Should not flag valid highlight and emphasis. Got: {result:?}"
1148 );
1149 }
1150
1151 #[test]
1152 fn test_obsidian_highlight_unicode() {
1153 let rule = MD037NoSpaceInEmphasis;
1155
1156 let content = "Text ==日本語 highlighted== here";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1158 let result = rule.check(&ctx).unwrap();
1159 assert!(
1160 result.is_empty(),
1161 "Should handle Unicode in highlights. Got: {result:?}"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_obsidian_highlight_with_html() {
1167 let rule = MD037NoSpaceInEmphasis;
1169
1170 let content = "<!-- ==not highlight in comment== --> ==actual highlight==";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1172 let result = rule.check(&ctx).unwrap();
1173 let _ = result;
1175 }
1176
1177 #[test]
1178 fn test_obsidian_inline_comment_emphasis_ignored() {
1179 let rule = MD037NoSpaceInEmphasis;
1181
1182 let content = "Visible %%* spaced emphasis *%% still visible.";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1184 let result = rule.check(&ctx).unwrap();
1185
1186 assert!(
1187 result.is_empty(),
1188 "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_inline_html_code_not_flagged() {
1194 let rule = MD037NoSpaceInEmphasis;
1195
1196 let content = "The formula is <code>a * b * c</code> in math.";
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1199 let result = rule.check(&ctx).unwrap();
1200 assert!(
1201 result.is_empty(),
1202 "Should not flag asterisks inside inline <code> tags. Got: {result:?}"
1203 );
1204
1205 let content2 = "Use <kbd>Ctrl * A</kbd> and <samp>x * y</samp> here.";
1207 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1208 let result2 = rule.check(&ctx2).unwrap();
1209 assert!(
1210 result2.is_empty(),
1211 "Should not flag asterisks inside inline <kbd> and <samp> tags. Got: {result2:?}"
1212 );
1213
1214 let content3 = r#"Result: <code class="math">a * b</code> done."#;
1216 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1217 let result3 = rule.check(&ctx3).unwrap();
1218 assert!(
1219 result3.is_empty(),
1220 "Should not flag asterisks inside <code> with attributes. Got: {result3:?}"
1221 );
1222
1223 let content4 = "Text * spaced * and <code>a * b</code>.";
1225 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1226 let result4 = rule.check(&ctx4).unwrap();
1227 assert_eq!(
1228 result4.len(),
1229 1,
1230 "Should flag real spaced emphasis but not code content. Got: {result4:?}"
1231 );
1232 assert_eq!(result4[0].column, 6);
1233 }
1234
1235 #[test]
1238 fn test_pandoc_bracketed_span_guard() {
1239 use crate::config::MarkdownFlavor;
1240 let rule = MD037NoSpaceInEmphasis;
1241 let content = "See [* important *]{.highlight} for details.\n";
1243 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1244 let result = rule.check(&ctx).unwrap();
1245 assert!(
1246 result.is_empty(),
1247 "MD037 should not flag emphasis-like patterns inside Pandoc bracketed spans: {result:?}"
1248 );
1249
1250 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1252 let result_std = rule.check(&ctx_std).unwrap();
1253 assert!(
1254 !result_std.is_empty(),
1255 "MD037 should still flag spaces in emphasis under Standard flavor: {result_std:?}"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_spaced_bold_metadata_pattern_detected() {
1261 let rule = MD037NoSpaceInEmphasis;
1262
1263 let content = "# Test\n\n** Explicit Import**: Convert markdownlint configs to rumdl format:";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let result = rule.check(&ctx).unwrap();
1267 assert_eq!(
1268 result.len(),
1269 1,
1270 "Should flag '** Explicit Import**' as spaced emphasis. Got: {result:?}"
1271 );
1272 assert_eq!(result[0].line, 3);
1273
1274 let content2 = "# Test\n\n**trailing only **: some text";
1276 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1277 let result2 = rule.check(&ctx2).unwrap();
1278 assert_eq!(
1279 result2.len(),
1280 1,
1281 "Should flag '**trailing only **' as spaced emphasis. Got: {result2:?}"
1282 );
1283
1284 let content3 = "# Test\n\n** both spaces **: some text";
1286 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1287 let result3 = rule.check(&ctx3).unwrap();
1288 assert_eq!(
1289 result3.len(),
1290 1,
1291 "Should flag '** both spaces **' as spaced emphasis. Got: {result3:?}"
1292 );
1293
1294 let content4 = "# Test\n\n**Key**: value";
1296 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1297 let result4 = rule.check(&ctx4).unwrap();
1298 assert!(
1299 result4.is_empty(),
1300 "Should not flag valid bold metadata '**Key**: value'. Got: {result4:?}"
1301 );
1302 }
1303}