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 is_in_table_cell,
16};
17
18#[inline]
20fn has_spacing_issues(span: &EmphasisSpan) -> bool {
21 span.has_leading_space || span.has_trailing_space
22}
23
24#[inline]
27fn truncate_for_display(text: &str, max_len: usize) -> String {
28 if text.len() <= max_len {
29 return text.to_string();
30 }
31
32 let prefix_len = max_len / 2 - 2; let suffix_len = max_len / 2 - 2;
34
35 let prefix_end = text.floor_char_boundary(prefix_len.min(text.len()));
37 let suffix_start = text.floor_char_boundary(text.len().saturating_sub(suffix_len));
38
39 format!("{}...{}", &text[..prefix_end], &text[suffix_start..])
40}
41
42#[derive(Clone)]
44pub struct MD037NoSpaceInEmphasis;
45
46impl Default for MD037NoSpaceInEmphasis {
47 fn default() -> Self {
48 Self
49 }
50}
51
52impl MD037NoSpaceInEmphasis {
53 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
55 for link in &ctx.links {
57 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
58 if link.is_reference && link.url.is_empty() {
59 continue;
60 }
61 return true;
62 }
63 }
64
65 for image in &ctx.images {
67 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
68 return true;
69 }
70 }
71
72 ctx.is_in_reference_def(byte_pos)
74 }
75}
76
77impl Rule for MD037NoSpaceInEmphasis {
78 fn name(&self) -> &'static str {
79 "MD037"
80 }
81
82 fn description(&self) -> &'static str {
83 "Spaces inside emphasis markers"
84 }
85
86 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
87 let content = ctx.content;
88 let _timer = crate::profiling::ScopedTimer::new("MD037_check");
89
90 if !content.contains('*') && !content.contains('_') {
92 return Ok(vec![]);
93 }
94
95 let line_index = &ctx.line_index;
97
98 let mut warnings = Vec::new();
99
100 for line in ctx
104 .filtered_lines()
105 .skip_front_matter()
106 .skip_code_blocks()
107 .skip_math_blocks()
108 .skip_html_blocks()
109 .skip_jsx_expressions()
110 .skip_mdx_comments()
111 .skip_obsidian_comments()
112 .skip_mkdocstrings()
113 {
114 if !line.content.contains('*') && !line.content.contains('_') {
116 continue;
117 }
118
119 self.check_line_for_emphasis_issues_fast(line.content, line.line_num, &mut warnings);
121 }
122
123 let mut filtered_warnings = Vec::new();
125 let lines = ctx.raw_lines();
126
127 for (line_idx, line) in lines.iter().enumerate() {
128 let line_num = line_idx + 1;
129 let line_start_pos = line_index.get_line_start_byte(line_num).unwrap_or(0);
130
131 for warning in &warnings {
133 if warning.line == line_num {
134 let byte_pos = line_start_pos + (warning.column - 1);
138 let line_pos = warning.column - 1;
140 let char_col = byte_to_char_count(line, warning.column - 1);
141
142 let in_pandoc_construct = ctx.flavor.is_pandoc_compatible() && ctx.is_in_bracketed_span(byte_pos);
150 if !in_pandoc_construct
151 && !self.is_in_link(ctx, byte_pos)
152 && !ctx.is_in_html_comment(byte_pos)
153 && !is_in_math_context(ctx, byte_pos)
154 && !is_in_table_cell(ctx, line_num, char_col)
155 && !ctx.is_in_code_span(line_num, char_col)
156 && !is_in_inline_html_code(line, line_pos)
157 && !is_in_jsx_expression(ctx, byte_pos)
158 && !is_in_mdx_comment(ctx, byte_pos)
159 && !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
160 && !ctx.is_position_in_obsidian_comment(line_num, char_col)
161 {
162 let mut adjusted_warning = warning.clone();
163 adjusted_warning.column = char_col;
165 adjusted_warning.end_column = byte_to_char_count(line, warning.end_column - 1);
166 if let Some(fix) = &mut adjusted_warning.fix {
167 let abs_start = line_start_pos + fix.range.start;
169 let abs_end = line_start_pos + fix.range.end;
170 fix.range = abs_start..abs_end;
171 }
172 filtered_warnings.push(adjusted_warning);
173 }
174 }
175 }
176 }
177
178 Ok(filtered_warnings)
179 }
180
181 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
182 let content = ctx.content;
183 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
184
185 if !content.contains('*') && !content.contains('_') {
187 return Ok(content.to_string());
188 }
189
190 let warnings = self.check(ctx)?;
192 let warnings =
193 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
194
195 if warnings.is_empty() {
197 return Ok(content.to_string());
198 }
199
200 let mut result = content.to_string();
202 let mut offset: isize = 0;
203
204 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
206 sorted_warnings.sort_by_key(|w| (w.line, w.column));
207
208 for warning in sorted_warnings {
209 if let Some(fix) = &warning.fix {
210 let actual_start = (fix.range.start as isize + offset) as usize;
212 let actual_end = (fix.range.end as isize + offset) as usize;
213
214 if actual_start < result.len() && actual_end <= result.len() {
216 result.replace_range(actual_start..actual_end, &fix.replacement);
218 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
220 }
221 }
222 }
223
224 Ok(result)
225 }
226
227 fn category(&self) -> RuleCategory {
229 RuleCategory::Emphasis
230 }
231
232 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
234 ctx.content.is_empty() || !ctx.likely_has_emphasis()
235 }
236
237 fn as_any(&self) -> &dyn std::any::Any {
238 self
239 }
240
241 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
242 where
243 Self: Sized,
244 {
245 Box::new(MD037NoSpaceInEmphasis)
246 }
247}
248
249impl MD037NoSpaceInEmphasis {
250 #[inline]
252 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
253 if has_doc_patterns(line) {
255 return;
256 }
257
258 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
263 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
264 {
265 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
266 && let Some(full_match) = caps.get(0)
267 {
268 let list_marker_end = full_match.end();
269 if list_marker_end < line.len() {
270 let remaining_content = &line[list_marker_end..];
271
272 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
275 }
276 }
277 return;
278 }
279
280 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
282 }
283
284 fn check_line_content_for_emphasis_fast(
286 &self,
287 content: &str,
288 line_num: usize,
289 offset: usize,
290 warnings: &mut Vec<LintWarning>,
291 ) {
292 let processed_content = replace_inline_code(content);
295 let processed_content = replace_inline_math(&processed_content);
296
297 let markers = find_emphasis_markers(&processed_content);
299 if markers.is_empty() {
300 return;
301 }
302
303 let spans = find_emphasis_spans(&processed_content, &markers);
305
306 let valid_ranges = find_valid_emphasis_ranges(&processed_content, &markers);
311
312 for span in spans {
314 if has_spacing_issues(&span) {
315 let full_start = span.opening.start_pos;
316 let full_end = span.closing.end_pos();
317
318 if valid_ranges
320 .iter()
321 .any(|&(start, end)| start <= full_start && full_end <= end)
322 {
323 continue;
324 }
325
326 let full_text = &content[full_start..full_end];
327
328 if full_end < content.len() {
331 let remaining = &content[full_end..];
332 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
334 continue;
335 }
336 }
337
338 let marker_char = span.opening.as_char();
340 let marker_str = if span.opening.count == 1 {
341 marker_char.to_string()
342 } else {
343 format!("{marker_char}{marker_char}")
344 };
345
346 let original_content = &content[span.opening.end_pos()..span.closing.start_pos];
353 let trimmed_content = original_content.trim();
354 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
355
356 let display_text = truncate_for_display(full_text, 60);
358
359 let warning = LintWarning {
360 rule_name: Some(self.name().to_string()),
361 message: format!("Spaces inside emphasis markers: {display_text:?}"),
362 line: line_num,
366 column: offset + full_start + 1,
367 end_line: line_num,
368 end_column: offset + full_end + 1,
369 severity: Severity::Warning,
370 fix: Some(Fix::new((offset + full_start)..(offset + full_end), fixed_text)),
371 };
372
373 warnings.push(warning);
374 }
375 }
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use crate::lint_context::LintContext;
383
384 #[test]
385 fn test_emphasis_marker_parsing() {
386 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
387 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
390 assert_eq!(markers.len(), 4); }
392
393 #[test]
394 fn test_emphasis_span_detection() {
395 let markers = find_emphasis_markers("This has *valid* emphasis");
396 let spans = find_emphasis_spans("This has *valid* emphasis", &markers);
397 assert_eq!(spans.len(), 1);
398 assert_eq!(spans[0].content, "valid");
399 assert!(!spans[0].has_leading_space);
400 assert!(!spans[0].has_trailing_space);
401
402 let markers = find_emphasis_markers("This has * invalid * emphasis");
403 let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
404 assert_eq!(spans.len(), 1);
405 assert_eq!(spans[0].content, " invalid ");
406 assert!(spans[0].has_leading_space);
407 assert!(spans[0].has_trailing_space);
408 }
409
410 #[test]
411 fn test_with_document_structure() {
412 let rule = MD037NoSpaceInEmphasis;
413
414 let content = "This is *correct* emphasis and **strong emphasis**";
416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417 let result = rule.check(&ctx).unwrap();
418 assert!(result.is_empty(), "No warnings expected for correct emphasis");
419
420 let content = "This is * text with spaces * and more content";
422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
423 let result = rule.check(&ctx).unwrap();
424 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
425
426 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429 let result = rule.check(&ctx).unwrap();
430 assert!(
431 !result.is_empty(),
432 "Expected warnings for spaces in emphasis outside code block"
433 );
434 }
435
436 #[test]
437 fn test_inline_code_inside_spaced_emphasis_preserved_on_fix() {
438 let rule = MD037NoSpaceInEmphasis;
442 let content = "Set * the `id` field * below.";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let fixed = rule.fix(&ctx).unwrap();
445 assert_eq!(fixed, "Set *the `id` field* below.");
446 assert!(!fixed.contains('X'), "masking placeholder leaked into fix: {fixed}");
447 }
448
449 #[test]
450 fn test_emphasis_in_links_not_flagged() {
451 let rule = MD037NoSpaceInEmphasis;
452 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
453
454This has * real spaced emphasis * that should be flagged."#;
455 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let result = rule.check(&ctx).unwrap();
457
458 assert_eq!(
462 result.len(),
463 1,
464 "Expected exactly 1 warning, but got: {:?}",
465 result.len()
466 );
467 assert!(result[0].message.contains("Spaces inside emphasis markers"));
468 assert!(result[0].line == 3); }
471
472 #[test]
473 fn test_emphasis_in_links_vs_outside_links() {
474 let rule = MD037NoSpaceInEmphasis;
475 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
476
477[* link *]: https://example.com/*path*"#;
478 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479 let result = rule.check(&ctx).unwrap();
480
481 assert_eq!(result.len(), 1);
483 assert!(result[0].message.contains("Spaces inside emphasis markers"));
484 assert!(result[0].line == 1);
486 }
487
488 #[test]
489 fn test_issue_49_asterisk_in_inline_code() {
490 let rule = MD037NoSpaceInEmphasis;
492
493 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497 assert!(
498 result.is_empty(),
499 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
500 );
501 }
502
503 #[test]
504 fn test_issue_28_inline_code_in_emphasis() {
505 let rule = MD037NoSpaceInEmphasis;
507
508 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.";
510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511 let result = rule.check(&ctx).unwrap();
512 assert!(
513 result.is_empty(),
514 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
515 );
516
517 let content2 = "The **`foo` and `bar`** methods are important.";
519 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
520 let result2 = rule.check(&ctx2).unwrap();
521 assert!(
522 result2.is_empty(),
523 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
524 );
525
526 let content3 = "This is __inline `code`__ with underscores.";
528 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
529 let result3 = rule.check(&ctx3).unwrap();
530 assert!(
531 result3.is_empty(),
532 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
533 );
534
535 let content4 = "This is *inline `test`* with single asterisks.";
537 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
538 let result4 = rule.check(&ctx4).unwrap();
539 assert!(
540 result4.is_empty(),
541 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
542 );
543
544 let content5 = "This has * real spaces * that should be flagged.";
546 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
547 let result5 = rule.check(&ctx5).unwrap();
548 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
549 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
550 }
551
552 #[test]
553 fn test_multibyte_utf8_no_panic() {
554 let rule = MD037NoSpaceInEmphasis;
558
559 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
561 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx);
563 assert!(result.is_ok(), "Greek text should not panic");
564
565 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
567 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
568 let result = rule.check(&ctx);
569 assert!(result.is_ok(), "Chinese text should not panic");
570
571 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
573 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
574 let result = rule.check(&ctx);
575 assert!(result.is_ok(), "Cyrillic text should not panic");
576
577 let mixed =
579 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
580 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx);
582 assert!(result.is_ok(), "Mixed CJK text should not panic");
583
584 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
586 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx);
588 assert!(result.is_ok(), "Arabic text should not panic");
589
590 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
592 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx);
594 assert!(result.is_ok(), "Emoji text should not panic");
595 }
596
597 #[test]
598 fn test_template_shortcode_syntax_not_flagged() {
599 let rule = MD037NoSpaceInEmphasis;
602
603 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let result = rule.check(&ctx).unwrap();
607 assert!(
608 result.is_empty(),
609 "Template shortcode syntax should not be flagged. Got: {result:?}"
610 );
611
612 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616 assert!(
617 result.is_empty(),
618 "Template shortcode syntax should not be flagged. Got: {result:?}"
619 );
620
621 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let result = rule.check(&ctx).unwrap();
625 assert!(
626 result.is_empty(),
627 "Multiple template shortcodes should not be flagged. Got: {result:?}"
628 );
629
630 let content = "This has * real spaced emphasis * here.";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
635 }
636
637 #[test]
638 fn test_multiline_code_span_not_flagged() {
639 let rule = MD037NoSpaceInEmphasis;
642
643 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";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let result = rule.check(&ctx).unwrap();
647 assert!(
648 result.is_empty(),
649 "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
650 );
651
652 let content2 = "Text with `code that\nspans * multiple * lines` here.";
654 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
655 let result2 = rule.check(&ctx2).unwrap();
656 assert!(
657 result2.is_empty(),
658 "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
659 );
660 }
661
662 #[test]
663 fn test_html_block_asterisks_not_flagged() {
664 let rule = MD037NoSpaceInEmphasis;
665
666 let content = r#"<table>
668<tr><td>Format</td><td>Size</td></tr>
669<tr><td>BC1</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 8</code></td></tr>
670<tr><td>BC2</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 16</code></td></tr>
671</table>"#;
672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673 let result = rule.check(&ctx).unwrap();
674 assert!(
675 result.is_empty(),
676 "Should not flag asterisks inside HTML blocks. Got: {result:?}"
677 );
678
679 let content2 = "<div>\n<p>Value is * something * here</p>\n</div>";
681 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
682 let result2 = rule.check(&ctx2).unwrap();
683 assert!(
684 result2.is_empty(),
685 "Should not flag emphasis-like patterns inside HTML div blocks. Got: {result2:?}"
686 );
687
688 let content3 = "Regular * spaced emphasis * text\n\n<div>* not emphasis *</div>";
690 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
691 let result3 = rule.check(&ctx3).unwrap();
692 assert_eq!(
693 result3.len(),
694 1,
695 "Should flag spaced emphasis in regular markdown but not inside HTML blocks. Got: {result3:?}"
696 );
697 assert_eq!(result3[0].line, 1, "Warning should be on line 1 (regular markdown)");
698 }
699
700 #[test]
701 fn test_mkdocs_icon_shortcode_not_flagged() {
702 let rule = MD037NoSpaceInEmphasis;
704
705 let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
709 let result = rule.check(&ctx).unwrap();
710 assert!(
711 result.is_empty(),
712 "Should not flag MkDocs icon shortcodes. Got: {result:?}"
713 );
714
715 let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
717 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
718 let result2 = rule.check(&ctx2).unwrap();
719 assert!(
720 !result2.is_empty(),
721 "Should still flag real spaced emphasis in MkDocs mode"
722 );
723 }
724
725 #[test]
726 fn test_mkdocs_pymdown_markup_not_flagged() {
727 let rule = MD037NoSpaceInEmphasis;
729
730 let content = "Press ++ctrl+c++ to copy.";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
733 let result = rule.check(&ctx).unwrap();
734 assert!(
735 result.is_empty(),
736 "Should not flag PyMdown Keys notation. Got: {result:?}"
737 );
738
739 let content2 = "This is ==highlighted text== for emphasis.";
741 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
742 let result2 = rule.check(&ctx2).unwrap();
743 assert!(
744 result2.is_empty(),
745 "Should not flag PyMdown Mark notation. Got: {result2:?}"
746 );
747
748 let content3 = "This is ^^inserted text^^ here.";
750 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
751 let result3 = rule.check(&ctx3).unwrap();
752 assert!(
753 result3.is_empty(),
754 "Should not flag PyMdown Insert notation. Got: {result3:?}"
755 );
756
757 let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
759 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
760 let result4 = rule.check(&ctx4).unwrap();
761 assert!(
762 !result4.is_empty(),
763 "Should still flag real spaced emphasis alongside PyMdown markup"
764 );
765 }
766
767 #[test]
770 fn test_obsidian_highlight_not_flagged() {
771 let rule = MD037NoSpaceInEmphasis;
773
774 let content = "This is ==highlighted text== here.";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
777 let result = rule.check(&ctx).unwrap();
778 assert!(
779 result.is_empty(),
780 "Should not flag Obsidian highlight syntax. Got: {result:?}"
781 );
782 }
783
784 #[test]
785 fn test_obsidian_highlight_multiple_on_line() {
786 let rule = MD037NoSpaceInEmphasis;
788
789 let content = "Both ==one== and ==two== are highlighted.";
790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
791 let result = rule.check(&ctx).unwrap();
792 assert!(
793 result.is_empty(),
794 "Should not flag multiple Obsidian highlights. Got: {result:?}"
795 );
796 }
797
798 #[test]
799 fn test_obsidian_highlight_entire_paragraph() {
800 let rule = MD037NoSpaceInEmphasis;
802
803 let content = "==Entire paragraph highlighted==";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
805 let result = rule.check(&ctx).unwrap();
806 assert!(
807 result.is_empty(),
808 "Should not flag entire highlighted paragraph. Got: {result:?}"
809 );
810 }
811
812 #[test]
813 fn test_obsidian_highlight_with_emphasis() {
814 let rule = MD037NoSpaceInEmphasis;
816
817 let content = "**==bold highlight==**";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
820 let result = rule.check(&ctx).unwrap();
821 assert!(
822 result.is_empty(),
823 "Should not flag bold highlight combination. Got: {result:?}"
824 );
825
826 let content2 = "*==italic highlight==*";
828 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Obsidian, None);
829 let result2 = rule.check(&ctx2).unwrap();
830 assert!(
831 result2.is_empty(),
832 "Should not flag italic highlight combination. Got: {result2:?}"
833 );
834 }
835
836 #[test]
837 fn test_obsidian_highlight_in_lists() {
838 let rule = MD037NoSpaceInEmphasis;
840
841 let content = "- Item with ==highlight== text\n- Another ==highlighted== item";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
843 let result = rule.check(&ctx).unwrap();
844 assert!(
845 result.is_empty(),
846 "Should not flag highlights in list items. Got: {result:?}"
847 );
848 }
849
850 #[test]
851 fn test_obsidian_highlight_in_blockquote() {
852 let rule = MD037NoSpaceInEmphasis;
854
855 let content = "> This quote has ==highlighted== text.";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
857 let result = rule.check(&ctx).unwrap();
858 assert!(
859 result.is_empty(),
860 "Should not flag highlights in blockquotes. Got: {result:?}"
861 );
862 }
863
864 #[test]
865 fn test_obsidian_highlight_in_tables() {
866 let rule = MD037NoSpaceInEmphasis;
868
869 let content = "| Header | Column |\n|--------|--------|\n| ==highlighted== | text |";
870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
871 let result = rule.check(&ctx).unwrap();
872 assert!(
873 result.is_empty(),
874 "Should not flag highlights in tables. Got: {result:?}"
875 );
876 }
877
878 #[test]
879 fn test_obsidian_highlight_in_code_blocks_ignored() {
880 let rule = MD037NoSpaceInEmphasis;
882
883 let content = "```\n==not highlight in code==\n```";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
885 let result = rule.check(&ctx).unwrap();
886 assert!(
887 result.is_empty(),
888 "Should ignore highlights in code blocks. Got: {result:?}"
889 );
890 }
891
892 #[test]
893 fn test_obsidian_highlight_edge_case_three_equals() {
894 let rule = MD037NoSpaceInEmphasis;
896
897 let content = "Test === something === here";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
900 let result = rule.check(&ctx).unwrap();
901 let _ = result;
904 }
905
906 #[test]
907 fn test_obsidian_highlight_edge_case_four_equals() {
908 let rule = MD037NoSpaceInEmphasis;
910
911 let content = "Test ==== here";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
913 let result = rule.check(&ctx).unwrap();
914 let _ = result;
916 }
917
918 #[test]
919 fn test_obsidian_highlight_adjacent() {
920 let rule = MD037NoSpaceInEmphasis;
922
923 let content = "==one====two==";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
925 let result = rule.check(&ctx).unwrap();
926 let _ = result;
928 }
929
930 #[test]
931 fn test_obsidian_highlight_with_special_chars() {
932 let rule = MD037NoSpaceInEmphasis;
934
935 let content = "Test ==code: `test`== here";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
938 let result = rule.check(&ctx).unwrap();
939 let _ = result;
941 }
942
943 #[test]
944 fn test_obsidian_highlight_unclosed() {
945 let rule = MD037NoSpaceInEmphasis;
947
948 let content = "This ==starts but never ends";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
950 let result = rule.check(&ctx).unwrap();
951 let _ = result;
953 }
954
955 #[test]
956 fn test_obsidian_highlight_still_flags_real_emphasis_issues() {
957 let rule = MD037NoSpaceInEmphasis;
959
960 let content = "This has * spaced emphasis * and ==valid highlight==";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
962 let result = rule.check(&ctx).unwrap();
963 assert!(
964 !result.is_empty(),
965 "Should still flag real spaced emphasis in Obsidian mode"
966 );
967 assert!(
968 result.len() == 1,
969 "Should flag exactly one issue (the spaced emphasis). Got: {result:?}"
970 );
971 }
972
973 #[test]
974 fn test_standard_flavor_does_not_recognize_highlight() {
975 let rule = MD037NoSpaceInEmphasis;
978
979 let content = "This is ==highlighted text== here.";
980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981 let result = rule.check(&ctx).unwrap();
982 let _ = result; }
987
988 #[test]
989 fn test_obsidian_highlight_mixed_with_regular_emphasis() {
990 let rule = MD037NoSpaceInEmphasis;
992
993 let content = "==highlighted== and *italic* and **bold** text";
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 valid highlight and emphasis. Got: {result:?}"
999 );
1000 }
1001
1002 #[test]
1003 fn test_obsidian_highlight_unicode() {
1004 let rule = MD037NoSpaceInEmphasis;
1006
1007 let content = "Text ==日本語 highlighted== here";
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 handle Unicode in highlights. Got: {result:?}"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_obsidian_highlight_with_html() {
1018 let rule = MD037NoSpaceInEmphasis;
1020
1021 let content = "<!-- ==not highlight in comment== --> ==actual highlight==";
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1023 let result = rule.check(&ctx).unwrap();
1024 let _ = result;
1026 }
1027
1028 #[test]
1029 fn test_obsidian_inline_comment_emphasis_ignored() {
1030 let rule = MD037NoSpaceInEmphasis;
1032
1033 let content = "Visible %%* spaced emphasis *%% still visible.";
1034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1035 let result = rule.check(&ctx).unwrap();
1036
1037 assert!(
1038 result.is_empty(),
1039 "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
1040 );
1041 }
1042
1043 #[test]
1044 fn test_inline_html_code_not_flagged() {
1045 let rule = MD037NoSpaceInEmphasis;
1046
1047 let content = "The formula is <code>a * b * c</code> in math.";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let result = rule.check(&ctx).unwrap();
1051 assert!(
1052 result.is_empty(),
1053 "Should not flag asterisks inside inline <code> tags. Got: {result:?}"
1054 );
1055
1056 let content2 = "Use <kbd>Ctrl * A</kbd> and <samp>x * y</samp> here.";
1058 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1059 let result2 = rule.check(&ctx2).unwrap();
1060 assert!(
1061 result2.is_empty(),
1062 "Should not flag asterisks inside inline <kbd> and <samp> tags. Got: {result2:?}"
1063 );
1064
1065 let content3 = r#"Result: <code class="math">a * b</code> done."#;
1067 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1068 let result3 = rule.check(&ctx3).unwrap();
1069 assert!(
1070 result3.is_empty(),
1071 "Should not flag asterisks inside <code> with attributes. Got: {result3:?}"
1072 );
1073
1074 let content4 = "Text * spaced * and <code>a * b</code>.";
1076 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1077 let result4 = rule.check(&ctx4).unwrap();
1078 assert_eq!(
1079 result4.len(),
1080 1,
1081 "Should flag real spaced emphasis but not code content. Got: {result4:?}"
1082 );
1083 assert_eq!(result4[0].column, 6);
1084 }
1085
1086 #[test]
1089 fn test_pandoc_bracketed_span_guard() {
1090 use crate::config::MarkdownFlavor;
1091 let rule = MD037NoSpaceInEmphasis;
1092 let content = "See [* important *]{.highlight} for details.\n";
1094 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1095 let result = rule.check(&ctx).unwrap();
1096 assert!(
1097 result.is_empty(),
1098 "MD037 should not flag emphasis-like patterns inside Pandoc bracketed spans: {result:?}"
1099 );
1100
1101 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1103 let result_std = rule.check(&ctx_std).unwrap();
1104 assert!(
1105 !result_std.is_empty(),
1106 "MD037 should still flag spaces in emphasis under Standard flavor: {result_std:?}"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_spaced_bold_metadata_pattern_detected() {
1112 let rule = MD037NoSpaceInEmphasis;
1113
1114 let content = "# Test\n\n** Explicit Import**: Convert markdownlint configs to rumdl format:";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118 assert_eq!(
1119 result.len(),
1120 1,
1121 "Should flag '** Explicit Import**' as spaced emphasis. Got: {result:?}"
1122 );
1123 assert_eq!(result[0].line, 3);
1124
1125 let content2 = "# Test\n\n**trailing only **: some text";
1127 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1128 let result2 = rule.check(&ctx2).unwrap();
1129 assert_eq!(
1130 result2.len(),
1131 1,
1132 "Should flag '**trailing only **' as spaced emphasis. Got: {result2:?}"
1133 );
1134
1135 let content3 = "# Test\n\n** both spaces **: some text";
1137 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1138 let result3 = rule.check(&ctx3).unwrap();
1139 assert_eq!(
1140 result3.len(),
1141 1,
1142 "Should flag '** both spaces **' as spaced emphasis. Got: {result3:?}"
1143 );
1144
1145 let content4 = "# Test\n\n**Key**: value";
1147 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1148 let result4 = rule.check(&ctx4).unwrap();
1149 assert!(
1150 result4.is_empty(),
1151 "Should not flag valid bold metadata '**Key**: value'. Got: {result4:?}"
1152 );
1153 }
1154}