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 {
109 if !line.content.contains('*') && !line.content.contains('_') {
111 continue;
112 }
113
114 self.check_line_for_emphasis_issues_fast(line.content, line.line_num, &mut warnings);
116 }
117
118 let mut filtered_warnings = Vec::new();
120 let lines = ctx.raw_lines();
121
122 for (line_idx, line) in lines.iter().enumerate() {
123 let line_num = line_idx + 1;
124 let line_start_pos = line_index.get_line_start_byte(line_num).unwrap_or(0);
125
126 for warning in &warnings {
128 if warning.line == line_num {
129 let byte_pos = line_start_pos + (warning.column - 1);
131 let line_pos = warning.column - 1;
133
134 if !self.is_in_link(ctx, byte_pos)
137 && !is_in_html_comment(content, byte_pos)
138 && !is_in_math_context(ctx, byte_pos)
139 && !is_in_table_cell(ctx, line_num, warning.column)
140 && !ctx.is_in_code_span(line_num, warning.column)
141 && !is_in_inline_html_code(line, line_pos)
142 && !is_in_jsx_expression(ctx, byte_pos)
143 && !is_in_mdx_comment(ctx, byte_pos)
144 && !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
145 && !ctx.is_position_in_obsidian_comment(line_num, warning.column)
146 {
147 let mut adjusted_warning = warning.clone();
148 if let Some(fix) = &mut adjusted_warning.fix {
149 let abs_start = line_start_pos + fix.range.start;
151 let abs_end = line_start_pos + fix.range.end;
152 fix.range = abs_start..abs_end;
153 }
154 filtered_warnings.push(adjusted_warning);
155 }
156 }
157 }
158 }
159
160 Ok(filtered_warnings)
161 }
162
163 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
164 let content = ctx.content;
165 let _timer = crate::profiling::ScopedTimer::new("MD037_fix");
166
167 if !content.contains('*') && !content.contains('_') {
169 return Ok(content.to_string());
170 }
171
172 let warnings = self.check(ctx)?;
174 let warnings =
175 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
176
177 if warnings.is_empty() {
179 return Ok(content.to_string());
180 }
181
182 let mut result = content.to_string();
184 let mut offset: isize = 0;
185
186 let mut sorted_warnings: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
188 sorted_warnings.sort_by_key(|w| (w.line, w.column));
189
190 for warning in sorted_warnings {
191 if let Some(fix) = &warning.fix {
192 let actual_start = (fix.range.start as isize + offset) as usize;
194 let actual_end = (fix.range.end as isize + offset) as usize;
195
196 if actual_start < result.len() && actual_end <= result.len() {
198 result.replace_range(actual_start..actual_end, &fix.replacement);
200 offset += fix.replacement.len() as isize - (fix.range.end - fix.range.start) as isize;
202 }
203 }
204 }
205
206 Ok(result)
207 }
208
209 fn category(&self) -> RuleCategory {
211 RuleCategory::Emphasis
212 }
213
214 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
216 ctx.content.is_empty() || !ctx.likely_has_emphasis()
217 }
218
219 fn as_any(&self) -> &dyn std::any::Any {
220 self
221 }
222
223 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
224 where
225 Self: Sized,
226 {
227 Box::new(MD037NoSpaceInEmphasis)
228 }
229}
230
231impl MD037NoSpaceInEmphasis {
232 #[inline]
234 fn check_line_for_emphasis_issues_fast(&self, line: &str, line_num: usize, warnings: &mut Vec<LintWarning>) {
235 if has_doc_patterns(line) {
237 return;
238 }
239
240 if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
245 && UNORDERED_LIST_MARKER_REGEX.is_match(line)
246 {
247 if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
248 && let Some(full_match) = caps.get(0)
249 {
250 let list_marker_end = full_match.end();
251 if list_marker_end < line.len() {
252 let remaining_content = &line[list_marker_end..];
253
254 self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
257 }
258 }
259 return;
260 }
261
262 self.check_line_content_for_emphasis_fast(line, line_num, 0, warnings);
264 }
265
266 fn check_line_content_for_emphasis_fast(
268 &self,
269 content: &str,
270 line_num: usize,
271 offset: usize,
272 warnings: &mut Vec<LintWarning>,
273 ) {
274 let processed_content = replace_inline_code(content);
277 let processed_content = replace_inline_math(&processed_content);
278
279 let markers = find_emphasis_markers(&processed_content);
281 if markers.is_empty() {
282 return;
283 }
284
285 let spans = find_emphasis_spans(&processed_content, markers);
287
288 for span in spans {
290 if has_spacing_issues(&span) {
291 let full_start = span.opening.start_pos;
293 let full_end = span.closing.end_pos();
294 let full_text = &content[full_start..full_end];
295
296 if full_end < content.len() {
299 let remaining = &content[full_end..];
300 if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
302 continue;
303 }
304 }
305
306 let marker_char = span.opening.as_char();
308 let marker_str = if span.opening.count == 1 {
309 marker_char.to_string()
310 } else {
311 format!("{marker_char}{marker_char}")
312 };
313
314 let trimmed_content = span.content.trim();
316 let fixed_text = format!("{marker_str}{trimmed_content}{marker_str}");
317
318 let display_text = truncate_for_display(full_text, 60);
320
321 let warning = LintWarning {
322 rule_name: Some(self.name().to_string()),
323 message: format!("Spaces inside emphasis markers: {display_text:?}"),
324 line: line_num,
325 column: offset + full_start + 1, end_line: line_num,
327 end_column: offset + full_end + 1,
328 severity: Severity::Warning,
329 fix: Some(Fix {
330 range: (offset + full_start)..(offset + full_end),
331 replacement: fixed_text,
332 }),
333 };
334
335 warnings.push(warning);
336 }
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::lint_context::LintContext;
345
346 #[test]
347 fn test_emphasis_marker_parsing() {
348 let markers = find_emphasis_markers("This has *single* and **double** emphasis");
349 assert_eq!(markers.len(), 4); let markers = find_emphasis_markers("*start* and *end*");
352 assert_eq!(markers.len(), 4); }
354
355 #[test]
356 fn test_emphasis_span_detection() {
357 let markers = find_emphasis_markers("This has *valid* emphasis");
358 let spans = find_emphasis_spans("This has *valid* emphasis", markers);
359 assert_eq!(spans.len(), 1);
360 assert_eq!(spans[0].content, "valid");
361 assert!(!spans[0].has_leading_space);
362 assert!(!spans[0].has_trailing_space);
363
364 let markers = find_emphasis_markers("This has * invalid * emphasis");
365 let spans = find_emphasis_spans("This has * invalid * emphasis", markers);
366 assert_eq!(spans.len(), 1);
367 assert_eq!(spans[0].content, " invalid ");
368 assert!(spans[0].has_leading_space);
369 assert!(spans[0].has_trailing_space);
370 }
371
372 #[test]
373 fn test_with_document_structure() {
374 let rule = MD037NoSpaceInEmphasis;
375
376 let content = "This is *correct* emphasis and **strong emphasis**";
378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379 let result = rule.check(&ctx).unwrap();
380 assert!(result.is_empty(), "No warnings expected for correct emphasis");
381
382 let content = "This is * text with spaces * and more content";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385 let result = rule.check(&ctx).unwrap();
386 assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
387
388 let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
391 let result = rule.check(&ctx).unwrap();
392 assert!(
393 !result.is_empty(),
394 "Expected warnings for spaces in emphasis outside code block"
395 );
396 }
397
398 #[test]
399 fn test_emphasis_in_links_not_flagged() {
400 let rule = MD037NoSpaceInEmphasis;
401 let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
402
403This has * real spaced emphasis * that should be flagged."#;
404 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
405 let result = rule.check(&ctx).unwrap();
406
407 assert_eq!(
411 result.len(),
412 1,
413 "Expected exactly 1 warning, but got: {:?}",
414 result.len()
415 );
416 assert!(result[0].message.contains("Spaces inside emphasis markers"));
417 assert!(result[0].line == 3); }
420
421 #[test]
422 fn test_emphasis_in_links_vs_outside_links() {
423 let rule = MD037NoSpaceInEmphasis;
424 let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
425
426[* link *]: https://example.com/*path*"#;
427 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428 let result = rule.check(&ctx).unwrap();
429
430 assert_eq!(result.len(), 1);
432 assert!(result[0].message.contains("Spaces inside emphasis markers"));
433 assert!(result[0].line == 1);
435 }
436
437 #[test]
438 fn test_issue_49_asterisk_in_inline_code() {
439 let rule = MD037NoSpaceInEmphasis;
441
442 let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445 let result = rule.check(&ctx).unwrap();
446 assert!(
447 result.is_empty(),
448 "Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
449 );
450 }
451
452 #[test]
453 fn test_issue_28_inline_code_in_emphasis() {
454 let rule = MD037NoSpaceInEmphasis;
456
457 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.";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461 assert!(
462 result.is_empty(),
463 "Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
464 );
465
466 let content2 = "The **`foo` and `bar`** methods are important.";
468 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
469 let result2 = rule.check(&ctx2).unwrap();
470 assert!(
471 result2.is_empty(),
472 "Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
473 );
474
475 let content3 = "This is __inline `code`__ with underscores.";
477 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
478 let result3 = rule.check(&ctx3).unwrap();
479 assert!(
480 result3.is_empty(),
481 "Should not flag inline code with underscore emphasis. Got: {result3:?}"
482 );
483
484 let content4 = "This is *inline `test`* with single asterisks.";
486 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
487 let result4 = rule.check(&ctx4).unwrap();
488 assert!(
489 result4.is_empty(),
490 "Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
491 );
492
493 let content5 = "This has * real spaces * that should be flagged.";
495 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
496 let result5 = rule.check(&ctx5).unwrap();
497 assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
498 assert!(result5[0].message.contains("Spaces inside emphasis markers"));
499 }
500
501 #[test]
502 fn test_multibyte_utf8_no_panic() {
503 let rule = MD037NoSpaceInEmphasis;
507
508 let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
510 let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
511 let result = rule.check(&ctx);
512 assert!(result.is_ok(), "Greek text should not panic");
513
514 let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
516 let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
517 let result = rule.check(&ctx);
518 assert!(result.is_ok(), "Chinese text should not panic");
519
520 let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
522 let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
523 let result = rule.check(&ctx);
524 assert!(result.is_ok(), "Cyrillic text should not panic");
525
526 let mixed =
528 "日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
529 let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
530 let result = rule.check(&ctx);
531 assert!(result.is_ok(), "Mixed CJK text should not panic");
532
533 let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
535 let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
536 let result = rule.check(&ctx);
537 assert!(result.is_ok(), "Arabic text should not panic");
538
539 let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
541 let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx);
543 assert!(result.is_ok(), "Emoji text should not panic");
544 }
545
546 #[test]
547 fn test_template_shortcode_syntax_not_flagged() {
548 let rule = MD037NoSpaceInEmphasis;
551
552 let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556 assert!(
557 result.is_empty(),
558 "Template shortcode syntax should not be flagged. Got: {result:?}"
559 );
560
561 let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565 assert!(
566 result.is_empty(),
567 "Template shortcode syntax should not be flagged. Got: {result:?}"
568 );
569
570 let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574 assert!(
575 result.is_empty(),
576 "Multiple template shortcodes should not be flagged. Got: {result:?}"
577 );
578
579 let content = "This has * real spaced emphasis * here.";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
584 }
585
586 #[test]
587 fn test_multiline_code_span_not_flagged() {
588 let rule = MD037NoSpaceInEmphasis;
591
592 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";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let result = rule.check(&ctx).unwrap();
596 assert!(
597 result.is_empty(),
598 "Should not flag asterisks inside multi-line code spans. Got: {result:?}"
599 );
600
601 let content2 = "Text with `code that\nspans * multiple * lines` here.";
603 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
604 let result2 = rule.check(&ctx2).unwrap();
605 assert!(
606 result2.is_empty(),
607 "Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
608 );
609 }
610
611 #[test]
612 fn test_html_block_asterisks_not_flagged() {
613 let rule = MD037NoSpaceInEmphasis;
614
615 let content = r#"<table>
617<tr><td>Format</td><td>Size</td></tr>
618<tr><td>BC1</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 8</code></td></tr>
619<tr><td>BC2</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 16</code></td></tr>
620</table>"#;
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622 let result = rule.check(&ctx).unwrap();
623 assert!(
624 result.is_empty(),
625 "Should not flag asterisks inside HTML blocks. Got: {result:?}"
626 );
627
628 let content2 = "<div>\n<p>Value is * something * here</p>\n</div>";
630 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
631 let result2 = rule.check(&ctx2).unwrap();
632 assert!(
633 result2.is_empty(),
634 "Should not flag emphasis-like patterns inside HTML div blocks. Got: {result2:?}"
635 );
636
637 let content3 = "Regular * spaced emphasis * text\n\n<div>* not emphasis *</div>";
639 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
640 let result3 = rule.check(&ctx3).unwrap();
641 assert_eq!(
642 result3.len(),
643 1,
644 "Should flag spaced emphasis in regular markdown but not inside HTML blocks. Got: {result3:?}"
645 );
646 assert_eq!(result3[0].line, 1, "Warning should be on line 1 (regular markdown)");
647 }
648
649 #[test]
650 fn test_mkdocs_icon_shortcode_not_flagged() {
651 let rule = MD037NoSpaceInEmphasis;
653
654 let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
658 let result = rule.check(&ctx).unwrap();
659 assert!(
660 result.is_empty(),
661 "Should not flag MkDocs icon shortcodes. Got: {result:?}"
662 );
663
664 let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
666 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
667 let result2 = rule.check(&ctx2).unwrap();
668 assert!(
669 !result2.is_empty(),
670 "Should still flag real spaced emphasis in MkDocs mode"
671 );
672 }
673
674 #[test]
675 fn test_mkdocs_pymdown_markup_not_flagged() {
676 let rule = MD037NoSpaceInEmphasis;
678
679 let content = "Press ++ctrl+c++ to copy.";
681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
682 let result = rule.check(&ctx).unwrap();
683 assert!(
684 result.is_empty(),
685 "Should not flag PyMdown Keys notation. Got: {result:?}"
686 );
687
688 let content2 = "This is ==highlighted text== for emphasis.";
690 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
691 let result2 = rule.check(&ctx2).unwrap();
692 assert!(
693 result2.is_empty(),
694 "Should not flag PyMdown Mark notation. Got: {result2:?}"
695 );
696
697 let content3 = "This is ^^inserted text^^ here.";
699 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
700 let result3 = rule.check(&ctx3).unwrap();
701 assert!(
702 result3.is_empty(),
703 "Should not flag PyMdown Insert notation. Got: {result3:?}"
704 );
705
706 let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
708 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
709 let result4 = rule.check(&ctx4).unwrap();
710 assert!(
711 !result4.is_empty(),
712 "Should still flag real spaced emphasis alongside PyMdown markup"
713 );
714 }
715
716 #[test]
719 fn test_obsidian_highlight_not_flagged() {
720 let rule = MD037NoSpaceInEmphasis;
722
723 let content = "This is ==highlighted text== here.";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
726 let result = rule.check(&ctx).unwrap();
727 assert!(
728 result.is_empty(),
729 "Should not flag Obsidian highlight syntax. Got: {result:?}"
730 );
731 }
732
733 #[test]
734 fn test_obsidian_highlight_multiple_on_line() {
735 let rule = MD037NoSpaceInEmphasis;
737
738 let content = "Both ==one== and ==two== are highlighted.";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
740 let result = rule.check(&ctx).unwrap();
741 assert!(
742 result.is_empty(),
743 "Should not flag multiple Obsidian highlights. Got: {result:?}"
744 );
745 }
746
747 #[test]
748 fn test_obsidian_highlight_entire_paragraph() {
749 let rule = MD037NoSpaceInEmphasis;
751
752 let content = "==Entire paragraph highlighted==";
753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
754 let result = rule.check(&ctx).unwrap();
755 assert!(
756 result.is_empty(),
757 "Should not flag entire highlighted paragraph. Got: {result:?}"
758 );
759 }
760
761 #[test]
762 fn test_obsidian_highlight_with_emphasis() {
763 let rule = MD037NoSpaceInEmphasis;
765
766 let content = "**==bold highlight==**";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
769 let result = rule.check(&ctx).unwrap();
770 assert!(
771 result.is_empty(),
772 "Should not flag bold highlight combination. Got: {result:?}"
773 );
774
775 let content2 = "*==italic highlight==*";
777 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Obsidian, None);
778 let result2 = rule.check(&ctx2).unwrap();
779 assert!(
780 result2.is_empty(),
781 "Should not flag italic highlight combination. Got: {result2:?}"
782 );
783 }
784
785 #[test]
786 fn test_obsidian_highlight_in_lists() {
787 let rule = MD037NoSpaceInEmphasis;
789
790 let content = "- Item with ==highlight== text\n- Another ==highlighted== item";
791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
792 let result = rule.check(&ctx).unwrap();
793 assert!(
794 result.is_empty(),
795 "Should not flag highlights in list items. Got: {result:?}"
796 );
797 }
798
799 #[test]
800 fn test_obsidian_highlight_in_blockquote() {
801 let rule = MD037NoSpaceInEmphasis;
803
804 let content = "> This quote has ==highlighted== text.";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
806 let result = rule.check(&ctx).unwrap();
807 assert!(
808 result.is_empty(),
809 "Should not flag highlights in blockquotes. Got: {result:?}"
810 );
811 }
812
813 #[test]
814 fn test_obsidian_highlight_in_tables() {
815 let rule = MD037NoSpaceInEmphasis;
817
818 let content = "| Header | Column |\n|--------|--------|\n| ==highlighted== | text |";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
820 let result = rule.check(&ctx).unwrap();
821 assert!(
822 result.is_empty(),
823 "Should not flag highlights in tables. Got: {result:?}"
824 );
825 }
826
827 #[test]
828 fn test_obsidian_highlight_in_code_blocks_ignored() {
829 let rule = MD037NoSpaceInEmphasis;
831
832 let content = "```\n==not highlight in code==\n```";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
834 let result = rule.check(&ctx).unwrap();
835 assert!(
836 result.is_empty(),
837 "Should ignore highlights in code blocks. Got: {result:?}"
838 );
839 }
840
841 #[test]
842 fn test_obsidian_highlight_edge_case_three_equals() {
843 let rule = MD037NoSpaceInEmphasis;
845
846 let content = "Test === something === here";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
849 let result = rule.check(&ctx).unwrap();
850 let _ = result;
853 }
854
855 #[test]
856 fn test_obsidian_highlight_edge_case_four_equals() {
857 let rule = MD037NoSpaceInEmphasis;
859
860 let content = "Test ==== here";
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
862 let result = rule.check(&ctx).unwrap();
863 let _ = result;
865 }
866
867 #[test]
868 fn test_obsidian_highlight_adjacent() {
869 let rule = MD037NoSpaceInEmphasis;
871
872 let content = "==one====two==";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
874 let result = rule.check(&ctx).unwrap();
875 let _ = result;
877 }
878
879 #[test]
880 fn test_obsidian_highlight_with_special_chars() {
881 let rule = MD037NoSpaceInEmphasis;
883
884 let content = "Test ==code: `test`== here";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
887 let result = rule.check(&ctx).unwrap();
888 let _ = result;
890 }
891
892 #[test]
893 fn test_obsidian_highlight_unclosed() {
894 let rule = MD037NoSpaceInEmphasis;
896
897 let content = "This ==starts but never ends";
898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
899 let result = rule.check(&ctx).unwrap();
900 let _ = result;
902 }
903
904 #[test]
905 fn test_obsidian_highlight_still_flags_real_emphasis_issues() {
906 let rule = MD037NoSpaceInEmphasis;
908
909 let content = "This has * spaced emphasis * and ==valid highlight==";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
911 let result = rule.check(&ctx).unwrap();
912 assert!(
913 !result.is_empty(),
914 "Should still flag real spaced emphasis in Obsidian mode"
915 );
916 assert!(
917 result.len() == 1,
918 "Should flag exactly one issue (the spaced emphasis). Got: {result:?}"
919 );
920 }
921
922 #[test]
923 fn test_standard_flavor_does_not_recognize_highlight() {
924 let rule = MD037NoSpaceInEmphasis;
927
928 let content = "This is ==highlighted text== here.";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931 let _ = result; }
936
937 #[test]
938 fn test_obsidian_highlight_mixed_with_regular_emphasis() {
939 let rule = MD037NoSpaceInEmphasis;
941
942 let content = "==highlighted== and *italic* and **bold** text";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
944 let result = rule.check(&ctx).unwrap();
945 assert!(
946 result.is_empty(),
947 "Should not flag valid highlight and emphasis. Got: {result:?}"
948 );
949 }
950
951 #[test]
952 fn test_obsidian_highlight_unicode() {
953 let rule = MD037NoSpaceInEmphasis;
955
956 let content = "Text ==日本語 highlighted== here";
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
958 let result = rule.check(&ctx).unwrap();
959 assert!(
960 result.is_empty(),
961 "Should handle Unicode in highlights. Got: {result:?}"
962 );
963 }
964
965 #[test]
966 fn test_obsidian_highlight_with_html() {
967 let rule = MD037NoSpaceInEmphasis;
969
970 let content = "<!-- ==not highlight in comment== --> ==actual highlight==";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
972 let result = rule.check(&ctx).unwrap();
973 let _ = result;
975 }
976
977 #[test]
978 fn test_obsidian_inline_comment_emphasis_ignored() {
979 let rule = MD037NoSpaceInEmphasis;
981
982 let content = "Visible %%* spaced emphasis *%% still visible.";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
984 let result = rule.check(&ctx).unwrap();
985
986 assert!(
987 result.is_empty(),
988 "Should ignore emphasis inside Obsidian comments. Got: {result:?}"
989 );
990 }
991
992 #[test]
993 fn test_inline_html_code_not_flagged() {
994 let rule = MD037NoSpaceInEmphasis;
995
996 let content = "The formula is <code>a * b * c</code> in math.";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(
1001 result.is_empty(),
1002 "Should not flag asterisks inside inline <code> tags. Got: {result:?}"
1003 );
1004
1005 let content2 = "Use <kbd>Ctrl * A</kbd> and <samp>x * y</samp> here.";
1007 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1008 let result2 = rule.check(&ctx2).unwrap();
1009 assert!(
1010 result2.is_empty(),
1011 "Should not flag asterisks inside inline <kbd> and <samp> tags. Got: {result2:?}"
1012 );
1013
1014 let content3 = r#"Result: <code class="math">a * b</code> done."#;
1016 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1017 let result3 = rule.check(&ctx3).unwrap();
1018 assert!(
1019 result3.is_empty(),
1020 "Should not flag asterisks inside <code> with attributes. Got: {result3:?}"
1021 );
1022
1023 let content4 = "Text * spaced * and <code>a * b</code>.";
1025 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
1026 let result4 = rule.check(&ctx4).unwrap();
1027 assert_eq!(
1028 result4.len(),
1029 1,
1030 "Should flag real spaced emphasis but not code content. Got: {result4:?}"
1031 );
1032 assert_eq!(result4[0].column, 6);
1033 }
1034}