mod md041_config;
pub(super) use md041_config::MD041Config;
use crate::lint_context::HeadingStyle;
use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, Severity};
use crate::rules::front_matter_utils::FrontMatterUtils;
use crate::utils::mkdocs_attr_list::is_mkdocs_anchor_line;
use crate::utils::range_utils::calculate_line_range;
use crate::utils::regex_cache::HTML_HEADING_PATTERN;
use regex::Regex;
#[derive(Clone)]
pub struct MD041FirstLineHeading {
pub level: usize,
pub front_matter_title: bool,
pub front_matter_title_pattern: Option<Regex>,
pub fix_enabled: bool,
}
impl Default for MD041FirstLineHeading {
fn default() -> Self {
Self {
level: 1,
front_matter_title: true,
front_matter_title_pattern: None,
fix_enabled: false,
}
}
}
enum FixPlan {
MoveOrRelevel {
front_matter_end_idx: usize,
heading_idx: usize,
is_setext: bool,
current_level: usize,
needs_level_fix: bool,
},
PromotePlainText {
front_matter_end_idx: usize,
title_line_idx: usize,
title_text: String,
},
InsertDerived {
front_matter_end_idx: usize,
derived_title: String,
},
}
impl MD041FirstLineHeading {
pub fn new(level: usize, front_matter_title: bool) -> Self {
Self {
level,
front_matter_title,
front_matter_title_pattern: None,
fix_enabled: false,
}
}
pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
Ok(regex) => Some(regex),
Err(e) => {
log::warn!("Invalid front_matter_title_pattern regex: {e}");
None
}
});
Self {
level,
front_matter_title,
front_matter_title_pattern,
fix_enabled,
}
}
fn has_front_matter_title(&self, content: &str) -> bool {
if !self.front_matter_title {
return false;
}
if let Some(ref pattern) = self.front_matter_title_pattern {
let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
for line in front_matter_lines {
if pattern.is_match(line) {
return true;
}
}
return false;
}
FrontMatterUtils::has_front_matter_field(content, "title:")
}
fn is_non_content_line(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.contains("]: ") {
return true;
}
if trimmed.starts_with('*') && trimmed.contains("]: ") {
return true;
}
if Self::is_badge_image_line(trimmed) {
return true;
}
false
}
fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
for (idx, line_info) in ctx.lines.iter().enumerate() {
if line_info.in_front_matter
|| line_info.is_blank
|| line_info.in_esm_block
|| line_info.in_html_comment
|| line_info.in_mdx_comment
|| line_info.in_kramdown_extension_block
|| line_info.is_kramdown_block_ial
{
continue;
}
let line_content = line_info.content(ctx.content);
if is_mkdocs && is_mkdocs_anchor_line(line_content) {
continue;
}
if Self::is_non_content_line(line_content) {
continue;
}
return Some(idx);
}
None
}
fn is_badge_image_line(line: &str) -> bool {
if line.is_empty() {
return false;
}
if !line.starts_with('!') && !line.starts_with('[') {
return false;
}
let mut remaining = line;
while !remaining.is_empty() {
remaining = remaining.trim_start();
if remaining.is_empty() {
break;
}
if remaining.starts_with("[![") {
if let Some(end) = Self::find_linked_image_end(remaining) {
remaining = &remaining[end..];
continue;
}
return false;
}
if remaining.starts_with("![") {
if let Some(end) = Self::find_image_end(remaining) {
remaining = &remaining[end..];
continue;
}
return false;
}
return false;
}
true
}
fn find_image_end(s: &str) -> Option<usize> {
if !s.starts_with("![") {
return None;
}
let alt_end = s[2..].find("](")?;
let paren_start = 2 + alt_end + 2; let paren_end = s[paren_start..].find(')')?;
Some(paren_start + paren_end + 1)
}
fn find_linked_image_end(s: &str) -> Option<usize> {
if !s.starts_with("[![") {
return None;
}
let inner_end = Self::find_image_end(&s[1..])?;
let after_inner = 1 + inner_end;
if !s[after_inner..].starts_with("](") {
return None;
}
let link_start = after_inner + 2;
let link_end = s[link_start..].find(')')?;
Some(link_start + link_end + 1)
}
fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
let hashes = "#".repeat(target_level);
let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
let after_hashes = &trimmed[content_start..];
let content = after_hashes.trim_start();
let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
format!("{leading_ws}{hashes} {content}")
} else {
let hashes = "#".repeat(target_level);
let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
format!("{leading_ws}{hashes} {trimmed}")
}
}
fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
if text.is_empty() {
return false;
}
if !next_is_blank_or_eof {
return false;
}
if text.len() > 80 {
return false;
}
let last_char = text.chars().next_back().unwrap_or(' ');
if matches!(last_char, '.' | '?' | '!' | ':' | ';') {
return false;
}
if text.starts_with('#')
|| text.starts_with("- ")
|| text.starts_with("* ")
|| text.starts_with("+ ")
|| text.starts_with("> ")
{
return false;
}
true
}
fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
let path = ctx.source_file.as_ref()?;
let stem = path.file_stem().and_then(|s| s.to_str())?;
let effective_stem = if stem.eq_ignore_ascii_case("index") || stem.eq_ignore_ascii_case("readme") {
path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())?
} else {
stem
};
let title: String = effective_stem
.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let upper: String = first.to_uppercase().collect();
upper + chars.as_str()
}
}
})
.collect::<Vec<_>>()
.join(" ");
if title.is_empty() { None } else { Some(title) }
}
fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
&& let Some(h_level) = captures.get(1)
&& h_level.as_str().parse::<usize>().unwrap_or(0) == level
{
return true;
}
let html_tags = ctx.html_tags();
let target_tag = format!("h{level}");
let opening_index = html_tags.iter().position(|tag| {
tag.line == first_line_idx + 1 && tag.tag_name == target_tag
&& !tag.is_closing
});
let Some(open_idx) = opening_index else {
return false;
};
let mut depth = 1usize;
for tag in html_tags.iter().skip(open_idx + 1) {
if tag.line <= first_line_idx + 1 {
continue;
}
if tag.tag_name == target_tag {
if tag.is_closing {
depth -= 1;
if depth == 0 {
return true;
}
} else if !tag.is_self_closing {
depth += 1;
}
}
}
false
}
fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
if ctx.lines.is_empty() {
return None;
}
let mut front_matter_end_idx = 0;
for line_info in &ctx.lines {
if line_info.in_front_matter {
front_matter_end_idx += 1;
} else {
break;
}
}
let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
let mut found_heading: Option<(usize, bool, usize)> = None;
let mut first_title_candidate: Option<(usize, String)> = None;
let mut found_non_title_content = false;
let mut saw_non_directive_content = false;
'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
let line_content = line_info.content(ctx.content);
let trimmed = line_content.trim();
let is_preamble = trimmed.is_empty()
|| line_info.in_html_comment
|| line_info.in_mdx_comment
|| line_info.in_html_block
|| Self::is_non_content_line(line_content)
|| (is_mkdocs && is_mkdocs_anchor_line(line_content))
|| line_info.in_kramdown_extension_block
|| line_info.is_kramdown_block_ial;
if is_preamble {
continue;
}
let is_directive_block = line_info.in_admonition
|| line_info.in_content_tab
|| line_info.in_pandoc_div
|| line_info.is_div_marker
|| line_info.in_pymdown_block;
if !is_directive_block {
saw_non_directive_content = true;
}
if let Some(heading) = &line_info.heading {
let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
found_heading = Some((idx, is_setext, heading.level as usize));
break 'scan;
}
if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
let next_is_blank_or_eof = ctx
.lines
.get(idx + 1)
.is_none_or(|l| l.content(ctx.content).trim().is_empty());
if Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
first_title_candidate = Some((idx, trimmed.to_string()));
} else {
found_non_title_content = true;
}
}
}
if let Some((h_idx, is_setext, current_level)) = found_heading {
if found_non_title_content || first_title_candidate.is_some() {
return None;
}
let needs_level_fix = current_level != self.level;
let needs_move = h_idx > front_matter_end_idx;
if needs_level_fix || needs_move {
return Some(FixPlan::MoveOrRelevel {
front_matter_end_idx,
heading_idx: h_idx,
is_setext,
current_level,
needs_level_fix,
});
}
return None; }
if let Some((title_idx, title_text)) = first_title_candidate {
return Some(FixPlan::PromotePlainText {
front_matter_end_idx,
title_line_idx: title_idx,
title_text,
});
}
if !saw_non_directive_content && let Some(derived_title) = Self::derive_title(ctx) {
return Some(FixPlan::InsertDerived {
front_matter_end_idx,
derived_title,
});
}
None
}
fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
self.fix_enabled && self.analyze_for_fix(ctx).is_some()
}
}
impl Rule for MD041FirstLineHeading {
fn name(&self) -> &'static str {
"MD041"
}
fn description(&self) -> &'static str {
"First line in file should be a top level heading"
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
if self.should_skip(ctx) {
return Ok(warnings);
}
let Some(first_line_idx) = Self::first_content_line_idx(ctx) else {
return Ok(warnings);
};
let first_line_info = &ctx.lines[first_line_idx];
let is_correct_heading = if let Some(heading) = &first_line_info.heading {
heading.level as usize == self.level
} else {
Self::is_html_heading(ctx, first_line_idx, self.level)
};
if !is_correct_heading {
let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
let fix = if self.can_fix(ctx) {
self.analyze_for_fix(ctx).and_then(|plan| {
let range_start = first_line_info.byte_offset;
let range_end = range_start + first_line_info.byte_len;
match &plan {
FixPlan::MoveOrRelevel {
heading_idx,
current_level,
needs_level_fix,
is_setext,
..
} if *heading_idx == first_line_idx => {
let heading_line = ctx.lines[*heading_idx].content(ctx.content);
let replacement = if *needs_level_fix || *is_setext {
self.fix_heading_level(heading_line, *current_level, self.level)
} else {
heading_line.to_string()
};
Some(Fix::new(range_start..range_end, replacement))
}
FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
let replacement = format!(
"{} {}",
"#".repeat(self.level),
ctx.lines[*title_line_idx].content(ctx.content).trim()
);
Some(Fix::new(range_start..range_end, replacement))
}
_ => {
self.fix(ctx)
.ok()
.map(|fixed_content| Fix::new(0..ctx.content.len(), fixed_content))
}
}
})
} else {
None
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: format!("First line in file should be a level {} heading", self.level),
severity: Severity::Warning,
fix,
});
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if !self.fix_enabled {
return Ok(ctx.content.to_string());
}
if self.should_skip(ctx) {
return Ok(ctx.content.to_string());
}
let first_content_line = Self::first_content_line_idx(ctx).map_or(1, |i| i + 1);
if ctx.inline_config().is_rule_disabled(self.name(), first_content_line) {
return Ok(ctx.content.to_string());
}
let Some(plan) = self.analyze_for_fix(ctx) else {
return Ok(ctx.content.to_string());
};
let lines = ctx.raw_lines();
let mut result = String::new();
let preserve_trailing_newline = ctx.content.ends_with('\n');
match plan {
FixPlan::MoveOrRelevel {
front_matter_end_idx,
heading_idx,
is_setext,
current_level,
needs_level_fix,
} => {
let heading_line = ctx.lines[heading_idx].content(ctx.content);
let fixed_heading = if needs_level_fix || is_setext {
self.fix_heading_level(heading_line, current_level, self.level)
} else {
heading_line.to_string()
};
for line in lines.iter().take(front_matter_end_idx) {
result.push_str(line);
result.push('\n');
}
result.push_str(&fixed_heading);
result.push('\n');
for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
if idx == heading_idx {
continue;
}
if is_setext && idx == heading_idx + 1 {
continue;
}
result.push_str(line);
result.push('\n');
}
}
FixPlan::PromotePlainText {
front_matter_end_idx,
title_line_idx,
title_text,
} => {
let hashes = "#".repeat(self.level);
let new_heading = format!("{hashes} {title_text}");
for line in lines.iter().take(front_matter_end_idx) {
result.push_str(line);
result.push('\n');
}
result.push_str(&new_heading);
result.push('\n');
for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
if idx == title_line_idx {
continue;
}
result.push_str(line);
result.push('\n');
}
}
FixPlan::InsertDerived {
front_matter_end_idx,
derived_title,
} => {
let hashes = "#".repeat(self.level);
let new_heading = format!("{hashes} {derived_title}");
for line in lines.iter().take(front_matter_end_idx) {
result.push_str(line);
result.push('\n');
}
result.push_str(&new_heading);
result.push('\n');
result.push('\n');
for line in lines.iter().skip(front_matter_end_idx) {
result.push_str(line);
result.push('\n');
}
}
}
if !preserve_trailing_newline && result.ends_with('\n') {
result.pop();
}
Ok(result)
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
let only_directives = !ctx.content.is_empty()
&& ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
let t = l.trim();
(t.starts_with("{{#") && t.ends_with("}}"))
|| (t.starts_with("<!--") && t.ends_with("-->"))
});
ctx.content.is_empty()
|| (self.front_matter_title && self.has_front_matter_title(ctx.content))
|| only_directives
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
let use_front_matter = !md041_config.front_matter_title.is_empty();
Box::new(MD041FirstLineHeading::with_pattern(
md041_config.level.as_usize(),
use_front_matter,
md041_config.front_matter_title_pattern,
md041_config.fix,
))
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
Some((
"MD041".to_string(),
toml::toml! {
level = 1
front-matter-title = "title"
front-matter-title-pattern = ""
fix = false
}
.into(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_first_line_is_heading_correct_level() {
let rule = MD041FirstLineHeading::default();
let content = "# My Document\n\nSome content here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings when first line is a level 1 heading"
);
}
#[test]
fn test_first_line_is_heading_wrong_level() {
let rule = MD041FirstLineHeading::default();
let content = "## My Document\n\nSome content here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 1);
assert!(result[0].message.contains("level 1 heading"));
}
#[test]
fn test_first_line_not_heading() {
let rule = MD041FirstLineHeading::default();
let content = "This is not a heading\n\n# This is a heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 1);
assert!(result[0].message.contains("level 1 heading"));
}
#[test]
fn test_empty_lines_before_heading() {
let rule = MD041FirstLineHeading::default();
let content = "\n\n# My Document\n\nSome content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings when empty lines precede a valid heading"
);
let content = "\n\nNot a heading\n\nSome content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
}
#[test]
fn test_front_matter_with_title() {
let rule = MD041FirstLineHeading::new(1, true);
let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings when front matter has title field"
);
}
#[test]
fn test_front_matter_without_title() {
let rule = MD041FirstLineHeading::new(1, true);
let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 6); }
#[test]
fn test_front_matter_disabled() {
let rule = MD041FirstLineHeading::new(1, false);
let content = "---\ntitle: My Document\n---\n\nSome content here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 5); }
#[test]
fn test_html_comments_before_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"HTML comments should be skipped when checking for first heading"
);
}
#[test]
fn test_multiline_html_comment_before_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multi-line HTML comments should be skipped when checking for first heading"
);
}
#[test]
fn test_html_comment_with_blank_lines_before_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"HTML comments with blank lines should be skipped when checking for first heading"
);
}
#[test]
fn test_html_comment_before_html_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"HTML comments should be skipped before HTML headings"
);
}
#[test]
fn test_document_with_only_html_comments() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- This is a comment -->\n<!-- Another comment -->";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Documents with only HTML comments should not trigger MD041"
);
}
#[test]
fn test_html_comment_followed_by_non_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"HTML comment followed by non-heading should still trigger MD041"
);
assert_eq!(
result[0].line, 2,
"Warning should be on the first non-comment, non-heading line"
);
}
#[test]
fn test_multiple_html_comments_before_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multiple HTML comments should all be skipped before heading"
);
}
#[test]
fn test_html_comment_with_wrong_level_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"HTML comment followed by wrong-level heading should still trigger MD041"
);
assert!(
result[0].message.contains("level 1 heading"),
"Should require level 1 heading"
);
}
#[test]
fn test_html_comment_mixed_with_reference_definitions() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"HTML comments and reference definitions should both be skipped before heading"
);
}
#[test]
fn test_html_comment_after_front_matter() {
let rule = MD041FirstLineHeading::default();
let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"HTML comments after front matter should be skipped before heading"
);
}
#[test]
fn test_html_comment_not_at_start_should_not_affect_rule() {
let rule = MD041FirstLineHeading::default();
let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"HTML comments in middle of document should not affect MD041 (only first content matters)"
);
}
#[test]
fn test_multiline_html_comment_followed_by_non_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Multi-line HTML comment followed by non-heading should still trigger MD041"
);
assert_eq!(
result[0].line, 5,
"Warning should be on the first non-comment, non-heading line"
);
}
#[test]
fn test_different_heading_levels() {
let rule = MD041FirstLineHeading::new(2, false);
let content = "## Second Level Heading\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
let content = "# First Level Heading\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("level 2 heading"));
}
#[test]
fn test_setext_headings() {
let rule = MD041FirstLineHeading::default();
let content = "My Document\n===========\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
let content = "My Document\n-----------\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("level 1 heading"));
}
#[test]
fn test_empty_document() {
let rule = MD041FirstLineHeading::default();
let content = "";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Expected no warnings for empty document");
}
#[test]
fn test_whitespace_only_document() {
let rule = MD041FirstLineHeading::default();
let content = " \n\n \t\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
}
#[test]
fn test_front_matter_then_whitespace() {
let rule = MD041FirstLineHeading::default();
let content = "---\ntitle: Test\n---\n\n \n\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings when no content after front matter"
);
}
#[test]
fn test_multiple_front_matter_types() {
let rule = MD041FirstLineHeading::new(1, true);
let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for TOML front matter with title"
);
let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for JSON front matter with title"
);
let content = "---\ntitle: My Document\n---\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for YAML front matter with title"
);
}
#[test]
fn test_toml_front_matter_with_heading() {
let rule = MD041FirstLineHeading::default();
let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings when heading follows TOML front matter"
);
}
#[test]
fn test_toml_front_matter_without_title_no_heading() {
let rule = MD041FirstLineHeading::new(1, true);
let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 6);
}
#[test]
fn test_toml_front_matter_level_2_heading() {
let rule = MD041FirstLineHeading::new(2, true);
let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Issue #427: TOML front matter with title and correct heading level should not warn"
);
}
#[test]
fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Issue #427 regression: TOML front matter must be skipped when locating first heading"
);
}
#[test]
fn test_json_front_matter_with_heading() {
let rule = MD041FirstLineHeading::default();
let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings when heading follows JSON front matter"
);
}
#[test]
fn test_malformed_front_matter() {
let rule = MD041FirstLineHeading::new(1, true);
let content = "- --\ntitle: My Document\n- --\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for malformed front matter with title"
);
}
#[test]
fn test_front_matter_with_heading() {
let rule = MD041FirstLineHeading::default();
let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings when first line after front matter is correct heading"
);
}
#[test]
fn test_no_fix_suggestion() {
let rule = MD041FirstLineHeading::default();
let content = "Not a heading\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
}
#[test]
fn test_complex_document_structure() {
let rule = MD041FirstLineHeading::default();
let content =
"---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"HTML comments should be skipped, so first heading after comment should be valid"
);
}
#[test]
fn test_heading_with_special_characters() {
let rule = MD041FirstLineHeading::default();
let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for heading with inline formatting"
);
}
#[test]
fn test_level_configuration() {
for level in 1..=6 {
let rule = MD041FirstLineHeading::new(level, false);
let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Expected no warnings for correct level {level} heading"
);
let wrong_level = if level == 1 { 2 } else { 1 };
let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains(&format!("level {level} heading")));
}
}
#[test]
fn test_issue_152_multiline_html_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<h1>\nSome text\n</h1>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Issue #152: Multi-line HTML h1 should be recognized as valid heading"
);
}
#[test]
fn test_multiline_html_heading_with_attributes() {
let rule = MD041FirstLineHeading::default();
let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multi-line HTML heading with attributes should be recognized"
);
}
#[test]
fn test_multiline_html_heading_wrong_level() {
let rule = MD041FirstLineHeading::default();
let content = "<h2>\nSome text\n</h2>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("level 1 heading"));
}
#[test]
fn test_multiline_html_heading_with_content_after() {
let rule = MD041FirstLineHeading::default();
let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multi-line HTML heading followed by content should be valid"
);
}
#[test]
fn test_multiline_html_heading_incomplete() {
let rule = MD041FirstLineHeading::default();
let content = "<h1>\nSome text\n\nMore content without closing tag";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("level 1 heading"));
}
#[test]
fn test_singleline_html_heading_still_works() {
let rule = MD041FirstLineHeading::default();
let content = "<h1>My Document</h1>\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Single-line HTML headings should still be recognized"
);
}
#[test]
fn test_multiline_html_heading_with_nested_tags() {
let rule = MD041FirstLineHeading::default();
let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multi-line HTML heading with nested tags should be recognized"
);
}
#[test]
fn test_multiline_html_heading_various_levels() {
for level in 1..=6 {
let rule = MD041FirstLineHeading::new(level, false);
let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multi-line HTML heading at level {level} should be recognized"
);
let wrong_level = if level == 1 { 2 } else { 1 };
let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains(&format!("level {level} heading")));
}
}
#[test]
fn test_issue_152_nested_heading_spans_many_lines() {
let rule = MD041FirstLineHeading::default();
let content = "<h1>\n <div>\n <img\n href=\"https://example.com/image.png\"\n alt=\"Example Image\"\n />\n <a\n href=\"https://example.com\"\n >Example Project</a>\n <span>Documentation</span>\n </div>\n</h1>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
}
#[test]
fn test_issue_152_picture_tag_heading() {
let rule = MD041FirstLineHeading::default();
let content = "<h1>\n <picture>\n <source\n srcset=\"https://example.com/light.png\"\n media=\"(prefers-color-scheme: light)\"\n />\n <source\n srcset=\"https://example.com/dark.png\"\n media=\"(prefers-color-scheme: dark)\"\n />\n <img src=\"https://example.com/default.png\" />\n </picture>\n</h1>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Picture tag inside multi-line HTML heading should be recognized"
);
}
#[test]
fn test_badge_images_before_heading() {
let rule = MD041FirstLineHeading::default();
let content = "\n\n# My Project";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Badge image should be skipped");
let content = " \n\n# My Project";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Multiple badges should be skipped");
let content = "[](https://example.com)\n\n# My Project";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Linked badge should be skipped");
}
#[test]
fn test_multiple_badge_lines_before_heading() {
let rule = MD041FirstLineHeading::default();
let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Multiple badge lines should be skipped");
}
#[test]
fn test_badges_without_heading_still_warns() {
let rule = MD041FirstLineHeading::default();
let content = "\n\nThis is not a heading.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
}
#[test]
fn test_mixed_content_not_badge_line() {
let rule = MD041FirstLineHeading::default();
let content = " Some text here\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
}
#[test]
fn test_is_badge_image_line_unit() {
assert!(MD041FirstLineHeading::is_badge_image_line(""));
assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
assert!(MD041FirstLineHeading::is_badge_image_line(" "));
assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
assert!(!MD041FirstLineHeading::is_badge_image_line(""));
assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
}
#[test]
fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
let rule = MD041FirstLineHeading::default();
let content = "[](){ #example }\n# Title";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MkDocs anchor line should be skipped in MkDocs flavor"
);
}
#[test]
fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
let rule = MD041FirstLineHeading::default();
let content = "[](){ #example }\n# Title";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"MkDocs anchor line should NOT be skipped in Standard flavor"
);
}
#[test]
fn test_multiple_mkdocs_anchors_before_heading() {
let rule = MD041FirstLineHeading::default();
let content = "[](){ #first }\n[](){ #second }\n# Title";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
);
}
#[test]
fn test_mkdocs_anchor_with_front_matter() {
let rule = MD041FirstLineHeading::default();
let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MkDocs anchor line after front matter should be skipped in MkDocs flavor"
);
}
#[test]
fn test_mkdocs_anchor_kramdown_style() {
let rule = MD041FirstLineHeading::default();
let content = "[](){: #anchor }\n# Title";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
);
}
#[test]
fn test_mkdocs_anchor_without_heading_still_warns() {
let rule = MD041FirstLineHeading::default();
let content = "[](){ #anchor }\nThis is not a heading.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"MkDocs anchor followed by non-heading should still trigger MD041"
);
}
#[test]
fn test_mkdocs_anchor_with_html_comment() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
);
}
#[test]
fn test_fix_disabled_by_default() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading::default();
let content = "## Wrong Level\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Fix should not change content when disabled");
}
#[test]
fn test_fix_wrong_heading_level() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "## Wrong Level\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
}
#[test]
fn test_fix_heading_after_preamble() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "\n\n# Title\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("# Title\n"),
"Heading should be moved to first line, got: {fixed}"
);
}
#[test]
fn test_fix_heading_after_html_comment() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "<!-- Comment -->\n# Title\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("# Title\n"),
"Heading should be moved above comment, got: {fixed}"
);
}
#[test]
fn test_fix_heading_level_and_move() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("# Wrong Level\n"),
"Heading should be fixed and moved, got: {fixed}"
);
}
#[test]
fn test_fix_with_front_matter() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
"Heading should be right after front matter, got: {fixed}"
);
}
#[test]
fn test_fix_with_toml_front_matter() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
"Heading should be right after TOML front matter, got: {fixed}"
);
}
#[test]
fn test_fix_cannot_fix_no_heading() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "Just some text.\n\nMore text.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Should not change content when no heading exists");
}
#[test]
fn test_fix_cannot_fix_content_before_heading() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "Some intro text.\n\n# Title\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Should not change content when real content exists before heading"
);
}
#[test]
fn test_fix_already_correct() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "# Title\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Should not change already correct content");
}
#[test]
fn test_fix_setext_heading_removes_underline() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "Wrong Level\n-----------\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# Wrong Level\n\nContent.\n",
"Setext heading should be converted to ATX and underline removed"
);
}
#[test]
fn test_fix_setext_h1_heading() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
"Setext h1 should be moved and converted to ATX"
);
}
#[test]
fn test_html_heading_not_claimed_fixable() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "<h2>Title</h2>\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].fix.is_none(),
"HTML heading should not be claimed as fixable"
);
}
#[test]
fn test_no_heading_not_claimed_fixable() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "Just some text.\n\nMore text.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].fix.is_none(),
"Document without heading should not be claimed as fixable"
);
}
#[test]
fn test_content_before_heading_not_claimed_fixable() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "Intro text.\n\n## Heading\n\nMore.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].fix.is_none(),
"Document with content before heading should not be claimed as fixable"
);
}
#[test]
fn test_fix_html_block_before_heading_is_now_fixable() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "<div>\n Some HTML\n</div>\n\n# My Document\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
assert!(
warnings[0].fix.is_some(),
"Should be fixable: heading exists after HTML block preamble"
);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("# My Document\n"),
"Heading should be moved to the top, got: {fixed}"
);
}
#[test]
fn test_fix_html_block_wrong_level_before_heading() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "<div>\n badge\n</div>\n\n## Wrong Level\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("# Wrong Level\n"),
"Heading should be fixed to level 1 and moved to top, got: {fixed}"
);
}
#[test]
fn test_fix_promote_plain_text_title() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "My Project\n\nSome content.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
assert!(
warnings[0].fix.is_some(),
"Should be fixable: first line is a title candidate"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# My Project\n\nSome content.\n",
"Title line should be promoted to heading"
);
}
#[test]
fn test_fix_promote_plain_text_title_with_front_matter() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
"Title should be promoted and placed right after front matter, got: {fixed}"
);
}
#[test]
fn test_fix_no_promote_ends_with_period() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "This is a sentence.\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
let warnings = rule.check(&ctx).unwrap();
assert!(warnings[0].fix.is_none(), "No fix should be offered");
}
#[test]
fn test_fix_no_promote_ends_with_colon() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "Note:\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Colon-ending line should not be promoted");
}
#[test]
fn test_fix_no_promote_if_too_long() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let long_line = "A".repeat(81);
let content = format!("{long_line}\n\nContent.\n");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
}
#[test]
fn test_fix_no_promote_if_no_blank_after() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "My Project\nImmediately continues.\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Line without following blank should not be promoted");
}
#[test]
fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "My Project\n\n# Actual Heading\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Should not fix when title candidate exists before a heading"
);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings[0].fix.is_none(), "No fix should be offered");
}
#[test]
fn test_fix_promote_title_at_eof_no_trailing_newline() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "My Project";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# My Project", "Should promote title at EOF");
}
#[test]
fn test_fix_insert_derived_directive_only_document() {
use crate::rule::Rule;
use std::path::PathBuf;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "!!! note\n This is a note.\n";
let ctx = LintContext::new(
content,
crate::config::MarkdownFlavor::MkDocs,
Some(PathBuf::from("setup-guide.md")),
);
let can_fix = rule.can_fix(&ctx);
assert!(can_fix, "Directive-only document with source file should be fixable");
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("# Setup Guide\n"),
"Should insert derived heading, got: {fixed}"
);
}
#[test]
fn test_fix_no_insert_derived_without_source_file() {
use crate::rule::Rule;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "!!! note\n This is a note.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Without a source file, cannot derive a title");
}
#[test]
fn test_fix_no_insert_derived_when_has_real_content() {
use crate::rule::Rule;
use std::path::PathBuf;
let rule = MD041FirstLineHeading {
level: 1,
front_matter_title: false,
front_matter_title_pattern: None,
fix_enabled: true,
};
let content = "!!! note\n A note.\n\nSome paragraph text.\n";
let ctx = LintContext::new(
content,
crate::config::MarkdownFlavor::MkDocs,
Some(PathBuf::from("guide.md")),
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Should not insert derived heading when real content is present"
);
}
#[test]
fn test_derive_title_converts_kebab_case() {
use std::path::PathBuf;
let ctx = LintContext::new(
"",
crate::config::MarkdownFlavor::Standard,
Some(PathBuf::from("my-setup-guide.md")),
);
let title = MD041FirstLineHeading::derive_title(&ctx);
assert_eq!(title, Some("My Setup Guide".to_string()));
}
#[test]
fn test_derive_title_converts_underscores() {
use std::path::PathBuf;
let ctx = LintContext::new(
"",
crate::config::MarkdownFlavor::Standard,
Some(PathBuf::from("api_reference.md")),
);
let title = MD041FirstLineHeading::derive_title(&ctx);
assert_eq!(title, Some("Api Reference".to_string()));
}
#[test]
fn test_derive_title_none_without_source_file() {
let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
let title = MD041FirstLineHeading::derive_title(&ctx);
assert_eq!(title, None);
}
#[test]
fn test_derive_title_index_file_uses_parent_dir() {
use std::path::PathBuf;
let ctx = LintContext::new(
"",
crate::config::MarkdownFlavor::Standard,
Some(PathBuf::from("docs/getting-started/index.md")),
);
let title = MD041FirstLineHeading::derive_title(&ctx);
assert_eq!(title, Some("Getting Started".to_string()));
}
#[test]
fn test_derive_title_readme_file_uses_parent_dir() {
use std::path::PathBuf;
let ctx = LintContext::new(
"",
crate::config::MarkdownFlavor::Standard,
Some(PathBuf::from("my-project/README.md")),
);
let title = MD041FirstLineHeading::derive_title(&ctx);
assert_eq!(title, Some("My Project".to_string()));
}
#[test]
fn test_derive_title_index_without_parent_returns_none() {
use std::path::PathBuf;
let ctx = LintContext::new(
"",
crate::config::MarkdownFlavor::Standard,
Some(PathBuf::from("index.md")),
);
let title = MD041FirstLineHeading::derive_title(&ctx);
assert_eq!(title, None);
}
#[test]
fn test_derive_title_readme_without_parent_returns_none() {
use std::path::PathBuf;
let ctx = LintContext::new(
"",
crate::config::MarkdownFlavor::Standard,
Some(PathBuf::from("README.md")),
);
let title = MD041FirstLineHeading::derive_title(&ctx);
assert_eq!(title, None);
}
#[test]
fn test_derive_title_readme_case_insensitive() {
use std::path::PathBuf;
let ctx = LintContext::new(
"",
crate::config::MarkdownFlavor::Standard,
Some(PathBuf::from("docs/api/readme.md")),
);
let title = MD041FirstLineHeading::derive_title(&ctx);
assert_eq!(title, Some("Api".to_string()));
}
#[test]
fn test_is_title_candidate_basic() {
assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
}
#[test]
fn test_is_title_candidate_rejects_sentence_punctuation() {
assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
}
#[test]
fn test_is_title_candidate_rejects_when_no_blank_after() {
assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
}
#[test]
fn test_is_title_candidate_rejects_long_lines() {
let long = "A".repeat(81);
assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
let ok = "A".repeat(80);
assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
}
#[test]
fn test_is_title_candidate_rejects_structural_markdown() {
assert!(!MD041FirstLineHeading::is_title_candidate("# Heading", true));
assert!(!MD041FirstLineHeading::is_title_candidate("- list item", true));
assert!(!MD041FirstLineHeading::is_title_candidate("* bullet", true));
assert!(!MD041FirstLineHeading::is_title_candidate("> blockquote", true));
}
#[test]
fn test_fix_replacement_not_empty_for_plain_text_promotion() {
let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
let content = "My Document Title\n\nMore content follows.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
let fix = warnings[0]
.fix
.as_ref()
.expect("Fix should be present for promotable text");
assert!(
!fix.replacement.is_empty(),
"Fix replacement must not be empty — applying it directly must produce valid output"
);
assert!(
fix.replacement.starts_with("# "),
"Fix replacement should be a level-1 heading, got: {:?}",
fix.replacement
);
assert_eq!(fix.replacement, "# My Document Title");
}
#[test]
fn test_fix_replacement_not_empty_for_releveling() {
let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
let content = "## Wrong Level\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
assert!(
!fix.replacement.is_empty(),
"Fix replacement must not be empty for releveling"
);
assert_eq!(fix.replacement, "# Wrong Level");
}
#[test]
fn test_fix_replacement_applied_produces_valid_output() {
let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
let content = "My Document\n\nMore content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
let fix = warnings[0].fix.as_ref().expect("Fix should be present");
let mut patched = content.to_string();
patched.replace_range(fix.range.clone(), &fix.replacement);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
}
#[test]
fn test_mdx_disable_on_line_1_no_heading() {
let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let rule = MD041FirstLineHeading::default();
let warnings = rule.check(&ctx).unwrap();
if !warnings.is_empty() {
assert_eq!(
warnings[0].line, 2,
"Warning must be on line 2 (first content line after MDX comment), not line 1"
);
}
}
#[test]
fn test_mdx_disable_fix_returns_unchanged() {
let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let rule = MD041FirstLineHeading {
fix_enabled: true,
..MD041FirstLineHeading::default()
};
let result = rule.fix(&ctx).unwrap();
assert_eq!(
result, content,
"fix() should not modify content when MD041 is disabled via MDX comment"
);
}
#[test]
fn test_mdx_comment_without_disable_heading_on_next_line() {
let rule = MD041FirstLineHeading::default();
let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MDX comment is preamble; heading on next line should satisfy MD041"
);
}
#[test]
fn test_mdx_comment_without_heading_triggers_warning() {
let rule = MD041FirstLineHeading::default();
let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"MDX comment followed by non-heading should trigger MD041"
);
assert_eq!(
result[0].line, 2,
"Warning should be on line 2 (the first content line after MDX comment)"
);
}
#[test]
fn test_multiline_mdx_comment_followed_by_heading() {
let rule = MD041FirstLineHeading::default();
let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multi-line MDX comment should be preamble; heading after it satisfies MD041"
);
}
#[test]
fn test_html_comment_still_works_as_preamble_regression() {
let rule = MD041FirstLineHeading::default();
let content = "<!-- Some comment -->\n# My Document\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"HTML comment should still be treated as preamble (regression test)"
);
}
}