1use crate::filtered_lines::FilteredLinesExt;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::emphasis_utils::{
7 EmphasisSpan, find_emphasis_markers, find_emphasis_spans, find_valid_emphasis_ranges, has_doc_patterns,
8 replace_inline_code, replace_inline_math,
9};
10use crate::utils::kramdown_utils::has_span_ial;
11use crate::utils::range_utils::byte_to_char_count;
12use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
13use crate::utils::skip_context::{
14 is_in_inline_html_code, is_in_jsx_expression, is_in_math_context, is_in_mdx_comment, is_in_mkdocs_markup,
15};
16use crate::utils::table_utils::TableUtils;
17use std::ops::Range;
18
19#[inline]
21fn has_spacing_issues(span: &EmphasisSpan) -> bool {
22 span.has_leading_space || span.has_trailing_space
23}
24
25fn table_line_flags(ctx: &crate::lint_context::LintContext) -> Vec<bool> {
28 if ctx.table_blocks.is_empty() {
29 return Vec::new();
30 }
31
32 let mut flags = vec![false; ctx.lines.len()];
33 for block in &ctx.table_blocks {
35 for idx in block.start_line..=block.end_line {
36 if let Some(flag) = flags.get_mut(idx) {
37 *flag = true;
38 }
39 }
40 }
41 flags
42}
43
44fn table_cell_ranges(line: &str) -> Vec<Range<usize>> {
51 let masked;
54 let scan = if line.contains('\\') || line.contains('`') {
55 let escaped = TableUtils::mask_pipes_for_table_parsing(line);
56 masked = TableUtils::mask_pipes_in_inline_code(&escaped);
57 debug_assert_eq!(
58 masked.len(),
59 line.len(),
60 "pipe masking must preserve byte offsets for cell slicing"
61 );
62 masked.as_str()
63 } else {
64 line
65 };
66
67 let mut ranges = Vec::new();
68 let mut start = 0;
69 for (pipe_pos, _) in scan.match_indices('|') {
70 ranges.push(start..pipe_pos);
71 start = pipe_pos + 1;
72 }
73 ranges.push(start..line.len());
74 ranges
75}
76
77#[inline]
80fn truncate_for_display(text: &str, max_len: usize) -> String {
81 if text.len() <= max_len {
82 return text.to_string();
83 }
84
85 let prefix_len = max_len / 2 - 2; let suffix_len = max_len / 2 - 2;
87
88 let prefix_end = text.floor_char_boundary(prefix_len.min(text.len()));
90 let suffix_start = text.floor_char_boundary(text.len().saturating_sub(suffix_len));
91
92 format!("{}...{}", &text[..prefix_end], &text[suffix_start..])
93}
94
95#[derive(Clone)]
97pub struct MD037NoSpaceInEmphasis;
98
99impl Default for MD037NoSpaceInEmphasis {
100 fn default() -> Self {
101 Self
102 }
103}
104
105impl MD037NoSpaceInEmphasis {
106 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
108 for link in &ctx.links {
110 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
111 if link.is_reference && link.url.is_empty() {
112 continue;
113 }
114 return true;
115 }
116 }
117
118 for image in &ctx.images {
120 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
121 return true;
122 }
123 }
124
125 ctx.is_in_reference_def(byte_pos)
127 }
128}
129
130impl Rule for MD037NoSpaceInEmphasis {
131 fn name(&self) -> &'static str {
132 "MD037"
133 }
134
135 fn description(&self) -> &'static str {
136 "Spaces inside emphasis markers"
137 }
138
139 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
140 let content = ctx.content;
141 let _timer = crate::profiling::ScopedTimer::new("MD037_check");
142
143 if !content.contains('*') && !content.contains('_') {
145 return Ok(vec![]);
146 }
147
148 let line_index = &ctx.line_index;
150
151 let mut warnings = Vec::new();
152 let table_lines = table_line_flags(ctx);
153
154 for line in ctx
158 .filtered_lines()
159 .skip_front_matter()
160 .skip_code_blocks()
161 .skip_math_blocks()
162 .skip_html_blocks()
163 .skip_jsx_expressions()
164 .skip_mdx_comments()
165 .skip_obsidian_comments()
166 .skip_mkdocstrings()
167 {
168 if !line.content.contains('*') && !line.content.contains('_') {
170 continue;
171 }
172
173 if table_lines.get(line.line_num - 1).copied().unwrap_or(false) {
174 for cell in table_cell_ranges(line.content) {
179 let Some(cell_content) = line.content.get(cell.clone()) else {
180 continue;
181 };
182 if !cell_content.contains('*') && !cell_content.contains('_') {
183 continue;
184 }
185 if has_doc_patterns(cell_content) {
186 continue;
187 }
188 self.check_line_content_for_emphasis_fast(cell_content, line.line_num, cell.start, &mut warnings);
189 }
190 continue;
191 }
192
193 self.check_line_for_emphasis_issues_fast(line.content, line.line_num, &mut warnings);
195 }
196
197 let mut filtered_warnings = Vec::new();
199 let lines = ctx.raw_lines();
200
201 for (line_idx, line) in lines.iter().enumerate() {
202 let line_num = line_idx + 1;
203 let line_start_pos = line_index.get_line_start_byte(line_num).unwrap_or(0);
204
205 for warning in &warnings {
207 if warning.line == line_num {
208 let byte_pos = line_start_pos + (warning.column - 1);
212 let line_pos = warning.column - 1;
214 let char_col = byte_to_char_count(line, warning.column - 1);
215
216 let in_pandoc_construct = ctx.flavor.is_pandoc_compatible() && ctx.is_in_bracketed_span(byte_pos);
224 if !in_pandoc_construct
225 && !self.is_in_link(ctx, byte_pos)
226 && !ctx.is_in_html_comment(byte_pos)
227 && !is_in_math_context(ctx, byte_pos)
228 && !ctx.is_in_code_span(line_num, char_col)
229 && !is_in_inline_html_code(line, line_pos)
230 && !is_in_jsx_expression(ctx, byte_pos)
231 && !is_in_mdx_comment(ctx, byte_pos)
232 && !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
233 && !ctx.is_position_in_obsidian_comment(line_num, char_col)
234 {
235 let mut adjusted_warning = warning.clone();
236 adjusted_warning.column = char_col;
238 adjusted_warning.end_column = byte_to_char_count(line, warning.end_column - 1);
239 if let Some(fix) = &mut adjusted_warning.fix {
240 let abs_start = line_start_pos + fix.range.start;
242 let abs_end = line_start_pos + fix.range.end;
243 fix.range = abs_start..abs_end;
244 }
245 filtered_warnings.push(adjusted_warning);
246 }
247 }
248 }
249 }
250
251 Ok(filtered_warnings)
252 }
253
254 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
255 let content = ctx.content;
256 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
257
258 if !content.contains('*') && !content.contains('_') {
260 return Ok(content.to_string());
261 }
262
263 let warnings = self.check(ctx)?;
265 let warnings =
266 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
267
268 if warnings.is_empty() {
270 return Ok(content.to_string());
271 }
272
273 let mut result = content.to_string();
275 let mut offset: isize = 0;
276
277 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
279 sorted_warnings.sort_by_key(|w| (w.line, w.column));
280
281 for warning in sorted_warnings {
282 if let Some(fix) = &warning.fix {
283 let actual_start = (fix.range.start as isize + offset) as usize;
285 let actual_end = (fix.range.end as isize + offset) as usize;
286
287 if actual_start < result.len() && actual_end <= result.len() {
289 result.replace_range(actual_start..actual_end, &fix.replacement);
291 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
293 }
294 }
295 }
296
297 Ok(result)
298 }
299
300 fn category(&self) -> RuleCategory {
302 RuleCategory::Emphasis
303 }
304
305 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
307 ctx.content.is_empty() || !ctx.likely_has_emphasis()
308 }
309
310 fn as_any(&self) -> &dyn std::any::Any {
311 self
312 }
313
314 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
315 where
316 Self: Sized,
317 {
318 Box::new(MD037NoSpaceInEmphasis)
319 }
320}
321
322impl MD037NoSpaceInEmphasis {
323 #[inline]
325 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
326 if has_doc_patterns(line) {
328 return;
329 }
330
331 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
336 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
337 {
338 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
339 && let Some(full_match) = caps.get(0)
340 {
341 let list_marker_end = full_match.end();
342 if list_marker_end < line.len() {
343 let remaining_content = &line[list_marker_end..];
344
345 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
348 }
349 }
350 return;
351 }
352
353 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
355 }
356
357 fn check_line_content_for_emphasis_fast(
359 &self,
360 content: &str,
361 line_num: usize,
362 offset: usize,
363 warnings: &mut Vec<LintWarning>,
364 ) {
365 let processed_content = replace_inline_code(content);
368 let processed_content = replace_inline_math(&processed_content);
369
370 let markers = find_emphasis_markers(&processed_content);
372 if markers.is_empty() {
373 return;
374 }
375
376 let spans = find_emphasis_spans(&processed_content, &markers);
378
379 let valid_ranges = find_valid_emphasis_ranges(&processed_content, &markers);
384
385 for span in spans {
387 if has_spacing_issues(&span) {
388 let full_start = span.opening.start_pos;
389 let full_end = span.closing.end_pos();
390
391 if valid_ranges
393 .iter()
394 .any(|&(start, end)| start <= full_start && full_end <= end)
395 {
396 continue;
397 }
398
399 let full_text = &content[full_start..full_end];
400
401 if full_end < content.len() {
404 let remaining = &content[full_end..];
405 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
407 continue;
408 }
409 }
410
411 let marker_char = span.opening.as_char();
413 let marker_str = if span.opening.count == 1 {
414 marker_char.to_string()
415 } else {
416 format!("{marker_char}{marker_char}")
417 };
418
419 let original_content = &content[span.opening.end_pos()..span.closing.start_pos];
426 let trimmed_content = original_content.trim();
427 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
428
429 let display_text = truncate_for_display(full_text, 60);
431
432 let warning = LintWarning {
433 rule_name: Some(self.name().to_string()),
434 message: format!("Spaces inside emphasis markers: {display_text:?}"),
435 line: line_num,
439 column: offset + full_start + 1,
440 end_line: line_num,
441 end_column: offset + full_end + 1,
442 severity: Severity::Warning,
443 fix: Some(Fix::new((offset + full_start)..(offset + full_end), fixed_text)),
444 };
445
446 warnings.push(warning);
447 }
448 }
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455 use crate::lint_context::LintContext;
456
457 #[test]
458 fn table_cell_ranges_unmasked_scan_matches_masked_scan() {
459 fn always_masked(line: &str) -> Vec<Range<usize>> {
462 let escaped = TableUtils::mask_pipes_for_table_parsing(line);
463 let masked = TableUtils::mask_pipes_in_inline_code(&escaped);
464 let mut ranges = Vec::new();
465 let mut start = 0;
466 for (pipe_pos, _) in masked.match_indices('|') {
467 ranges.push(start..pipe_pos);
468 start = pipe_pos + 1;
469 }
470 ranges.push(start..line.len());
471 ranges
472 }
473
474 for line in [
475 "| a | b |",
476 "| a * x * | b |",
477 "a | b",
478 "no pipes at all",
479 "",
480 "|||",
481 "| naïve ünïcode | 日本語 |",
482 r"| a \| b | c |",
483 "| `a | b` | c |",
484 r"| `a \| b` | c |",
485 r"| a \\ | b |",
486 "> | a * x * | b |",
487 ] {
488 assert_eq!(table_cell_ranges(line), always_masked(line), "line: {line:?}");
489 }
490 }
491
492 #[test]
493 fn test_emphasis_marker_parsing() {
494 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
495 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
498 assert_eq!(markers.len(), 4); }
500
501 #[test]
502 fn test_emphasis_span_detection() {
503 let markers = find_emphasis_markers("This has *valid* emphasis");
504 let spans = find_emphasis_spans("This has *valid* emphasis", &markers);
505 assert_eq!(spans.len(), 1);
506 assert_eq!(spans[0].content, "valid");
507 assert!(!spans[0].has_leading_space);
508 assert!(!spans[0].has_trailing_space);
509
510 let markers = find_emphasis_markers("This has * invalid * emphasis");
511 let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
512 assert_eq!(spans.len(), 1);
513 assert_eq!(spans[0].content, " invalid ");
514 assert!(spans[0].has_leading_space);
515 assert!(spans[0].has_trailing_space);
516 }
517
518 #[test]
519 fn test_with_document_structure() {
520 let rule = MD037NoSpaceInEmphasis;
521
522 let content = "This is *correct* emphasis and **strong emphasis**";
524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525 let result = rule.check(&ctx).unwrap();
526 assert!(result.is_empty(), "No warnings expected for correct emphasis");
527
528 let content = "This is * text with spaces * and more content";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let result = rule.check(&ctx).unwrap();
532 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
533
534 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538 assert!(
539 !result.is_empty(),
540 "Expected warnings for spaces in emphasis outside code block"
541 );
542 }
543
544 #[test]
545 fn test_inline_code_inside_spaced_emphasis_preserved_on_fix() {
546 let rule = MD037NoSpaceInEmphasis;
550 let content = "Set * the `id` field * below.";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let fixed = rule.fix(&ctx).unwrap();
553 assert_eq!(fixed, "Set *the `id` field* below.");
554 assert!(!fixed.contains('X'), "masking placeholder leaked into fix: {fixed}");
555 }
556
557 #[test]
558 fn test_emphasis_in_links_not_flagged() {
559 let rule = MD037NoSpaceInEmphasis;
560 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
561
562This has * real spaced emphasis * that should be flagged."#;
563 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565
566 assert_eq!(
570 result.len(),
571 1,
572 "Expected exactly 1 warning, but got: {:?}",
573 result.len()
574 );
575 assert!(result[0].message.contains("Spaces inside emphasis markers"));
576 assert!(result[0].line == 3); }
579
580 #[test]
581 fn test_emphasis_in_links_vs_outside_links() {
582 let rule = MD037NoSpaceInEmphasis;
583 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
584
585[* link *]: https://example.com/*path*"#;
586 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588
589 assert_eq!(result.len(), 1);
591 assert!(result[0].message.contains("Spaces inside emphasis markers"));
592 assert!(result[0].line == 1);
594 }
595
596 #[test]
597 fn test_issue_49_asterisk_in_inline_code() {
598 let rule = MD037NoSpaceInEmphasis;
600
601 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604 let result = rule.check(&ctx).unwrap();
605 assert!(
606 result.is_empty(),
607 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
608 );
609 }
610
611 #[test]
612 fn test_issue_28_inline_code_in_emphasis() {
613 let rule = MD037NoSpaceInEmphasis;
615
616 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.";
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 inline code inside emphasis as spaces (issue #28). Got: {result:?}"
623 );
624
625 let content2 = "The **`foo` and `bar`** methods are important.";
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 multiple inline code snippets inside emphasis. Got: {result2:?}"
632 );
633
634 let content3 = "This is __inline `code`__ with underscores.";
636 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
637 let result3 = rule.check(&ctx3).unwrap();
638 assert!(
639 result3.is_empty(),
640 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
641 );
642
643 let content4 = "This is *inline `test`* with single asterisks.";
645 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
646 let result4 = rule.check(&ctx4).unwrap();
647 assert!(
648 result4.is_empty(),
649 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
650 );
651
652 let content5 = "This has * real spaces * that should be flagged.";
654 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
655 let result5 = rule.check(&ctx5).unwrap();
656 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
657 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
658 }
659
660 #[test]
661 fn test_multibyte_utf8_no_panic() {
662 let rule = MD037NoSpaceInEmphasis;
666
667 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
669 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
670 let result = rule.check(&ctx);
671 assert!(result.is_ok(), "Greek text should not panic");
672
673 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
675 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
676 let result = rule.check(&ctx);
677 assert!(result.is_ok(), "Chinese text should not panic");
678
679 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
681 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
682 let result = rule.check(&ctx);
683 assert!(result.is_ok(), "Cyrillic text should not panic");
684
685 let mixed =
687 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
688 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.check(&ctx);
690 assert!(result.is_ok(), "Mixed CJK text should not panic");
691
692 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
694 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx);
696 assert!(result.is_ok(), "Arabic text should not panic");
697
698 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
700 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx);
702 assert!(result.is_ok(), "Emoji text should not panic");
703 }
704
705 #[test]
706 fn test_template_shortcode_syntax_not_flagged() {
707 let rule = MD037NoSpaceInEmphasis;
710
711 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx).unwrap();
715 assert!(
716 result.is_empty(),
717 "Template shortcode syntax should not be flagged. Got: {result:?}"
718 );
719
720 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx).unwrap();
724 assert!(
725 result.is_empty(),
726 "Template shortcode syntax should not be flagged. Got: {result:?}"
727 );
728
729 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.check(&ctx).unwrap();
733 assert!(
734 result.is_empty(),
735 "Multiple template shortcodes should not be flagged. Got: {result:?}"
736 );
737
738 let content = "This has * real spaced emphasis * here.";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let result = rule.check(&ctx).unwrap();
742 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
743 }
744
745 #[test]
746 fn test_multiline_code_span_not_flagged() {
747 let rule = MD037NoSpaceInEmphasis;
750
751 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";
753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754 let result = rule.check(&ctx).unwrap();
755 assert!(
756 result.is_empty(),
757 "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
758 );
759
760 let content2 = "Text with `code that\nspans * multiple * lines` here.";
762 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
763 let result2 = rule.check(&ctx2).unwrap();
764 assert!(
765 result2.is_empty(),
766 "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
767 );
768 }
769
770 #[test]
771 fn test_html_block_asterisks_not_flagged() {
772 let rule = MD037NoSpaceInEmphasis;
773
774 let content = r#"<table>
776<tr><td>Format</td><td>Size</td></tr>
777<tr><td>BC1</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 8</code></td></tr>
778<tr><td>BC2</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 16</code></td></tr>
779</table>"#;
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782 assert!(
783 result.is_empty(),
784 "Should not flag asterisks inside HTML blocks. Got: {result:?}"
785 );
786
787 let content2 = "<div>\n<p>Value is * something * here</p>\n</div>";
789 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
790 let result2 = rule.check(&ctx2).unwrap();
791 assert!(
792 result2.is_empty(),
793 "Should not flag emphasis-like patterns inside HTML div blocks. Got: {result2:?}"
794 );
795
796 let content3 = "Regular * spaced emphasis * text\n\n<div>* not emphasis *</div>";
798 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
799 let result3 = rule.check(&ctx3).unwrap();
800 assert_eq!(
801 result3.len(),
802 1,
803 "Should flag spaced emphasis in regular markdown but not inside HTML blocks. Got: {result3:?}"
804 );
805 assert_eq!(result3[0].line, 1, "Warning should be on line 1 (regular markdown)");
806 }
807
808 #[test]
809 fn test_mkdocs_icon_shortcode_not_flagged() {
810 let rule = MD037NoSpaceInEmphasis;
812
813 let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
817 let result = rule.check(&ctx).unwrap();
818 assert!(
819 result.is_empty(),
820 "Should not flag MkDocs icon shortcodes. Got: {result:?}"
821 );
822
823 let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
825 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
826 let result2 = rule.check(&ctx2).unwrap();
827 assert!(
828 !result2.is_empty(),
829 "Should still flag real spaced emphasis in MkDocs mode"
830 );
831 }
832
833 #[test]
834 fn test_mkdocs_pymdown_markup_not_flagged() {
835 let rule = MD037NoSpaceInEmphasis;
837
838 let content = "Press ++ctrl+c++ to copy.";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
841 let result = rule.check(&ctx).unwrap();
842 assert!(
843 result.is_empty(),
844 "Should not flag PyMdown Keys notation. Got: {result:?}"
845 );
846
847 let content2 = "This is ==highlighted text== for emphasis.";
849 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
850 let result2 = rule.check(&ctx2).unwrap();
851 assert!(
852 result2.is_empty(),
853 "Should not flag PyMdown Mark notation. Got: {result2:?}"
854 );
855
856 let content3 = "This is ^^inserted text^^ here.";
858 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
859 let result3 = rule.check(&ctx3).unwrap();
860 assert!(
861 result3.is_empty(),
862 "Should not flag PyMdown Insert notation. Got: {result3:?}"
863 );
864
865 let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
867 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
868 let result4 = rule.check(&ctx4).unwrap();
869 assert!(
870 !result4.is_empty(),
871 "Should still flag real spaced emphasis alongside PyMdown markup"
872 );
873 }
874
875 #[test]
878 fn test_obsidian_highlight_not_flagged() {
879 let rule = MD037NoSpaceInEmphasis;
881
882 let content = "This is ==highlighted text== here.";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
885 let result = rule.check(&ctx).unwrap();
886 assert!(
887 result.is_empty(),
888 "Should not flag Obsidian highlight syntax. Got: {result:?}"
889 );
890 }
891
892 #[test]
893 fn test_obsidian_highlight_multiple_on_line() {
894 let rule = MD037NoSpaceInEmphasis;
896
897 let content = "Both ==one== and ==two== are highlighted.";
898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
899 let result = rule.check(&ctx).unwrap();
900 assert!(
901 result.is_empty(),
902 "Should not flag multiple Obsidian highlights. Got: {result:?}"
903 );
904 }
905
906 #[test]
907 fn test_obsidian_highlight_entire_paragraph() {
908 let rule = MD037NoSpaceInEmphasis;
910
911 let content = "==Entire paragraph highlighted==";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
913 let result = rule.check(&ctx).unwrap();
914 assert!(
915 result.is_empty(),
916 "Should not flag entire highlighted paragraph. Got: {result:?}"
917 );
918 }
919
920 #[test]
921 fn test_obsidian_highlight_with_emphasis() {
922 let rule = MD037NoSpaceInEmphasis;
924
925 let content = "**==bold highlight==**";
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
928 let result = rule.check(&ctx).unwrap();
929 assert!(
930 result.is_empty(),
931 "Should not flag bold highlight combination. Got: {result:?}"
932 );
933
934 let content2 = "*==italic highlight==*";
936 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Obsidian, None);
937 let result2 = rule.check(&ctx2).unwrap();
938 assert!(
939 result2.is_empty(),
940 "Should not flag italic highlight combination. Got: {result2:?}"
941 );
942 }
943
944 #[test]
945 fn test_obsidian_highlight_in_lists() {
946 let rule = MD037NoSpaceInEmphasis;
948
949 let content = "- Item with ==highlight== text\n- Another ==highlighted== item";
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
951 let result = rule.check(&ctx).unwrap();
952 assert!(
953 result.is_empty(),
954 "Should not flag highlights in list items. Got: {result:?}"
955 );
956 }
957
958 #[test]
959 fn test_obsidian_highlight_in_blockquote() {
960 let rule = MD037NoSpaceInEmphasis;
962
963 let content = "> This quote has ==highlighted== text.";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
965 let result = rule.check(&ctx).unwrap();
966 assert!(
967 result.is_empty(),
968 "Should not flag highlights in blockquotes. Got: {result:?}"
969 );
970 }
971
972 #[test]
973 fn test_obsidian_highlight_in_tables() {
974 let rule = MD037NoSpaceInEmphasis;
976
977 let content = "| Header | Column |\n|--------|--------|\n| ==highlighted== | text |";
978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
979 let result = rule.check(&ctx).unwrap();
980 assert!(
981 result.is_empty(),
982 "Should not flag highlights in tables. Got: {result:?}"
983 );
984 }
985
986 #[test]
987 fn test_obsidian_highlight_in_code_blocks_ignored() {
988 let rule = MD037NoSpaceInEmphasis;
990
991 let content = "```\n==not highlight in code==\n```";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
993 let result = rule.check(&ctx).unwrap();
994 assert!(
995 result.is_empty(),
996 "Should ignore highlights in code blocks. Got: {result:?}"
997 );
998 }
999
1000 #[test]
1001 fn test_obsidian_highlight_edge_case_three_equals() {
1002 let rule = MD037NoSpaceInEmphasis;
1004
1005 let content = "Test === something === here";
1007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1008 let result = rule.check(&ctx).unwrap();
1009 let _ = result;
1012 }
1013
1014 #[test]
1015 fn test_obsidian_highlight_edge_case_four_equals() {
1016 let rule = MD037NoSpaceInEmphasis;
1018
1019 let content = "Test ==== here";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1021 let result = rule.check(&ctx).unwrap();
1022 let _ = result;
1024 }
1025
1026 #[test]
1027 fn test_obsidian_highlight_adjacent() {
1028 let rule = MD037NoSpaceInEmphasis;
1030
1031 let content = "==one====two==";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1033 let result = rule.check(&ctx).unwrap();
1034 let _ = result;
1036 }
1037
1038 #[test]
1039 fn test_obsidian_highlight_with_special_chars() {
1040 let rule = MD037NoSpaceInEmphasis;
1042
1043 let content = "Test ==code: `test`== here";
1045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1046 let result = rule.check(&ctx).unwrap();
1047 let _ = result;
1049 }
1050
1051 #[test]
1052 fn test_obsidian_highlight_unclosed() {
1053 let rule = MD037NoSpaceInEmphasis;
1055
1056 let content = "This ==starts but never ends";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1058 let result = rule.check(&ctx).unwrap();
1059 let _ = result;
1061 }
1062
1063 #[test]
1064 fn test_obsidian_highlight_still_flags_real_emphasis_issues() {
1065 let rule = MD037NoSpaceInEmphasis;
1067
1068 let content = "This has * spaced emphasis * and ==valid highlight==";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1070 let result = rule.check(&ctx).unwrap();
1071 assert!(
1072 !result.is_empty(),
1073 "Should still flag real spaced emphasis in Obsidian mode"
1074 );
1075 assert!(
1076 result.len() == 1,
1077 "Should flag exactly one issue (the spaced emphasis). Got: {result:?}"
1078 );
1079 }
1080
1081 #[test]
1082 fn test_standard_flavor_does_not_recognize_highlight() {
1083 let rule = MD037NoSpaceInEmphasis;
1086
1087 let content = "This is ==highlighted text== here.";
1088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1089 let result = rule.check(&ctx).unwrap();
1090 let _ = result; }
1095
1096 #[test]
1097 fn test_obsidian_highlight_mixed_with_regular_emphasis() {
1098 let rule = MD037NoSpaceInEmphasis;
1100
1101 let content = "==highlighted== and *italic* and **bold** text";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1103 let result = rule.check(&ctx).unwrap();
1104 assert!(
1105 result.is_empty(),
1106 "Should not flag valid highlight and emphasis. Got: {result:?}"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_obsidian_highlight_unicode() {
1112 let rule = MD037NoSpaceInEmphasis;
1114
1115 let content = "Text ==日本語 highlighted== here";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1117 let result = rule.check(&ctx).unwrap();
1118 assert!(
1119 result.is_empty(),
1120 "Should handle Unicode in highlights. Got: {result:?}"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_obsidian_highlight_with_html() {
1126 let rule = MD037NoSpaceInEmphasis;
1128
1129 let content = "<!-- ==not highlight in comment== --> ==actual highlight==";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1131 let result = rule.check(&ctx).unwrap();
1132 let _ = result;
1134 }
1135
1136 #[test]
1137 fn test_obsidian_inline_comment_emphasis_ignored() {
1138 let rule = MD037NoSpaceInEmphasis;
1140
1141 let content = "Visible %%* spaced emphasis *%% still visible.";
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1143 let result = rule.check(&ctx).unwrap();
1144
1145 assert!(
1146 result.is_empty(),
1147 "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
1148 );
1149 }
1150
1151 #[test]
1152 fn test_inline_html_code_not_flagged() {
1153 let rule = MD037NoSpaceInEmphasis;
1154
1155 let content = "The formula is <code>a * b * c</code> in math.";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159 assert!(
1160 result.is_empty(),
1161 "Should not flag asterisks inside inline <code> tags. Got: {result:?}"
1162 );
1163
1164 let content2 = "Use <kbd>Ctrl * A</kbd> and <samp>x * y</samp> here.";
1166 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1167 let result2 = rule.check(&ctx2).unwrap();
1168 assert!(
1169 result2.is_empty(),
1170 "Should not flag asterisks inside inline <kbd> and <samp> tags. Got: {result2:?}"
1171 );
1172
1173 let content3 = r#"Result: <code class="math">a * b</code> done."#;
1175 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1176 let result3 = rule.check(&ctx3).unwrap();
1177 assert!(
1178 result3.is_empty(),
1179 "Should not flag asterisks inside <code> with attributes. Got: {result3:?}"
1180 );
1181
1182 let content4 = "Text * spaced * and <code>a * b</code>.";
1184 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1185 let result4 = rule.check(&ctx4).unwrap();
1186 assert_eq!(
1187 result4.len(),
1188 1,
1189 "Should flag real spaced emphasis but not code content. Got: {result4:?}"
1190 );
1191 assert_eq!(result4[0].column, 6);
1192 }
1193
1194 #[test]
1197 fn test_pandoc_bracketed_span_guard() {
1198 use crate::config::MarkdownFlavor;
1199 let rule = MD037NoSpaceInEmphasis;
1200 let content = "See [* important *]{.highlight} for details.\n";
1202 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1203 let result = rule.check(&ctx).unwrap();
1204 assert!(
1205 result.is_empty(),
1206 "MD037 should not flag emphasis-like patterns inside Pandoc bracketed spans: {result:?}"
1207 );
1208
1209 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1211 let result_std = rule.check(&ctx_std).unwrap();
1212 assert!(
1213 !result_std.is_empty(),
1214 "MD037 should still flag spaces in emphasis under Standard flavor: {result_std:?}"
1215 );
1216 }
1217
1218 #[test]
1219 fn test_spaced_bold_metadata_pattern_detected() {
1220 let rule = MD037NoSpaceInEmphasis;
1221
1222 let content = "# Test\n\n** Explicit Import**: Convert markdownlint configs to rumdl format:";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226 assert_eq!(
1227 result.len(),
1228 1,
1229 "Should flag '** Explicit Import**' as spaced emphasis. Got: {result:?}"
1230 );
1231 assert_eq!(result[0].line, 3);
1232
1233 let content2 = "# Test\n\n**trailing only **: some text";
1235 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1236 let result2 = rule.check(&ctx2).unwrap();
1237 assert_eq!(
1238 result2.len(),
1239 1,
1240 "Should flag '**trailing only **' as spaced emphasis. Got: {result2:?}"
1241 );
1242
1243 let content3 = "# Test\n\n** both spaces **: some text";
1245 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1246 let result3 = rule.check(&ctx3).unwrap();
1247 assert_eq!(
1248 result3.len(),
1249 1,
1250 "Should flag '** both spaces **' as spaced emphasis. Got: {result3:?}"
1251 );
1252
1253 let content4 = "# Test\n\n**Key**: value";
1255 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1256 let result4 = rule.check(&ctx4).unwrap();
1257 assert!(
1258 result4.is_empty(),
1259 "Should not flag valid bold metadata '**Key**: value'. Got: {result4:?}"
1260 );
1261 }
1262}