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