mod md018_config;
pub use md018_config::MD018Config;
use crate::config::MarkdownFlavor;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::range_utils::calculate_single_line_range;
use crate::utils::regex_cache::get_cached_regex;
const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
const OBSIDIAN_TAG_PATTERN_STR: &str = r"^#[^\d\s#][^\s#]*(?:\s|$)";
#[derive(Clone)]
pub struct MD018NoMissingSpaceAtx {
config: MD018Config,
}
impl Default for MD018NoMissingSpaceAtx {
fn default() -> Self {
Self::new()
}
}
impl MD018NoMissingSpaceAtx {
pub fn new() -> Self {
Self {
config: MD018Config::default(),
}
}
pub fn from_config_struct(config: MD018Config) -> Self {
Self { config }
}
fn is_magiclink_ref(line: &str) -> bool {
get_cached_regex(MAGICLINK_REF_PATTERN_STR).is_ok_and(|re| re.is_match(line.trim_start()))
}
fn is_obsidian_tag(line: &str) -> bool {
get_cached_regex(OBSIDIAN_TAG_PATTERN_STR).is_ok_and(|re| re.is_match(line.trim_start()))
}
fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
let trimmed_line = line.trim_start();
let indent = line.len() - trimmed_line.len();
if !trimmed_line.starts_with('#') {
return None;
}
if indent > 0 {
return None;
}
let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
.map(|re| re.is_match(trimmed_line))
.unwrap_or(false);
let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
.map(|re| re.is_match(trimmed_line))
.unwrap_or(false);
if is_emoji || is_unicode {
return None;
}
let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
if hash_count == 0 || hash_count > 6 {
return None;
}
let after_hashes = &trimmed_line[hash_count..];
if after_hashes
.chars()
.next()
.is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
{
return None;
}
if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
let content = after_hashes.trim();
if content.chars().all(|c| c == '#') {
return None;
}
if content.len() < 2 {
return None;
}
if content.starts_with('*') || content.starts_with('_') {
return None;
}
if self.config.magiclink && hash_count == 1 && Self::is_magiclink_ref(line) {
return None;
}
if flavor == MarkdownFlavor::Obsidian && hash_count == 1 && Self::is_obsidian_tag(line) {
return None;
}
let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
return Some((indent + hash_count, fixed));
}
None
}
fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
let mut current_line = 1;
let mut start_byte = 0;
for (i, c) in content.char_indices() {
if current_line == line_num && c == '\n' {
return start_byte..i;
} else if c == '\n' {
current_line += 1;
if current_line == line_num {
start_byte = i + 1;
}
}
}
if current_line == line_num {
return start_byte..content.len();
}
0..0
}
}
impl Rule for MD018NoMissingSpaceAtx {
fn name(&self) -> &'static str {
"MD018"
}
fn description(&self) -> &'static str {
"No space after hash in heading"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
for (line_num, line_info) in ctx.lines.iter().enumerate() {
if line_info.in_html_block || line_info.in_html_comment || line_info.in_pymdown_block {
continue;
}
if let Some(heading) = &line_info.heading {
if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
if line_info.indent > 0 {
continue;
}
let line = line_info.content(ctx.content);
let trimmed = line.trim_start();
let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
if is_emoji || is_unicode {
continue;
}
if self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line) {
continue;
}
if ctx.flavor == MarkdownFlavor::Obsidian && heading.level == 1 && Self::is_obsidian_tag(line) {
continue;
}
if trimmed.len() > heading.marker.len() {
let after_marker = &trimmed[heading.marker.len()..];
if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
{
let hash_end_col = line_info.indent + heading.marker.len() + 1; let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
line_num + 1, hash_end_col,
0, );
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
fix: Some(Fix {
range: self.get_line_byte_range(ctx.content, line_num + 1),
replacement: {
let line = line_info.content(ctx.content);
let original_indent = &line[..line_info.indent];
format!("{original_indent}{} {after_marker}", heading.marker)
},
}),
});
}
}
}
} else if !line_info.in_code_block
&& !line_info.in_front_matter
&& !line_info.in_html_comment
&& !line_info.is_blank
{
if let Some((hash_end_pos, fixed_line)) =
self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
{
let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
line_num + 1, hash_end_pos + 1, 0, );
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: "No space after hash in heading".to_string(),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
fix: Some(Fix {
range: self.get_line_byte_range(ctx.content, line_num + 1),
replacement: fixed_line,
}),
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let warnings = self.check(ctx)?;
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
let warning_lines: std::collections::HashSet<usize> = warnings.iter().map(|w| w.line).collect();
let mut lines = Vec::new();
for (idx, line_info) in ctx.lines.iter().enumerate() {
let mut fixed = false;
if !warning_lines.contains(&(idx + 1)) {
lines.push(line_info.content(ctx.content).to_string());
continue;
}
if let Some(heading) = &line_info.heading {
if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
let line = line_info.content(ctx.content);
let trimmed = line.trim_start();
let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
.map(|re| re.is_match(trimmed))
.unwrap_or(false);
let is_magiclink = self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line);
let is_obsidian_tag =
ctx.flavor == MarkdownFlavor::Obsidian && heading.level == 1 && Self::is_obsidian_tag(line);
if !is_emoji
&& !is_unicode
&& !is_magiclink
&& !is_obsidian_tag
&& trimmed.len() > heading.marker.len()
{
let after_marker = &trimmed[heading.marker.len()..];
if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
{
let line = line_info.content(ctx.content);
let original_indent = &line[..line_info.indent];
lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
fixed = true;
}
}
}
} else if !line_info.in_code_block
&& !line_info.in_front_matter
&& !line_info.in_html_comment
&& !line_info.is_blank
{
if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
lines.push(fixed_line);
fixed = true;
}
}
if !fixed {
lines.push(line_info.content(ctx.content).to_string());
}
}
let mut result = lines.join("\n");
if ctx.content.ends_with('\n') && !result.ends_with('\n') {
result.push('\n');
}
Ok(result)
}
fn category(&self) -> RuleCategory {
RuleCategory::Heading
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
!ctx.likely_has_headings()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD018Config>(config);
Box::new(MD018NoMissingSpaceAtx::from_config_struct(rule_config))
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let json_value = serde_json::to_value(&self.config).ok()?;
Some((
self.name().to_string(),
crate::rule_config_serde::json_to_toml_value(&json_value)?,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_basic_functionality() {
let rule = MD018NoMissingSpaceAtx::new();
let content = "# Heading 1\n## Heading 2\n### Heading 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
let content = "#Heading 1\n## Heading 2\n###Heading 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
assert_eq!(result[1].line, 3);
}
#[test]
fn test_malformed_heading_detection() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
.is_some()
);
assert!(
rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
.is_some()
);
assert!(
rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
.is_some()
);
assert!(
rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
.is_some()
);
assert!(
rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
.is_some()
);
assert!(
rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
.is_some()
);
assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none()); assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none()); assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none()); assert!(
rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
.is_none()
); assert!(
rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
.is_none()
); }
#[test]
fn test_malformed_heading_with_context() {
let rule = MD018NoMissingSpaceAtx::new();
let content = r#"# Test Document
##Introduction
This should be detected.
##CodeBlock
This should NOT be detected (indented code block).
```
##FencedCodeBlock
This should NOT be detected (fenced code block).
```
##Conclusion
This should be detected.
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
#[test]
fn test_malformed_heading_fix() {
let rule = MD018NoMissingSpaceAtx::new();
let content = r#"##Introduction
This is a test.
###Background
More content."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = r#"## Introduction
This is a test.
### Background
More content."#;
assert_eq!(fixed, expected);
}
#[test]
fn test_mixed_proper_and_malformed_headings() {
let rule = MD018NoMissingSpaceAtx::new();
let content = r#"# Proper Heading
##Malformed Heading
## Another Proper Heading
###Another Malformed
#### Proper with space
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
#[test]
fn test_css_selectors_in_html_blocks() {
let rule = MD018NoMissingSpaceAtx::new();
let content = r#"# Proper Heading
<style>
#slide-1 ol li {
margin-top: 0;
}
#special-slide ol li {
margin-top: 2em;
}
</style>
## Another Heading
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"CSS selectors in <style> blocks should not be flagged as malformed headings"
);
}
#[test]
fn test_js_code_in_script_blocks() {
let rule = MD018NoMissingSpaceAtx::new();
let content = r#"# Heading
<script>
const element = document.querySelector('#main-content');
#another-comment
</script>
## Another Heading
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"JavaScript code in <script> blocks should not be flagged as malformed headings"
);
}
#[test]
fn test_all_malformed_headings_detected() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
.is_some(),
"#hello SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
"#tag SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
.is_some(),
"#hashtag SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
.is_some(),
"#javascript SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
"#123 SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
.is_some(),
"#12345 SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
.is_some(),
"#29039) SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
.is_some(),
"#Summary SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
.is_some(),
"#Introduction SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
"#API SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
.is_some(),
"##introduction SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
.is_some(),
"###section SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
.is_some(),
"###fer SHOULD be detected as malformed heading"
);
assert!(
rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
"##123 SHOULD be detected as malformed heading"
);
}
#[test]
fn test_patterns_that_should_not_be_flagged() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
assert!(
rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
.is_none()
);
assert!(
rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
.is_none()
);
assert!(
rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
.is_none()
);
assert!(
rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
.is_none()
);
assert!(
rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
.is_none()
);
}
#[test]
fn test_inline_issue_refs_not_at_line_start() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
.is_none()
);
assert!(
rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
.is_none()
);
assert!(
rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
.is_none()
);
}
#[test]
fn test_lowercase_patterns_full_check() {
let rule = MD018NoMissingSpaceAtx::new();
let content = "#hello\n\n#world\n\n#tag";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
assert_eq!(result[0].line, 1);
assert_eq!(result[1].line, 3);
assert_eq!(result[2].line, 5);
}
#[test]
fn test_numeric_patterns_full_check() {
let rule = MD018NoMissingSpaceAtx::new();
let content = "#123\n\n#456\n\n#29039";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
}
#[test]
fn test_fix_lowercase_patterns() {
let rule = MD018NoMissingSpaceAtx::new();
let content = "#hello\nSome text.\n\n#world";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "# hello\nSome text.\n\n# world";
assert_eq!(fixed, expected);
}
#[test]
fn test_fix_numeric_patterns() {
let rule = MD018NoMissingSpaceAtx::new();
let content = "#123\nContent.\n\n##456";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "# 123\nContent.\n\n## 456";
assert_eq!(fixed, expected);
}
#[test]
fn test_indented_malformed_headings() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
.is_none(),
"1-space indented #hello should be skipped"
);
assert!(
rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
.is_none(),
"2-space indented #hello should be skipped"
);
assert!(
rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
.is_none(),
"3-space indented #hello should be skipped"
);
assert!(
rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
.is_some(),
"Non-indented #hello should be detected"
);
}
#[test]
fn test_tab_after_hash_is_valid() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
.is_none(),
"Tab after # should be valid"
);
assert!(
rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
.is_none(),
"Tab after ## should be valid"
);
}
#[test]
fn test_mixed_case_patterns() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
.is_some()
);
assert!(
rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
.is_some()
);
assert!(
rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
.is_some()
);
assert!(
rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
.is_some()
);
}
#[test]
fn test_unicode_lowercase() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
"Unicode lowercase #über should be detected"
);
assert!(
rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
"Unicode lowercase #café should be detected"
);
assert!(
rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
.is_some(),
"Japanese #日本語 should be detected"
);
}
#[test]
fn test_matches_markdownlint_behavior() {
let rule = MD018NoMissingSpaceAtx::new();
let content = r#"#hello
## world
###fer
#123
#Tag
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
assert!(flagged_lines.contains(&1), "#hello should be flagged");
assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
assert!(flagged_lines.contains(&5), "###fer should be flagged");
assert!(flagged_lines.contains(&7), "#123 should be flagged");
assert!(flagged_lines.contains(&9), "#Tag should be flagged");
assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
}
#[test]
fn test_skip_frontmatter_yaml_comments() {
let rule = MD018NoMissingSpaceAtx::new();
let content = r#"---
#reviewers:
#- sig-api-machinery
#another_comment: value
title: Test Document
---
# Valid heading
#invalid heading without space
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should only flag the malformed heading outside frontmatter"
);
assert_eq!(result[0].line, 10, "Should flag line 10");
}
#[test]
fn test_skip_html_comments() {
let rule = MD018NoMissingSpaceAtx::new();
let content = r#"# Real Heading
Some text.
<!--
```
#%% Cell marker
import matplotlib.pyplot as plt
#%% Another cell
data = [1, 2, 3]
```
-->
More content.
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag content inside HTML comments, found {} issues",
result.len()
);
}
#[test]
fn test_mkdocs_magiclink_skips_numeric_refs() {
let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
assert!(
rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
"#10 should be skipped with magiclink config (MagicLink issue ref)"
);
assert!(
rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
"#123 should be skipped with magiclink config (MagicLink issue ref)"
);
assert!(
rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
.is_none(),
"#10 followed by text should be skipped with magiclink config"
);
assert!(
rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
"#37 followed by punctuation should be skipped with magiclink config"
);
}
#[test]
fn test_mkdocs_magiclink_still_flags_non_numeric() {
let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
assert!(
rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
.is_some(),
"#Summary should still be flagged with magiclink config"
);
assert!(
rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
.is_some(),
"#hello should still be flagged with magiclink config"
);
assert!(
rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
.is_some(),
"#10abc (mixed) should still be flagged with magiclink config"
);
}
#[test]
fn test_mkdocs_magiclink_only_single_hash() {
let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
assert!(
rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
"##10 should be flagged with magiclink config (only single # is MagicLink)"
);
assert!(
rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
.is_some(),
"###123 should be flagged with magiclink config"
);
}
#[test]
fn test_standard_flavor_flags_numeric_refs() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
"#10 should be flagged in Standard flavor"
);
assert!(
rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
"#123 should be flagged in Standard flavor"
);
}
#[test]
fn test_mkdocs_magiclink_full_check() {
let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
let content = r#"# PRs that are helpful for context
#10 discusses the philosophy behind the project, and #37 shows a good example.
#Summary
##Introduction
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
assert!(
!flagged_lines.contains(&3),
"#10 should NOT be flagged with magiclink config"
);
assert!(
flagged_lines.contains(&5),
"#Summary SHOULD be flagged with magiclink config"
);
assert!(
flagged_lines.contains(&7),
"##Introduction SHOULD be flagged with magiclink config"
);
}
#[test]
fn test_mkdocs_magiclink_fix_exact_output() {
let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
let content = "#10 discusses the issue.\n\n#Summary";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "#10 discusses the issue.\n\n# Summary";
assert_eq!(
fixed, expected,
"magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
);
}
#[test]
fn test_mkdocs_magiclink_edge_cases() {
let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
let valid_refs = [
"#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
for ref_str in valid_refs {
assert!(
rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
"{ref_str:?} should be skipped as MagicLink ref with magiclink config"
);
}
let invalid_refs = [
"#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
for ref_str in invalid_refs {
assert!(
rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
"{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
);
}
}
#[test]
fn test_mkdocs_magiclink_hyphenated_continuation() {
let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
assert!(
rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
"#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
);
}
#[test]
fn test_mkdocs_magiclink_standalone_number() {
let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
let content = "See issue:\n\n#10\n\nFor details.";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Standalone #10 should not be flagged with magiclink config"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
}
#[test]
fn test_standard_flavor_flags_all_numeric() {
let rule = MD018NoMissingSpaceAtx::new();
let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
for pattern in numeric_patterns {
assert!(
rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
"{pattern:?} should be flagged in Standard flavor"
);
}
assert!(
rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
"#1 should be skipped (content too short, existing behavior)"
);
}
#[test]
fn test_mkdocs_vs_standard_fix_comparison() {
let content = "#10 is an issue\n#Summary";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
let rule_default = MD018NoMissingSpaceAtx::new();
let fixed_default = rule_default.fix(&ctx).unwrap();
assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
}
#[test]
fn test_obsidian_tag_skips_simple_tags() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
"#hey should be skipped in Obsidian flavor (tag syntax)"
);
assert!(
rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
"#tag should be skipped in Obsidian flavor"
);
assert!(
rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
.is_none(),
"#hello should be skipped in Obsidian flavor"
);
assert!(
rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
.is_none(),
"#myTag should be skipped in Obsidian flavor"
);
}
#[test]
fn test_obsidian_tag_skips_complex_tags() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
.is_none(),
"#project/active should be skipped in Obsidian flavor (nested tag)"
);
assert!(
rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
.is_none(),
"#my-tag should be skipped in Obsidian flavor"
);
assert!(
rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
.is_none(),
"#my_tag should be skipped in Obsidian flavor"
);
assert!(
rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
.is_none(),
"#tag2023 should be skipped in Obsidian flavor"
);
assert!(
rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
.is_none(),
"#project/sub/task should be skipped in Obsidian flavor"
);
}
#[test]
fn test_obsidian_tag_with_trailing_content() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
"#hey followed by space should be skipped"
);
assert!(
rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
.is_none(),
"#tag followed by text should be skipped"
);
}
#[test]
fn test_obsidian_tag_still_flags_multi_hash() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
"##tag should be flagged in Obsidian flavor (only single # is a tag)"
);
assert!(
rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
.is_some(),
"###hello should be flagged in Obsidian flavor"
);
}
#[test]
fn test_obsidian_tag_numeric_still_flagged() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
"#123 should be flagged in Obsidian flavor (tags cannot start with digit)"
);
assert!(
rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
"#10 should be flagged in Obsidian flavor"
);
}
#[test]
fn test_obsidian_flavor_full_check() {
let rule = MD018NoMissingSpaceAtx::new();
let content = r#"# Real Heading
#hey this is a tag
#project/active also a tag
##Introduction
#123
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
assert!(
!flagged_lines.contains(&3),
"#hey should NOT be flagged in Obsidian flavor"
);
assert!(
!flagged_lines.contains(&5),
"#project/active should NOT be flagged in Obsidian flavor"
);
assert!(
flagged_lines.contains(&7),
"##Introduction SHOULD be flagged in Obsidian flavor"
);
assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
}
#[test]
fn test_obsidian_flavor_fix_exact_output() {
let rule = MD018NoMissingSpaceAtx::new();
let content = "#hey is a tag.\n\n##Introduction";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "#hey is a tag.\n\n## Introduction";
assert_eq!(
fixed, expected,
"Obsidian fix should preserve tags and fix multi-hash headings"
);
}
#[test]
fn test_standard_flavor_flags_obsidian_tags() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
"#hey should be flagged in Standard flavor"
);
assert!(
rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
"#tag should be flagged in Standard flavor"
);
assert!(
rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
.is_some(),
"#project/active should be flagged in Standard flavor"
);
}
#[test]
fn test_obsidian_vs_standard_fix_comparison() {
let rule = MD018NoMissingSpaceAtx::new();
let content = "#hey tag\n##Introduction";
let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed_standard = rule.fix(&ctx_standard).unwrap();
assert_eq!(fixed_standard, "# hey tag\n## Introduction");
}
#[test]
fn test_obsidian_tag_edge_cases() {
let rule = MD018NoMissingSpaceAtx::new();
let valid_tags = [
"#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
for tag in valid_tags {
let result = rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian);
let _ = result;
}
let invalid_tags = ["#1tag", "#123", "#2023-project"];
for tag in invalid_tags {
assert!(
rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
"{tag:?} should be flagged in Obsidian flavor (starts with digit)"
);
}
}
#[test]
fn test_obsidian_tag_alone_on_line() {
let rule = MD018NoMissingSpaceAtx::new();
let content = "Some text\n\n#todo\n\nMore text.";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Standalone #todo should not be flagged in Obsidian flavor"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
}
#[test]
fn test_obsidian_deeply_nested_tags() {
let rule = MD018NoMissingSpaceAtx::new();
let nested_tags = [
"#a/b",
"#a/b/c",
"#project/2023/q1/task",
"#work/meetings/weekly",
"#life/health/exercise/running",
];
for tag in nested_tags {
assert!(
rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
"{tag:?} should be skipped in Obsidian flavor (nested tag)"
);
}
}
#[test]
fn test_obsidian_unicode_tags() {
let rule = MD018NoMissingSpaceAtx::new();
let unicode_tags = [
"#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
for tag in unicode_tags {
assert!(
rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
"{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
);
}
}
#[test]
fn test_obsidian_tags_with_special_endings() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
.is_none(),
"#tag followed by text should be skipped"
);
let content = "#todo";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "#todo at end of line should be skipped");
}
#[test]
fn test_obsidian_combined_with_other_skip_contexts() {
let rule = MD018NoMissingSpaceAtx::new();
let content = "```\n#todo\n```";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Tag in code block should be skipped");
let content = "<!-- #todo -->";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Tag in HTML comment should be skipped");
}
#[test]
fn test_obsidian_boundary_cases() {
let rule = MD018NoMissingSpaceAtx::new();
assert!(
rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
"#ab should be skipped in Obsidian flavor"
);
assert!(
rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
.is_none(),
"#my_tag should be skipped"
);
assert!(
rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
.is_none(),
"#my-tag should be skipped"
);
assert!(
rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
.is_none(),
"#MyTag should be skipped"
);
assert!(
rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
"#TODO should be skipped in Obsidian flavor"
);
}
}