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_html_comment, is_in_inline_html_code, is_in_jsx_expression, is_in_math_context, is_in_mdx_comment,
14 is_in_mkdocs_markup, 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 if !self.is_in_link(ctx, byte_pos)
138 && !is_in_html_comment(content, byte_pos)
139 && !is_in_math_context(ctx, byte_pos)
140 && !is_in_table_cell(ctx, line_num, warning.column)
141 && !ctx.is_in_code_span(line_num, warning.column)
142 && !is_in_inline_html_code(line, line_pos)
143 && !is_in_jsx_expression(ctx, byte_pos)
144 && !is_in_mdx_comment(ctx, byte_pos)
145 && !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
146 && !ctx.is_position_in_obsidian_comment(line_num, warning.column)
147 {
148 let mut adjusted_warning = warning.clone();
149 if let Some(fix) = &mut adjusted_warning.fix {
150 let abs_start = line_start_pos + fix.range.start;
152 let abs_end = line_start_pos + fix.range.end;
153 fix.range = abs_start..abs_end;
154 }
155 filtered_warnings.push(adjusted_warning);
156 }
157 }
158 }
159 }
160
161 Ok(filtered_warnings)
162 }
163
164 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
165 let content = ctx.content;
166 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
167
168 if !content.contains('*') && !content.contains('_') {
170 return Ok(content.to_string());
171 }
172
173 let warnings = self.check(ctx)?;
175 let warnings =
176 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
177
178 if warnings.is_empty() {
180 return Ok(content.to_string());
181 }
182
183 let mut result = content.to_string();
185 let mut offset: isize = 0;
186
187 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
189 sorted_warnings.sort_by_key(|w| (w.line, w.column));
190
191 for warning in sorted_warnings {
192 if let Some(fix) = &warning.fix {
193 let actual_start = (fix.range.start as isize + offset) as usize;
195 let actual_end = (fix.range.end as isize + offset) as usize;
196
197 if actual_start < result.len() && actual_end <= result.len() {
199 result.replace_range(actual_start..actual_end, &fix.replacement);
201 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
203 }
204 }
205 }
206
207 Ok(result)
208 }
209
210 fn category(&self) -> RuleCategory {
212 RuleCategory::Emphasis
213 }
214
215 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
217 ctx.content.is_empty() || !ctx.likely_has_emphasis()
218 }
219
220 fn as_any(&self) -> &dyn std::any::Any {
221 self
222 }
223
224 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
225 where
226 Self: Sized,
227 {
228 Box::new(MD037NoSpaceInEmphasis)
229 }
230}
231
232impl MD037NoSpaceInEmphasis {
233 #[inline]
235 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
236 if has_doc_patterns(line) {
238 return;
239 }
240
241 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
246 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
247 {
248 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
249 && let Some(full_match) = caps.get(0)
250 {
251 let list_marker_end = full_match.end();
252 if list_marker_end < line.len() {
253 let remaining_content = &line[list_marker_end..];
254
255 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
258 }
259 }
260 return;
261 }
262
263 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
265 }
266
267 fn check_line_content_for_emphasis_fast(
269 &self,
270 content: &str,
271 line_num: usize,
272 offset: usize,
273 warnings: &mut Vec<LintWarning>,
274 ) {
275 let processed_content = replace_inline_code(content);
278 let processed_content = replace_inline_math(&processed_content);
279
280 let markers = find_emphasis_markers(&processed_content);
282 if markers.is_empty() {
283 return;
284 }
285
286 let spans = find_emphasis_spans(&processed_content, &markers);
288
289 for span in spans {
291 if has_spacing_issues(&span) {
292 let full_start = span.opening.start_pos;
294 let full_end = span.closing.end_pos();
295 let full_text = &content[full_start..full_end];
296
297 if full_end < content.len() {
300 let remaining = &content[full_end..];
301 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
303 continue;
304 }
305 }
306
307 let marker_char = span.opening.as_char();
309 let marker_str = if span.opening.count == 1 {
310 marker_char.to_string()
311 } else {
312 format!("{marker_char}{marker_char}")
313 };
314
315 let trimmed_content = span.content.trim();
317 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
318
319 let display_text = truncate_for_display(full_text, 60);
321
322 let warning = LintWarning {
323 rule_name: Some(self.name().to_string()),
324 message: format!("Spaces inside emphasis markers: {display_text:?}"),
325 line: line_num,
326 column: offset + full_start + 1, end_line: line_num,
328 end_column: offset + full_end + 1,
329 severity: Severity::Warning,
330 fix: Some(Fix::new((offset + full_start)..(offset + full_end), fixed_text)),
331 };
332
333 warnings.push(warning);
334 }
335 }
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::lint_context::LintContext;
343
344 #[test]
345 fn test_emphasis_marker_parsing() {
346 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
347 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
350 assert_eq!(markers.len(), 4); }
352
353 #[test]
354 fn test_emphasis_span_detection() {
355 let markers = find_emphasis_markers("This has *valid* emphasis");
356 let spans = find_emphasis_spans("This has *valid* emphasis", &markers);
357 assert_eq!(spans.len(), 1);
358 assert_eq!(spans[0].content, "valid");
359 assert!(!spans[0].has_leading_space);
360 assert!(!spans[0].has_trailing_space);
361
362 let markers = find_emphasis_markers("This has * invalid * emphasis");
363 let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
364 assert_eq!(spans.len(), 1);
365 assert_eq!(spans[0].content, " invalid ");
366 assert!(spans[0].has_leading_space);
367 assert!(spans[0].has_trailing_space);
368 }
369
370 #[test]
371 fn test_with_document_structure() {
372 let rule = MD037NoSpaceInEmphasis;
373
374 let content = "This is *correct* emphasis and **strong emphasis**";
376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377 let result = rule.check(&ctx).unwrap();
378 assert!(result.is_empty(), "No warnings expected for correct emphasis");
379
380 let content = "This is * text with spaces * and more content";
382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
383 let result = rule.check(&ctx).unwrap();
384 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
385
386 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389 let result = rule.check(&ctx).unwrap();
390 assert!(
391 !result.is_empty(),
392 "Expected warnings for spaces in emphasis outside code block"
393 );
394 }
395
396 #[test]
397 fn test_emphasis_in_links_not_flagged() {
398 let rule = MD037NoSpaceInEmphasis;
399 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
400
401This has * real spaced emphasis * that should be flagged."#;
402 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403 let result = rule.check(&ctx).unwrap();
404
405 assert_eq!(
409 result.len(),
410 1,
411 "Expected exactly 1 warning, but got: {:?}",
412 result.len()
413 );
414 assert!(result[0].message.contains("Spaces inside emphasis markers"));
415 assert!(result[0].line == 3); }
418
419 #[test]
420 fn test_emphasis_in_links_vs_outside_links() {
421 let rule = MD037NoSpaceInEmphasis;
422 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
423
424[* link *]: https://example.com/*path*"#;
425 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let result = rule.check(&ctx).unwrap();
427
428 assert_eq!(result.len(), 1);
430 assert!(result[0].message.contains("Spaces inside emphasis markers"));
431 assert!(result[0].line == 1);
433 }
434
435 #[test]
436 fn test_issue_49_asterisk_in_inline_code() {
437 let rule = MD037NoSpaceInEmphasis;
439
440 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443 let result = rule.check(&ctx).unwrap();
444 assert!(
445 result.is_empty(),
446 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
447 );
448 }
449
450 #[test]
451 fn test_issue_28_inline_code_in_emphasis() {
452 let rule = MD037NoSpaceInEmphasis;
454
455 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.";
457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458 let result = rule.check(&ctx).unwrap();
459 assert!(
460 result.is_empty(),
461 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
462 );
463
464 let content2 = "The **`foo` and `bar`** methods are important.";
466 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
467 let result2 = rule.check(&ctx2).unwrap();
468 assert!(
469 result2.is_empty(),
470 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
471 );
472
473 let content3 = "This is __inline `code`__ with underscores.";
475 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
476 let result3 = rule.check(&ctx3).unwrap();
477 assert!(
478 result3.is_empty(),
479 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
480 );
481
482 let content4 = "This is *inline `test`* with single asterisks.";
484 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
485 let result4 = rule.check(&ctx4).unwrap();
486 assert!(
487 result4.is_empty(),
488 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
489 );
490
491 let content5 = "This has * real spaces * that should be flagged.";
493 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
494 let result5 = rule.check(&ctx5).unwrap();
495 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
496 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
497 }
498
499 #[test]
500 fn test_multibyte_utf8_no_panic() {
501 let rule = MD037NoSpaceInEmphasis;
505
506 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
508 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx);
510 assert!(result.is_ok(), "Greek text should not panic");
511
512 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
514 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
515 let result = rule.check(&ctx);
516 assert!(result.is_ok(), "Chinese text should not panic");
517
518 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
520 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx);
522 assert!(result.is_ok(), "Cyrillic text should not panic");
523
524 let mixed =
526 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
527 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
528 let result = rule.check(&ctx);
529 assert!(result.is_ok(), "Mixed CJK text should not panic");
530
531 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
533 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
534 let result = rule.check(&ctx);
535 assert!(result.is_ok(), "Arabic text should not panic");
536
537 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
539 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
540 let result = rule.check(&ctx);
541 assert!(result.is_ok(), "Emoji text should not panic");
542 }
543
544 #[test]
545 fn test_template_shortcode_syntax_not_flagged() {
546 let rule = MD037NoSpaceInEmphasis;
549
550 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let result = rule.check(&ctx).unwrap();
554 assert!(
555 result.is_empty(),
556 "Template shortcode syntax should not be flagged. Got: {result:?}"
557 );
558
559 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert!(
564 result.is_empty(),
565 "Template shortcode syntax should not be flagged. Got: {result:?}"
566 );
567
568 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571 let result = rule.check(&ctx).unwrap();
572 assert!(
573 result.is_empty(),
574 "Multiple template shortcodes should not be flagged. Got: {result:?}"
575 );
576
577 let content = "This has * real spaced emphasis * here.";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let result = rule.check(&ctx).unwrap();
581 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
582 }
583
584 #[test]
585 fn test_multiline_code_span_not_flagged() {
586 let rule = MD037NoSpaceInEmphasis;
589
590 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";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx).unwrap();
594 assert!(
595 result.is_empty(),
596 "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
597 );
598
599 let content2 = "Text with `code that\nspans * multiple * lines` here.";
601 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
602 let result2 = rule.check(&ctx2).unwrap();
603 assert!(
604 result2.is_empty(),
605 "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
606 );
607 }
608
609 #[test]
610 fn test_html_block_asterisks_not_flagged() {
611 let rule = MD037NoSpaceInEmphasis;
612
613 let content = r#"<table>
615<tr><td>Format</td><td>Size</td></tr>
616<tr><td>BC1</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 8</code></td></tr>
617<tr><td>BC2</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 16</code></td></tr>
618</table>"#;
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert!(
622 result.is_empty(),
623 "Should not flag asterisks inside HTML blocks. Got: {result:?}"
624 );
625
626 let content2 = "<div>\n<p>Value is * something * here</p>\n</div>";
628 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
629 let result2 = rule.check(&ctx2).unwrap();
630 assert!(
631 result2.is_empty(),
632 "Should not flag emphasis-like patterns inside HTML div blocks. Got: {result2:?}"
633 );
634
635 let content3 = "Regular * spaced emphasis * text\n\n<div>* not emphasis *</div>";
637 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
638 let result3 = rule.check(&ctx3).unwrap();
639 assert_eq!(
640 result3.len(),
641 1,
642 "Should flag spaced emphasis in regular markdown but not inside HTML blocks. Got: {result3:?}"
643 );
644 assert_eq!(result3[0].line, 1, "Warning should be on line 1 (regular markdown)");
645 }
646
647 #[test]
648 fn test_mkdocs_icon_shortcode_not_flagged() {
649 let rule = MD037NoSpaceInEmphasis;
651
652 let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
656 let result = rule.check(&ctx).unwrap();
657 assert!(
658 result.is_empty(),
659 "Should not flag MkDocs icon shortcodes. Got: {result:?}"
660 );
661
662 let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
664 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
665 let result2 = rule.check(&ctx2).unwrap();
666 assert!(
667 !result2.is_empty(),
668 "Should still flag real spaced emphasis in MkDocs mode"
669 );
670 }
671
672 #[test]
673 fn test_mkdocs_pymdown_markup_not_flagged() {
674 let rule = MD037NoSpaceInEmphasis;
676
677 let content = "Press ++ctrl+c++ to copy.";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
680 let result = rule.check(&ctx).unwrap();
681 assert!(
682 result.is_empty(),
683 "Should not flag PyMdown Keys notation. Got: {result:?}"
684 );
685
686 let content2 = "This is ==highlighted text== for emphasis.";
688 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
689 let result2 = rule.check(&ctx2).unwrap();
690 assert!(
691 result2.is_empty(),
692 "Should not flag PyMdown Mark notation. Got: {result2:?}"
693 );
694
695 let content3 = "This is ^^inserted text^^ here.";
697 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
698 let result3 = rule.check(&ctx3).unwrap();
699 assert!(
700 result3.is_empty(),
701 "Should not flag PyMdown Insert notation. Got: {result3:?}"
702 );
703
704 let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
706 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
707 let result4 = rule.check(&ctx4).unwrap();
708 assert!(
709 !result4.is_empty(),
710 "Should still flag real spaced emphasis alongside PyMdown markup"
711 );
712 }
713
714 #[test]
717 fn test_obsidian_highlight_not_flagged() {
718 let rule = MD037NoSpaceInEmphasis;
720
721 let content = "This is ==highlighted text== here.";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
724 let result = rule.check(&ctx).unwrap();
725 assert!(
726 result.is_empty(),
727 "Should not flag Obsidian highlight syntax. Got: {result:?}"
728 );
729 }
730
731 #[test]
732 fn test_obsidian_highlight_multiple_on_line() {
733 let rule = MD037NoSpaceInEmphasis;
735
736 let content = "Both ==one== and ==two== are highlighted.";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
738 let result = rule.check(&ctx).unwrap();
739 assert!(
740 result.is_empty(),
741 "Should not flag multiple Obsidian highlights. Got: {result:?}"
742 );
743 }
744
745 #[test]
746 fn test_obsidian_highlight_entire_paragraph() {
747 let rule = MD037NoSpaceInEmphasis;
749
750 let content = "==Entire paragraph highlighted==";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
752 let result = rule.check(&ctx).unwrap();
753 assert!(
754 result.is_empty(),
755 "Should not flag entire highlighted paragraph. Got: {result:?}"
756 );
757 }
758
759 #[test]
760 fn test_obsidian_highlight_with_emphasis() {
761 let rule = MD037NoSpaceInEmphasis;
763
764 let content = "**==bold highlight==**";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
767 let result = rule.check(&ctx).unwrap();
768 assert!(
769 result.is_empty(),
770 "Should not flag bold highlight combination. Got: {result:?}"
771 );
772
773 let content2 = "*==italic highlight==*";
775 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Obsidian, None);
776 let result2 = rule.check(&ctx2).unwrap();
777 assert!(
778 result2.is_empty(),
779 "Should not flag italic highlight combination. Got: {result2:?}"
780 );
781 }
782
783 #[test]
784 fn test_obsidian_highlight_in_lists() {
785 let rule = MD037NoSpaceInEmphasis;
787
788 let content = "- Item with ==highlight== text\n- Another ==highlighted== item";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
790 let result = rule.check(&ctx).unwrap();
791 assert!(
792 result.is_empty(),
793 "Should not flag highlights in list items. Got: {result:?}"
794 );
795 }
796
797 #[test]
798 fn test_obsidian_highlight_in_blockquote() {
799 let rule = MD037NoSpaceInEmphasis;
801
802 let content = "> This quote has ==highlighted== text.";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
804 let result = rule.check(&ctx).unwrap();
805 assert!(
806 result.is_empty(),
807 "Should not flag highlights in blockquotes. Got: {result:?}"
808 );
809 }
810
811 #[test]
812 fn test_obsidian_highlight_in_tables() {
813 let rule = MD037NoSpaceInEmphasis;
815
816 let content = "| Header | Column |\n|--------|--------|\n| ==highlighted== | text |";
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
818 let result = rule.check(&ctx).unwrap();
819 assert!(
820 result.is_empty(),
821 "Should not flag highlights in tables. Got: {result:?}"
822 );
823 }
824
825 #[test]
826 fn test_obsidian_highlight_in_code_blocks_ignored() {
827 let rule = MD037NoSpaceInEmphasis;
829
830 let content = "```\n==not highlight in code==\n```";
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
832 let result = rule.check(&ctx).unwrap();
833 assert!(
834 result.is_empty(),
835 "Should ignore highlights in code blocks. Got: {result:?}"
836 );
837 }
838
839 #[test]
840 fn test_obsidian_highlight_edge_case_three_equals() {
841 let rule = MD037NoSpaceInEmphasis;
843
844 let content = "Test === something === here";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
847 let result = rule.check(&ctx).unwrap();
848 let _ = result;
851 }
852
853 #[test]
854 fn test_obsidian_highlight_edge_case_four_equals() {
855 let rule = MD037NoSpaceInEmphasis;
857
858 let content = "Test ==== here";
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
860 let result = rule.check(&ctx).unwrap();
861 let _ = result;
863 }
864
865 #[test]
866 fn test_obsidian_highlight_adjacent() {
867 let rule = MD037NoSpaceInEmphasis;
869
870 let content = "==one====two==";
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
872 let result = rule.check(&ctx).unwrap();
873 let _ = result;
875 }
876
877 #[test]
878 fn test_obsidian_highlight_with_special_chars() {
879 let rule = MD037NoSpaceInEmphasis;
881
882 let content = "Test ==code: `test`== here";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
885 let result = rule.check(&ctx).unwrap();
886 let _ = result;
888 }
889
890 #[test]
891 fn test_obsidian_highlight_unclosed() {
892 let rule = MD037NoSpaceInEmphasis;
894
895 let content = "This ==starts but never ends";
896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
897 let result = rule.check(&ctx).unwrap();
898 let _ = result;
900 }
901
902 #[test]
903 fn test_obsidian_highlight_still_flags_real_emphasis_issues() {
904 let rule = MD037NoSpaceInEmphasis;
906
907 let content = "This has * spaced emphasis * and ==valid highlight==";
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
909 let result = rule.check(&ctx).unwrap();
910 assert!(
911 !result.is_empty(),
912 "Should still flag real spaced emphasis in Obsidian mode"
913 );
914 assert!(
915 result.len() == 1,
916 "Should flag exactly one issue (the spaced emphasis). Got: {result:?}"
917 );
918 }
919
920 #[test]
921 fn test_standard_flavor_does_not_recognize_highlight() {
922 let rule = MD037NoSpaceInEmphasis;
925
926 let content = "This is ==highlighted text== here.";
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928 let result = rule.check(&ctx).unwrap();
929 let _ = result; }
934
935 #[test]
936 fn test_obsidian_highlight_mixed_with_regular_emphasis() {
937 let rule = MD037NoSpaceInEmphasis;
939
940 let content = "==highlighted== and *italic* and **bold** text";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
942 let result = rule.check(&ctx).unwrap();
943 assert!(
944 result.is_empty(),
945 "Should not flag valid highlight and emphasis. Got: {result:?}"
946 );
947 }
948
949 #[test]
950 fn test_obsidian_highlight_unicode() {
951 let rule = MD037NoSpaceInEmphasis;
953
954 let content = "Text ==日本語 highlighted== here";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
956 let result = rule.check(&ctx).unwrap();
957 assert!(
958 result.is_empty(),
959 "Should handle Unicode in highlights. Got: {result:?}"
960 );
961 }
962
963 #[test]
964 fn test_obsidian_highlight_with_html() {
965 let rule = MD037NoSpaceInEmphasis;
967
968 let content = "<!-- ==not highlight in comment== --> ==actual highlight==";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
970 let result = rule.check(&ctx).unwrap();
971 let _ = result;
973 }
974
975 #[test]
976 fn test_obsidian_inline_comment_emphasis_ignored() {
977 let rule = MD037NoSpaceInEmphasis;
979
980 let content = "Visible %%* spaced emphasis *%% still visible.";
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
982 let result = rule.check(&ctx).unwrap();
983
984 assert!(
985 result.is_empty(),
986 "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
987 );
988 }
989
990 #[test]
991 fn test_inline_html_code_not_flagged() {
992 let rule = MD037NoSpaceInEmphasis;
993
994 let content = "The formula is <code>a * b * c</code> in math.";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998 assert!(
999 result.is_empty(),
1000 "Should not flag asterisks inside inline <code> tags. Got: {result:?}"
1001 );
1002
1003 let content2 = "Use <kbd>Ctrl * A</kbd> and <samp>x * y</samp> here.";
1005 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1006 let result2 = rule.check(&ctx2).unwrap();
1007 assert!(
1008 result2.is_empty(),
1009 "Should not flag asterisks inside inline <kbd> and <samp> tags. Got: {result2:?}"
1010 );
1011
1012 let content3 = r#"Result: <code class="math">a * b</code> done."#;
1014 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1015 let result3 = rule.check(&ctx3).unwrap();
1016 assert!(
1017 result3.is_empty(),
1018 "Should not flag asterisks inside <code> with attributes. Got: {result3:?}"
1019 );
1020
1021 let content4 = "Text * spaced * and <code>a * b</code>.";
1023 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1024 let result4 = rule.check(&ctx4).unwrap();
1025 assert_eq!(
1026 result4.len(),
1027 1,
1028 "Should flag real spaced emphasis but not code content. Got: {result4:?}"
1029 );
1030 assert_eq!(result4[0].column, 6);
1031 }
1032
1033 #[test]
1034 fn test_spaced_bold_metadata_pattern_detected() {
1035 let rule = MD037NoSpaceInEmphasis;
1036
1037 let content = "# Test\n\n** Explicit Import**: Convert markdownlint configs to rumdl format:";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let result = rule.check(&ctx).unwrap();
1041 assert_eq!(
1042 result.len(),
1043 1,
1044 "Should flag '** Explicit Import**' as spaced emphasis. Got: {result:?}"
1045 );
1046 assert_eq!(result[0].line, 3);
1047
1048 let content2 = "# Test\n\n**trailing only **: some text";
1050 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1051 let result2 = rule.check(&ctx2).unwrap();
1052 assert_eq!(
1053 result2.len(),
1054 1,
1055 "Should flag '**trailing only **' as spaced emphasis. Got: {result2:?}"
1056 );
1057
1058 let content3 = "# Test\n\n** both spaces **: some text";
1060 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1061 let result3 = rule.check(&ctx3).unwrap();
1062 assert_eq!(
1063 result3.len(),
1064 1,
1065 "Should flag '** both spaces **' as spaced emphasis. Got: {result3:?}"
1066 );
1067
1068 let content4 = "# Test\n\n**Key**: value";
1070 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1071 let result4 = rule.check(&ctx4).unwrap();
1072 assert!(
1073 result4.is_empty(),
1074 "Should not flag valid bold metadata '**Key**: value'. Got: {result4:?}"
1075 );
1076 }
1077}