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