use crate::config::MarkdownFlavor;
use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::emphasis_utils::{
EmphasisEdges, EmphasisMarker, find_emphasis_edges, find_emphasis_markers, find_valid_emphasis_ranges,
has_doc_patterns, replace_inline_code_cow, replace_inline_math_cow,
};
use crate::utils::kramdown_utils::has_span_ial;
use crate::utils::range_utils::byte_to_char_count;
use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
use crate::utils::skip_context::{is_in_inline_html_code, is_in_math_context, is_in_mkdocs_markup};
use crate::utils::table_utils::TableUtils;
use std::ops::Range;
#[inline]
fn has_spacing_issues(span: &EmphasisEdges) -> bool {
span.has_leading_space || span.has_trailing_space
}
fn table_cell_ranges(line: &str, flavor: MarkdownFlavor) -> Vec<Range<usize>> {
let masked;
let scan = if line.contains('\\') || line.contains('`') {
let escaped = TableUtils::mask_escaped_pipes(line, flavor);
masked = TableUtils::mask_pipes_in_inline_code(&escaped);
debug_assert_eq!(
masked.len(),
line.len(),
"pipe masking must preserve byte offsets for cell slicing"
);
masked.as_str()
} else {
line
};
let mut ranges = Vec::new();
let mut start = 0;
for (pipe_pos, _) in scan.match_indices('|') {
ranges.push(start..pipe_pos);
start = pipe_pos + 1;
}
ranges.push(start..line.len());
ranges
}
#[inline]
fn truncate_for_display(text: &str, max_len: usize) -> String {
if text.len() <= max_len {
return text.to_string();
}
let prefix_len = max_len / 2 - 2; let suffix_len = max_len / 2 - 2;
let prefix_end = text.floor_char_boundary(prefix_len.min(text.len()));
let suffix_start = text.floor_char_boundary(text.len().saturating_sub(suffix_len));
format!("{}...{}", &text[..prefix_end], &text[suffix_start..])
}
#[derive(Clone)]
pub struct MD037NoSpaceInEmphasis;
impl Default for MD037NoSpaceInEmphasis {
fn default() -> Self {
Self
}
}
impl MD037NoSpaceInEmphasis {
fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
if ctx.is_range_in_rendered_link(&(byte_pos..byte_pos + 1))
|| ctx
.images()
.iter()
.any(|image| image.byte_offset <= byte_pos && byte_pos < image.byte_end)
{
return true;
}
ctx.is_in_reference_def(byte_pos)
}
fn emphasis_span_ends(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
let mut ends: Vec<(usize, usize)> = ctx
.source_emphasis_spans()
.iter()
.map(|span| (span.byte_end, span.byte_offset))
.collect();
ends.sort_unstable();
ends
}
fn closes_earlier_emphasis(span_ends: &[(usize, usize)], start: usize, end: usize) -> bool {
let first_after_start = span_ends.partition_point(|(span_end, _)| *span_end <= start);
span_ends[first_after_start..]
.iter()
.take_while(|(span_end, _)| *span_end < end)
.any(|(_, span_start)| *span_start <= start)
}
}
impl Rule for MD037NoSpaceInEmphasis {
fn name(&self) -> &'static str {
"MD037"
}
fn description(&self) -> &'static str {
"Spaces inside emphasis markers"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let _timer = crate::profiling::ScopedTimer::new("MD037_check");
let has_html = ctx.has_char('<');
if !ctx.has_char('*') && !ctx.has_char('_') {
return Ok(vec![]);
}
let mut warnings = Vec::new();
for syntax in ctx.lexical_emphasis() {
let line_num = syntax.line_num;
let line_content = syntax.source();
if ctx.lines[line_num - 1].in_table_block {
for cell in table_cell_ranges(line_content, ctx.flavor) {
let Some(cell_content) = line_content.get(cell.clone()) else {
continue;
};
if !cell_content.contains('*') && !cell_content.contains('_') {
continue;
}
if has_doc_patterns(cell_content) {
continue;
}
self.check_line_content_for_emphasis_fast(cell_content, line_num, cell.start, &mut warnings);
}
continue;
}
self.check_line_for_emphasis_issues_fast(syntax, &mut warnings);
}
let mut filtered_warnings = Vec::new();
let lines = ctx.raw_lines();
let span_ends = if warnings.is_empty() {
Vec::new()
} else {
Self::emphasis_span_ends(ctx)
};
for warning in &warnings {
let line_num = warning.line;
let Some(line) = line_num.checked_sub(1).and_then(|index| lines.get(index)) else {
continue;
};
let line_start_pos = ctx.line_start_byte(line_num).unwrap_or(0);
let byte_pos = line_start_pos + (warning.column - 1);
let line_pos = warning.column - 1;
let char_col = byte_to_char_count(line, warning.column - 1);
let in_pandoc_construct = ctx.flavor.is_pandoc_compatible() && ctx.is_in_bracketed_span(byte_pos);
let byte_end = line_start_pos + (warning.end_column - 1);
let closing_pos = byte_end.saturating_sub(1);
let marker_in_html = has_html
&& [byte_pos, closing_pos].into_iter().any(|pos| {
ctx.is_in_html_comment(pos) || ctx.is_in_html_tag(pos) || ctx.is_in_jsx_component_tag(pos)
});
if !in_pandoc_construct
&& !marker_in_html
&& !ctx.is_in_jinja_string(byte_pos)
&& !ctx.is_in_jinja_string(closing_pos)
&& !Self::closes_earlier_emphasis(&span_ends, byte_pos, byte_end)
&& !self.is_in_link(ctx, byte_pos)
&& !ctx.is_in_shortcode(byte_pos)
&& !ctx.is_in_shortcode(closing_pos)
&& !is_in_math_context(ctx, byte_pos)
&& !ctx.is_in_code_span(line_num, char_col)
&& !ctx.is_in_code_span_byte(closing_pos)
&& !is_in_inline_html_code(line, line_pos)
&& !ctx.overlaps_mdx_inline_code(byte_pos, byte_end)
&& !is_in_mkdocs_markup(line, line_pos, ctx.flavor)
&& !ctx.is_position_in_obsidian_comment(line_num, char_col)
{
let mut adjusted_warning = warning.clone();
adjusted_warning.column = char_col;
adjusted_warning.end_column = byte_to_char_count(line, warning.end_column - 1);
filtered_warnings.push(adjusted_warning);
}
}
Ok(filtered_warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
Ok(ctx.content.to_string())
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn category(&self) -> RuleCategory {
RuleCategory::Emphasis
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.likely_has_emphasis()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MD037NoSpaceInEmphasis)
}
}
impl MD037NoSpaceInEmphasis {
#[inline]
fn check_line_for_emphasis_issues_fast(
&self,
syntax: crate::lint_context::EmphasisLine<'_>,
warnings: &mut Vec<LintWarning>,
) {
let line = syntax.source();
let line_num = syntax.line_num;
if has_doc_patterns(line) {
return;
}
if (line.starts_with(' ') || line.starts_with('*') || line.starts_with('+') || line.starts_with('-'))
&& UNORDERED_LIST_MARKER_REGEX.is_match(line)
{
if let Some(caps) = UNORDERED_LIST_MARKER_REGEX.captures(line)
&& let Some(full_match) = caps.get(0)
{
let list_marker_end = full_match.end();
if list_marker_end < line.len() {
let remaining_content = &line[list_marker_end..];
self.check_line_content_for_emphasis_fast(remaining_content, line_num, list_marker_end, warnings);
}
}
return;
}
let processed = replace_inline_math_cow(syntax.without_code());
let fresh_markers;
let markers = if matches!(processed, std::borrow::Cow::Borrowed(_)) {
syntax.markers
} else {
fresh_markers = find_emphasis_markers(&processed);
&fresh_markers
};
self.check_prepared_content(line, &processed, markers, line_num, 0, warnings);
}
fn check_line_content_for_emphasis_fast(
&self,
content: &str,
line_num: usize,
offset: usize,
warnings: &mut Vec<LintWarning>,
) {
let code_masked = replace_inline_code_cow(content);
let processed_content = replace_inline_math_cow(&code_masked);
let markers = find_emphasis_markers(&processed_content);
self.check_prepared_content(content, &processed_content, &markers, line_num, offset, warnings);
}
fn check_prepared_content(
&self,
content: &str,
processed_content: &str,
markers: &[EmphasisMarker],
line_num: usize,
offset: usize,
warnings: &mut Vec<LintWarning>,
) {
if markers.is_empty() {
return;
}
let spans = find_emphasis_edges(processed_content, markers);
let valid_ranges = find_valid_emphasis_ranges(processed_content, markers);
for span in spans {
if has_spacing_issues(&span) {
let full_start = span.opening.start_pos;
let full_end = span.closing.end_pos();
if valid_ranges
.iter()
.any(|&(start, end)| start <= full_start && full_end <= end)
{
continue;
}
let full_text = &content[full_start..full_end];
if full_end < content.len() {
let remaining = &content[full_end..];
if remaining.starts_with('{') && has_span_ial(remaining.split_whitespace().next().unwrap_or("")) {
continue;
}
}
let display_text = truncate_for_display(full_text, 60);
let warning = LintWarning {
rule_name: Some(self.name().to_string()),
message: format!("Spaces inside emphasis markers: {display_text:?}"),
line: line_num,
column: offset + full_start + 1,
end_line: line_num,
end_column: offset + full_end + 1,
severity: Severity::Warning,
fix: None,
};
warnings.push(warning);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
use crate::utils::emphasis_utils::find_emphasis_spans;
#[test]
fn test_marker_inside_shortcode_cannot_close_spaced_emphasis() {
let rule = MD037NoSpaceInEmphasis;
let source = "Before * text {{< note title=\"*\" >}}\n\nBefore * visible *\n";
let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Hugo, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
#[test]
fn test_myst_comment_contents_are_not_linted() {
let rule = MD037NoSpaceInEmphasis;
for prefix in ["% ", "%", " %\t", " % "] {
let source = format!("{prefix}* literal *\n\nBefore * visible *\n");
let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "comments are not visible prose: {source}");
assert_eq!(warnings[0].line, 3);
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
#[test]
fn test_jinja_quoted_spaced_markers_are_literal() {
let rule = MD037NoSpaceInEmphasis;
for template in [
r#"{{ "* literal *" }}"#,
"{% set text = '_ 内容 _' %}",
"{{\n \"** literal **\"\n}}",
r#"{{ {"text": "* literal *"} }}"#,
r#"* text {{ "*" }}"#,
] {
let source = format!("Before {template}\n\nBefore * visible *\n");
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.check(&ctx).unwrap().len(),
1,
"quoted markers are literal: {source}"
);
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
#[test]
fn test_mdx_module_spaced_markers_are_literal() {
let rule = MD037NoSpaceInEmphasis;
for module in [
"export const text = \"* literal *\"",
"export const data = {\n text: \"_ 内容 _\"\n}",
"import text from \"./* literal *.js\"",
] {
let source = format!("{module}\n\nBefore * visible *\n");
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "module strings must stay literal: {source}");
assert!(warnings[0].line > module.lines().count());
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
#[test]
fn test_spaced_markers_in_html_attributes_or_closing_in_comments_are_ignored() {
let rule = MD037NoSpaceInEmphasis;
for source in [
"Before <span title=\"* literal *\">visible</span>\n",
"Before <span\n title=\"_ literal _\">visible</span>\n",
"日本語 <span title=\"** 内容 **\">表示</span>\n",
"Before * text <!-- * -->\n",
"Before ** text <span title=\"**\">visible</span>\n",
] {
let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
assert!(
rule.check(&ctx).unwrap().is_empty(),
"HTML markers are literal: {source}"
);
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
#[test]
fn test_spaced_emphasis_beside_html_attributes_still_reported() {
let rule = MD037NoSpaceInEmphasis;
let source = "Before <span title=\"* literal *\">* visible *</span> <!-- * hidden * -->\n";
let expected = "Before <span title=\"* literal *\">*visible*</span> <!-- * hidden * -->\n";
let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&ctx).unwrap(), source);
let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
}
#[test]
fn test_spaced_marker_pair_ending_inside_multiline_code_is_ignored() {
let rule = MD037NoSpaceInEmphasis;
for content in [
"Before * text `code *\nend`\n",
"_ text `code _\nend`\n",
"** text `code **\nend`\n",
"__ text ``code `tick` __\nend``\n",
"日本語 * 内容 `コード *\n終了`\n",
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
rule.check(&ctx).unwrap().is_empty(),
"closing marker is code: {content}"
);
assert_eq!(rule.fix(&ctx).unwrap(), content);
}
}
#[test]
fn test_spaced_emphasis_beside_multiline_code_requires_manual_correction() {
let rule = MD037NoSpaceInEmphasis;
let source = "Before ** text `code **\nend` and * real *\n";
let expected = "Before ** text `code **\nend` and *real*\n";
let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].line, 2);
assert_eq!(rule.fix(&ctx).unwrap(), source);
let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
let code_values = |content: &str| {
pulldown_cmark::Parser::new(content)
.filter_map(|event| match event {
pulldown_cmark::Event::Code(value) => Some(value.into_string()),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(code_values(source), vec!["code ** end"]);
assert_eq!(code_values(source), code_values(expected));
}
#[test]
fn table_cell_ranges_unmasked_scan_matches_masked_scan() {
fn always_masked(line: &str, flavor: MarkdownFlavor) -> Vec<Range<usize>> {
let escaped = TableUtils::mask_escaped_pipes(line, flavor);
let masked = TableUtils::mask_pipes_in_inline_code(&escaped);
let mut ranges = Vec::new();
let mut start = 0;
for (pipe_pos, _) in masked.match_indices('|') {
ranges.push(start..pipe_pos);
start = pipe_pos + 1;
}
ranges.push(start..line.len());
ranges
}
for line in [
"| a | b |",
"| a * x * | b |",
"a | b",
"no pipes at all",
"",
"|||",
"| naïve ünïcode | 日本語 |",
r"| a \| b | c |",
"| `a | b` | c |",
r"| `a \| b` | c |",
r"| a \\ | b |",
r"| a \\| b | c |",
"> | a * x * | b |",
] {
for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MkDocs] {
assert_eq!(
table_cell_ranges(line, flavor),
always_masked(line, flavor),
"line: {line:?} flavor: {flavor:?}"
);
}
}
}
#[test]
fn test_emphasis_marker_parsing() {
let markers = find_emphasis_markers("This has *single* and **double** emphasis");
assert_eq!(markers.len(), 4);
let markers = find_emphasis_markers("*start* and *end*");
assert_eq!(markers.len(), 4); }
#[test]
fn test_emphasis_span_detection() {
let markers = find_emphasis_markers("This has *valid* emphasis");
let spans = find_emphasis_spans("This has *valid* emphasis", &markers);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "valid");
assert!(!spans[0].has_leading_space);
assert!(!spans[0].has_trailing_space);
let markers = find_emphasis_markers("This has * invalid * emphasis");
let spans = find_emphasis_spans("This has * invalid * emphasis", &markers);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, " invalid ");
assert!(spans[0].has_leading_space);
assert!(spans[0].has_trailing_space);
}
#[test]
fn test_with_document_structure() {
let rule = MD037NoSpaceInEmphasis;
let content = "This is *correct* emphasis and **strong emphasis**";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "No warnings expected for correct emphasis");
let content = "This is * text with spaces * and more content";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Expected warnings for spaces in emphasis");
let content = "This is *correct* emphasis\n```\n* incorrect * in code block\n```\nOutside block with * spaces in emphasis *";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Expected warnings for spaces in emphasis outside code block"
);
}
#[test]
fn test_spaced_literal_markers_preserve_embedded_inline_code() {
let rule = MD037NoSpaceInEmphasis;
let content = "Set * the `id` field * below.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn unresolved_spaced_reference_still_warns_while_rendered_links_do_not() {
let rule = MD037NoSpaceInEmphasis;
for (source, expected) in [
("[ * spaced * ][unknown]\n", 1),
("[ * spaced * ](url)\n", 0),
("\n", 0),
] {
let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.check(&ctx).unwrap().len(), expected, "{source:?}");
}
}
#[test]
fn test_emphasis_in_links_not_flagged() {
let rule = MD037NoSpaceInEmphasis;
let content = r#"Check this [* spaced asterisk *](https://example.com/*test*) link.
This has * real spaced emphasis * that should be flagged."#;
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected exactly 1 warning, but got: {:?}",
result.len()
);
assert!(result[0].message.contains("Spaces inside emphasis markers"));
assert!(result[0].line == 3); }
#[test]
fn test_emphasis_in_links_vs_outside_links() {
let rule = MD037NoSpaceInEmphasis;
let content = r#"Check [* spaced *](https://example.com/*test*) and inline * real spaced * text.
[* link *]: https://example.com/*path*"#;
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Spaces inside emphasis markers"));
assert!(result[0].line == 1);
}
#[test]
fn test_issue_49_asterisk_in_inline_code() {
let rule = MD037NoSpaceInEmphasis;
let content = "The `__mul__` method is needed for left-hand multiplication (`vector * 3`) and `__rmul__` is needed for right-hand multiplication (`3 * vector`).";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag asterisks inside inline code as emphasis (issue #49). Got: {result:?}"
);
}
#[test]
fn test_issue_28_inline_code_in_emphasis() {
let rule = MD037NoSpaceInEmphasis;
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.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag inline code inside emphasis as spaces (issue #28). Got: {result:?}"
);
let content2 = "The **`foo` and `bar`** methods are important.";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"Should not flag multiple inline code snippets inside emphasis. Got: {result2:?}"
);
let content3 = "This is __inline `code`__ with underscores.";
let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
let result3 = rule.check(&ctx3).unwrap();
assert!(
result3.is_empty(),
"Should not flag inline code with underscore emphasis. Got: {result3:?}"
);
let content4 = "This is *inline `test`* with single asterisks.";
let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
let result4 = rule.check(&ctx4).unwrap();
assert!(
result4.is_empty(),
"Should not flag inline code with single asterisk emphasis. Got: {result4:?}"
);
let content5 = "This has * real spaces * that should be flagged.";
let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
let result5 = rule.check(&ctx5).unwrap();
assert!(!result5.is_empty(), "Should still flag actual spaces in emphasis");
assert!(result5[0].message.contains("Spaces inside emphasis markers"));
}
#[test]
fn test_multibyte_utf8_no_panic() {
let rule = MD037NoSpaceInEmphasis;
let greek = "Αυτό είναι ένα * τεστ με ελληνικά * και πολύ μεγάλο κείμενο που θα πρέπει να περικοπεί σωστά.";
let ctx = LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx);
assert!(result.is_ok(), "Greek text should not panic");
let chinese = "这是一个 * 测试文本 * 包含中文字符,需要正确处理多字节边界。";
let ctx = LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx);
assert!(result.is_ok(), "Chinese text should not panic");
let cyrillic = "Это * тест с кириллицей * и очень длинным текстом для проверки обрезки.";
let ctx = LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx);
assert!(result.is_ok(), "Cyrillic text should not panic");
let mixed =
"日本語と * 中文と한국어が混在する非常に長いテキストでtruncate_for_displayの境界処理をテスト * します。";
let ctx = LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx);
assert!(result.is_ok(), "Mixed CJK text should not panic");
let arabic = "هذا * اختبار بالعربية * مع نص طويل جداً لاختبار معالجة حدود الأحرف.";
let ctx = LintContext::new(arabic, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx);
assert!(result.is_ok(), "Arabic text should not panic");
let emoji = "This has * 🎉 party 🎊 celebration 🥳 emojis * that use multi-byte sequences.";
let ctx = LintContext::new(emoji, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx);
assert!(result.is_ok(), "Emoji text should not panic");
}
#[test]
fn test_template_shortcode_syntax_not_flagged() {
let rule = MD037NoSpaceInEmphasis;
let content = "{* ../../docs_src/cookie_param_models/tutorial001.py hl[9:12,16] *}";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Template shortcode syntax should not be flagged. Got: {result:?}"
);
let content = "{* ../../docs_src/conditional_openapi/tutorial001.py hl[6,11] *}";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Template shortcode syntax should not be flagged. Got: {result:?}"
);
let content = "# Header\n\n{* file1.py *}\n\nSome text.\n\n{* file2.py hl[1-5] *}";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multiple template shortcodes should not be flagged. Got: {result:?}"
);
let content = "This has * real spaced emphasis * here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Real spaced emphasis should still be flagged");
}
#[test]
fn test_multiline_code_span_not_flagged() {
let rule = MD037NoSpaceInEmphasis;
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";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag asterisks inside multi-line code spans. Got: {result:?}"
);
let content2 = "Text with `code that\nspans * multiple * lines` here.";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"Should not flag asterisks inside multi-line code spans. Got: {result2:?}"
);
}
#[test]
fn test_html_block_asterisks_not_flagged() {
let rule = MD037NoSpaceInEmphasis;
let content = r#"<table>
<tr><td>Format</td><td>Size</td></tr>
<tr><td>BC1</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 8</code></td></tr>
<tr><td>BC2</td><td><code>floor((width + 3) / 4) * floor((height + 3) / 4) * 16</code></td></tr>
</table>"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag asterisks inside HTML blocks. Got: {result:?}"
);
let content2 = "<div>\n<p>Value is * something * here</p>\n</div>";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"Should not flag emphasis-like patterns inside HTML div blocks. Got: {result2:?}"
);
let content3 = "Regular * spaced emphasis * text\n\n<div>* not emphasis *</div>";
let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
let result3 = rule.check(&ctx3).unwrap();
assert_eq!(
result3.len(),
1,
"Should flag spaced emphasis in regular markdown but not inside HTML blocks. Got: {result3:?}"
);
assert_eq!(result3[0].line, 1, "Warning should be on line 1 (regular markdown)");
}
#[test]
fn test_mkdocs_icon_shortcode_not_flagged() {
let rule = MD037NoSpaceInEmphasis;
let content = "Click :material-check: to confirm and :fontawesome-solid-star: for favorites.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag MkDocs icon shortcodes. Got: {result:?}"
);
let content2 = "This has * real spaced emphasis * but also :material-check: icon.";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
!result2.is_empty(),
"Should still flag real spaced emphasis in MkDocs mode"
);
}
#[test]
fn test_mkdocs_pymdown_markup_not_flagged() {
let rule = MD037NoSpaceInEmphasis;
let content = "Press ++ctrl+c++ to copy.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag PyMdown Keys notation. Got: {result:?}"
);
let content2 = "This is ==highlighted text== for emphasis.";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::MkDocs, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"Should not flag PyMdown Mark notation. Got: {result2:?}"
);
let content3 = "This is ^^inserted text^^ here.";
let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::MkDocs, None);
let result3 = rule.check(&ctx3).unwrap();
assert!(
result3.is_empty(),
"Should not flag PyMdown Insert notation. Got: {result3:?}"
);
let content4 = "Press ++ctrl++ then * spaced emphasis * here.";
let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::MkDocs, None);
let result4 = rule.check(&ctx4).unwrap();
assert!(
!result4.is_empty(),
"Should still flag real spaced emphasis alongside PyMdown markup"
);
}
#[test]
fn test_obsidian_highlight_not_flagged() {
let rule = MD037NoSpaceInEmphasis;
let content = "This is ==highlighted text== here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag Obsidian highlight syntax. Got: {result:?}"
);
}
#[test]
fn test_obsidian_highlight_multiple_on_line() {
let rule = MD037NoSpaceInEmphasis;
let content = "Both ==one== and ==two== are highlighted.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag multiple Obsidian highlights. Got: {result:?}"
);
}
#[test]
fn test_obsidian_highlight_entire_paragraph() {
let rule = MD037NoSpaceInEmphasis;
let content = "==Entire paragraph highlighted==";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag entire highlighted paragraph. Got: {result:?}"
);
}
#[test]
fn test_obsidian_highlight_with_emphasis() {
let rule = MD037NoSpaceInEmphasis;
let content = "**==bold highlight==**";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag bold highlight combination. Got: {result:?}"
);
let content2 = "*==italic highlight==*";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Obsidian, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"Should not flag italic highlight combination. Got: {result2:?}"
);
}
#[test]
fn test_obsidian_highlight_in_lists() {
let rule = MD037NoSpaceInEmphasis;
let content = "- Item with ==highlight== text\n- Another ==highlighted== item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag highlights in list items. Got: {result:?}"
);
}
#[test]
fn test_obsidian_highlight_in_blockquote() {
let rule = MD037NoSpaceInEmphasis;
let content = "> This quote has ==highlighted== text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag highlights in blockquotes. Got: {result:?}"
);
}
#[test]
fn test_obsidian_highlight_in_tables() {
let rule = MD037NoSpaceInEmphasis;
let content = "| Header | Column |\n|--------|--------|\n| ==highlighted== | text |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag highlights in tables. Got: {result:?}"
);
}
#[test]
fn test_obsidian_highlight_in_code_blocks_ignored() {
let rule = MD037NoSpaceInEmphasis;
let content = "```\n==not highlight in code==\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should ignore highlights in code blocks. Got: {result:?}"
);
}
#[test]
fn test_obsidian_highlight_edge_case_three_equals() {
let rule = MD037NoSpaceInEmphasis;
let content = "Test === something === here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
let _ = result;
}
#[test]
fn test_obsidian_highlight_edge_case_four_equals() {
let rule = MD037NoSpaceInEmphasis;
let content = "Test ==== here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
let _ = result;
}
#[test]
fn test_obsidian_highlight_adjacent() {
let rule = MD037NoSpaceInEmphasis;
let content = "==one====two==";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
let _ = result;
}
#[test]
fn test_obsidian_highlight_with_special_chars() {
let rule = MD037NoSpaceInEmphasis;
let content = "Test ==code: `test`== here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
let _ = result;
}
#[test]
fn test_obsidian_highlight_unclosed() {
let rule = MD037NoSpaceInEmphasis;
let content = "This ==starts but never ends";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
let _ = result;
}
#[test]
fn test_obsidian_highlight_still_flags_real_emphasis_issues() {
let rule = MD037NoSpaceInEmphasis;
let content = "This has * spaced emphasis * and ==valid highlight==";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should still flag real spaced emphasis in Obsidian mode"
);
assert!(
result.len() == 1,
"Should flag exactly one issue (the spaced emphasis). Got: {result:?}"
);
}
#[test]
fn test_standard_flavor_does_not_recognize_highlight() {
let rule = MD037NoSpaceInEmphasis;
let content = "This is ==highlighted text== here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let _ = result; }
#[test]
fn test_obsidian_highlight_mixed_with_regular_emphasis() {
let rule = MD037NoSpaceInEmphasis;
let content = "==highlighted== and *italic* and **bold** text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag valid highlight and emphasis. Got: {result:?}"
);
}
#[test]
fn test_obsidian_highlight_unicode() {
let rule = MD037NoSpaceInEmphasis;
let content = "Text ==日本語 highlighted== here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should handle Unicode in highlights. Got: {result:?}"
);
}
#[test]
fn test_obsidian_highlight_with_html() {
let rule = MD037NoSpaceInEmphasis;
let content = "<!-- ==not highlight in comment== --> ==actual highlight==";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
let _ = result;
}
#[test]
fn test_obsidian_inline_comment_emphasis_ignored() {
let rule = MD037NoSpaceInEmphasis;
let content = "Visible %%* spaced emphasis *%% still visible.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should ignore emphasis inside Obsidian comments. Got: {result:?}"
);
}
#[test]
fn test_inline_html_code_not_flagged() {
let rule = MD037NoSpaceInEmphasis;
let content = "The formula is <code>a * b * c</code> in math.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag asterisks inside inline <code> tags. Got: {result:?}"
);
let content2 = "Use <kbd>Ctrl * A</kbd> and <samp>x * y</samp> here.";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"Should not flag asterisks inside inline <kbd> and <samp> tags. Got: {result2:?}"
);
let content3 = r#"Result: <code class="math">a * b</code> done."#;
let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
let result3 = rule.check(&ctx3).unwrap();
assert!(
result3.is_empty(),
"Should not flag asterisks inside <code> with attributes. Got: {result3:?}"
);
let content4 = "Text * spaced * and <code>a * b</code>.";
let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
let result4 = rule.check(&ctx4).unwrap();
assert_eq!(
result4.len(),
1,
"Should flag real spaced emphasis but not code content. Got: {result4:?}"
);
assert_eq!(result4[0].column, 6);
}
#[test]
fn test_pandoc_bracketed_span_guard() {
use crate::config::MarkdownFlavor;
let rule = MD037NoSpaceInEmphasis;
let content = "See [* important *]{.highlight} for details.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD037 should not flag emphasis-like patterns inside Pandoc bracketed spans: {result:?}"
);
let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
let result_std = rule.check(&ctx_std).unwrap();
assert!(
!result_std.is_empty(),
"MD037 should still flag spaces in emphasis under Standard flavor: {result_std:?}"
);
}
#[test]
fn test_spaced_bold_metadata_pattern_detected() {
let rule = MD037NoSpaceInEmphasis;
let content = "# Test\n\n** Explicit Import**: Convert markdownlint configs to rumdl format:";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag '** Explicit Import**' as spaced emphasis. Got: {result:?}"
);
assert_eq!(result[0].line, 3);
let content2 = "# Test\n\n**trailing only **: some text";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(
result2.len(),
1,
"Should flag '**trailing only **' as spaced emphasis. Got: {result2:?}"
);
let content3 = "# Test\n\n** both spaces **: some text";
let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
let result3 = rule.check(&ctx3).unwrap();
assert_eq!(
result3.len(),
1,
"Should flag '** both spaces **' as spaced emphasis. Got: {result3:?}"
);
let content4 = "# Test\n\n**Key**: value";
let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
let result4 = rule.check(&ctx4).unwrap();
assert!(
result4.is_empty(),
"Should not flag valid bold metadata '**Key**: value'. Got: {result4:?}"
);
}
}
#[cfg(test)]
mod mdx_adjacent_prose_tests {
use super::*;
#[test]
fn md037_mdx_code_does_not_hide_adjacent_prose() {
let rule = MD037NoSpaceInEmphasis;
for code in [
"{value}",
"{/* * literal * */}",
"{\"* literal *\"}",
"<Card onClick={() => null} title='* literal *'>text</Card>",
] {
for ending in ["\n", "\r\n"] {
let source = format!("内容 Before {code} after * visible *{ending}");
let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
}
#[test]
fn md037_mdx_code_cannot_supply_the_other_emphasis_marker() {
let rule = MD037NoSpaceInEmphasis;
for source in ["Before * text {\"*\"} after\n", "Before * text {/* * */} after\n"] {
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
}