use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
use crate::rules::heading_utils::HeadingStyle;
use crate::utils::range_utils::calculate_heading_range;
use toml;
mod md003_config;
use md003_config::MD003Config;
static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
#[derive(Clone, Default)]
pub struct MD003HeadingStyle {
config: MD003Config,
style_explicit: bool,
}
impl MD003HeadingStyle {
pub fn new(style: HeadingStyle) -> Self {
Self {
config: MD003Config { style },
style_explicit: true,
}
}
pub fn from_config_struct(config: MD003Config) -> Self {
Self {
config,
style_explicit: false,
}
}
fn is_consistent_mode(&self) -> bool {
self.config.style == HeadingStyle::Consistent
}
fn heading_is_in_literal_code(
ctx: &crate::lint_context::LintContext,
heading_idx: usize,
heading: &crate::lint_context::HeadingInfo,
) -> bool {
let marker_idx = heading_idx
+ usize::from(matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
));
ctx.lines.get(marker_idx).is_some_and(|line| {
let marker_offset = line.byte_offset + line.indent;
ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)
})
}
fn heading_style(
ctx: &crate::lint_context::LintContext,
heading_idx: usize,
heading: &crate::lint_context::HeadingInfo,
) -> HeadingStyle {
match heading.style {
crate::lint_context::HeadingStyle::ATX => {
if !heading.has_closing_sequence {
return HeadingStyle::Atx;
}
let line = &ctx.lines[heading_idx];
let content = line.content(ctx.content);
let before_id = content
.rsplit_once(" {#")
.filter(|(_, id)| id.trim_end().ends_with('}'))
.map_or(content, |(text, _)| text);
let marker = line.byte_offset + before_id.trim_end().len() - heading.closing_sequence.len();
if ctx.is_inside_template_code(marker) || ctx.is_inside_mdx_code(marker) {
HeadingStyle::Atx
} else {
HeadingStyle::AtxClosed
}
}
crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
}
}
fn conversion_is_safe(
ctx: &crate::lint_context::LintContext,
heading_idx: usize,
heading: &crate::lint_context::HeadingInfo,
) -> bool {
if matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) {
return crate::rules::heading_utils::setext_to_atx_is_safe(ctx, heading_idx, heading);
}
let line = &ctx.lines[heading_idx];
let boundary = line.byte_offset + line.byte_len;
!ctx.is_inside_template_code(boundary) && !ctx.is_inside_mdx_code(boundary)
}
fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
if ctx.flavor == crate::config::MarkdownFlavor::MDG {
self.warn_once_about_overridden_style();
return HeadingStyle::Atx;
}
if !self.is_consistent_mode() {
return self.config.style;
}
let mut style_counts = std::collections::HashMap::new();
for (line_num, line_info) in ctx.lines.iter().enumerate() {
if let Some(heading) = &line_info.heading {
if Self::heading_is_in_literal_code(ctx, line_num, heading) {
continue;
}
let style = Self::heading_style(ctx, line_num, heading);
*style_counts.entry(style).or_insert(0) += 1;
}
}
style_counts
.into_iter()
.max_by(|(style_a, count_a), (style_b, count_b)| {
match count_a.cmp(count_b) {
std::cmp::Ordering::Equal => {
let priority = |s: &HeadingStyle| match s {
HeadingStyle::Atx => 0,
HeadingStyle::Setext1 => 1,
HeadingStyle::Setext2 => 2,
HeadingStyle::AtxClosed => 3,
_ => 4,
};
priority(style_b).cmp(&priority(style_a)) }
other => other,
}
})
.map_or(HeadingStyle::Atx, |(style, _)| style)
}
fn warn_once_about_overridden_style(&self) {
if !self.style_explicit || matches!(self.config.style, HeadingStyle::Atx | HeadingStyle::Consistent) {
return;
}
let configured = self.config.style.to_string();
MDG_STYLE_OVERRIDE.report(
"MD003",
"style",
&configured,
"atx",
"Markdown with Gherkin recognizes structure headings only in plain ATX form",
);
}
}
impl Rule for MD003HeadingStyle {
fn name(&self) -> &'static str {
"MD003"
}
fn description(&self) -> &'static str {
"Heading style"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut result = Vec::new();
let target_style = self.get_target_style(ctx);
for (line_num, line_info) in ctx.lines.iter().enumerate() {
if let Some(heading) = &line_info.heading {
if Self::heading_is_in_literal_code(ctx, line_num, heading) {
continue;
}
let level = heading.level;
let current_style = Self::heading_style(ctx, line_num, heading);
let expected_style = match target_style {
HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
if level > 2 {
current_style
} else if level == 1 {
HeadingStyle::Setext1
} else {
HeadingStyle::Setext2
}
}
HeadingStyle::SetextWithAtx => {
if level <= 2 {
if level == 1 {
HeadingStyle::Setext1
} else {
HeadingStyle::Setext2
}
} else {
HeadingStyle::Atx
}
}
HeadingStyle::SetextWithAtxClosed => {
if level <= 2 {
if level == 1 {
HeadingStyle::Setext1
} else {
HeadingStyle::Setext2
}
} else {
HeadingStyle::AtxClosed
}
}
_ => target_style,
};
let expected_style = if ctx.flavor == crate::config::MarkdownFlavor::MDG {
HeadingStyle::Atx
} else {
expected_style
};
if current_style != expected_style {
let first_line_num = line_num + 2 - heading.text_lines;
let fix = if Self::conversion_is_safe(ctx, line_num, heading) {
use crate::rules::heading_utils::HeadingUtils;
let converted_heading =
HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
let first_line_info = &ctx.lines[first_line_num - 1];
let first_line = first_line_info.content(ctx.content);
let original_indent = &first_line[..first_line_info.indent];
let final_heading = format!("{original_indent}{converted_heading}");
let converting_from_setext =
matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
let last_line = if converting_from_setext {
line_num + 2
} else {
line_num + 1
};
let start = ctx.line_content_byte_range(first_line_num).start;
let end = ctx.line_content_byte_range(last_line).end;
Some(crate::rule::Fix::new(start..end, final_heading))
} else {
None
};
let (start_line, start_col, end_line, end_col) =
calculate_heading_range(first_line_num, line_num + 1, line_info.content(ctx.content));
result.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: format!(
"Heading style should be {}, found {}",
match expected_style {
HeadingStyle::Atx => "# Heading",
HeadingStyle::AtxClosed => "# Heading #",
HeadingStyle::Setext1 => "Heading\n=======",
HeadingStyle::Setext2 => "Heading\n-------",
HeadingStyle::Consistent => "consistent with the first heading",
HeadingStyle::SetextWithAtx => "setext-with-atx style",
HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
},
match current_style {
HeadingStyle::Atx => "# Heading",
HeadingStyle::AtxClosed => "# Heading #",
HeadingStyle::Setext1 => "Heading (underlined with =)",
HeadingStyle::Setext2 => "Heading (underlined with -)",
HeadingStyle::Consistent => "consistent style",
HeadingStyle::SetextWithAtx => "setext-with-atx style",
HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
}
),
severity: Severity::Warning,
fix,
});
}
}
}
Ok(result)
}
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());
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let mut fixes: Vec<_> = warnings
.iter()
.filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
.collect();
fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
let mut result = ctx.content.to_string();
for (start, end, replacement) in fixes {
if start < result.len() && end <= result.len() && start <= end {
result.replace_range(start..end, replacement);
}
}
Ok(result)
}
fn category(&self) -> RuleCategory {
RuleCategory::Heading
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
if ctx.content.is_empty() || !ctx.likely_has_headings() {
return true;
}
!ctx.lines.iter().any(|line| line.heading.is_some())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_sections!(MD003Config);
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
let style_explicit = option_is_explicit(config, "MD003", "style");
Box::new(Self {
config: rule_config,
style_explicit,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_atx_heading_style() {
let rule = MD003HeadingStyle::default();
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());
}
#[test]
fn test_setext_heading_style() {
let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
let content = "Heading 1\n=========\n\nHeading 2\n---------";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_front_matter() {
let rule = MD003HeadingStyle::default();
let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"No warnings expected for content with front matter, found: {result:?}"
);
}
#[test]
fn test_consistent_heading_style() {
let rule = MD003HeadingStyle::default();
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());
}
#[test]
fn test_with_different_styles() {
let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
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(),
"No warnings expected for consistent ATX style, found: {result:?}"
);
let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should have warnings for inconsistent heading styles"
);
let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"No warnings expected for setext style with ATX for level 3, found: {result:?}"
);
}
#[test]
fn test_setext_with_atx_style() {
let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
);
let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
let result_wrong = rule.check(&ctx_wrong).unwrap();
assert_eq!(
result_wrong.len(),
2,
"Should flag ATX headings for h1/h2 with setext_with_atx style"
);
}
#[test]
fn test_fix_preserves_attribute_lists() {
let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
let content = "# Heading { #custom-id .class } #";
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("Should have a fix");
assert!(
fix.replacement.contains("{ #custom-id .class }"),
"check() fix should preserve attribute list, got: {}",
fix.replacement
);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("{ #custom-id .class }"),
"fix() should preserve attribute list, got: {fixed}"
);
assert!(
!fixed.contains(" #\n") && !fixed.ends_with(" #"),
"fix() should remove ATX closed trailing hashes, got: {fixed}"
);
}
#[test]
fn test_setext_with_atx_closed_style() {
let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
);
let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
let result_wrong = rule.check(&ctx_wrong).unwrap();
assert_eq!(
result_wrong.len(),
2,
"Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
);
}
#[test]
fn test_mdg_steers_every_heading_to_plain_atx() {
let cases = [
(
MD003HeadingStyle::new(HeadingStyle::Atx),
"Checkout\n========\n\n## Scenario: Buy an item\n",
"# Checkout\n\n## Scenario: Buy an item\n",
),
(
MD003HeadingStyle::new(HeadingStyle::AtxClosed),
"# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
"# Feature: Checkout\n\n## Scenario: Buy an item\n",
),
(
MD003HeadingStyle::new(HeadingStyle::Setext1),
"# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
"# Feature: Checkout\n\n## Scenario: Documentation\n",
),
(
MD003HeadingStyle::default(),
"Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
"# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
),
];
for (rule, content, expected) in cases {
let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
assert!(!rule.should_skip(&mdg_ctx));
assert!(
!rule.check(&mdg_ctx).unwrap().is_empty(),
"MDG must still report non-ATX headings in {content:?}"
);
let fixed = rule.fix(&mdg_ctx).unwrap();
assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
}
}
#[test]
fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
assert!(direct.style_explicit);
let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
style: HeadingStyle::Setext1,
});
assert!(!defaulted.style_explicit);
let mut config = crate::config::Config::default();
let mut rule_config = crate::config::RuleConfig::default();
rule_config
.values
.insert("style".to_string(), toml::Value::String("setext".to_string()));
config.rules.insert("MD003".to_string(), rule_config);
let configured = MD003HeadingStyle::from_config(&config);
let configured = configured
.as_any()
.downcast_ref::<MD003HeadingStyle>()
.expect("MD003::from_config builds MD003HeadingStyle");
assert!(configured.style_explicit);
let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
let default_configured = default_configured
.as_any()
.downcast_ref::<MD003HeadingStyle>()
.expect("MD003::from_config builds MD003HeadingStyle");
assert!(!default_configured.style_explicit);
}
#[test]
fn test_literal_headings_are_preserved_beside_visible_style_fixes() {
for (style, literal, visible, target) in [
(HeadingStyle::Atx, "\nFake\n---\n", "Visible\n---", "## Visible"),
(HeadingStyle::Atx, "\n# Fake #\n", "# Visible #", "# Visible"),
(HeadingStyle::AtxClosed, "\n# Fake\n", "# Visible", "# Visible #"),
(HeadingStyle::Setext1, "\n# Fake\n", "# Visible", "Visible\n======="),
] {
let rule = MD003HeadingStyle::new(style);
for (flavor, code) in [
(
crate::config::MarkdownFlavor::Standard,
format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
),
(
crate::config::MarkdownFlavor::Hugo,
format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
),
] {
for ending in ["\n", "\r\n"] {
let source = format!("{code}\n\n{visible}\n").replace('\n', ending);
let expected = format!("{code}\n\n{target}\n").replace('\n', ending);
let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{source}");
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
expected,
"{source}"
);
let direct_expected = format!("{}{ending}{ending}{target}{ending}", code.replace('\n', ending));
assert_eq!(rule.fix(&ctx).unwrap(), direct_expected, "{source}");
}
}
}
}
#[test]
fn test_literal_heading_styles_do_not_choose_visible_style() {
let rule = MD003HeadingStyle::default();
for literal in ["# Fake #\n\n# Other #", "\nFake\n===\n\nOther\n===\n"] {
for (flavor, code) in [
(
crate::config::MarkdownFlavor::Standard,
format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
),
(
crate::config::MarkdownFlavor::Hugo,
format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
),
] {
let source = format!("{code}\n\n# Visible\n");
let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
}
#[test]
fn test_dynamic_and_mdx_body_heading_styles_remain_fixable() {
let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
for (flavor, source, target) in [
(
crate::config::MarkdownFlavor::Standard,
"# {{ title }}\n",
"# {{ title }} #\n",
),
(
crate::config::MarkdownFlavor::MDX,
"<div>\n\n# Visible\n\n</div>\n",
"<div>\n\n# Visible #\n\n</div>\n",
),
] {
let ctx = crate::lint_context::LintContext::new(source, flavor, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&ctx).unwrap(), target);
}
}
#[test]
fn test_unsafe_title_conversions_leave_safe_neighbors_fixable() {
for (style, source_title, adjacent, fixed_adjacent) in [
(
HeadingStyle::Setext1,
"# {% set unused=\"first \nlast\" %}{{ unused|length }}",
"# Adjacent",
"Adjacent\n========",
),
(
HeadingStyle::Setext1,
"# {{< note title=`first \nlast` >}}",
"# Adjacent",
"Adjacent\n========",
),
(
HeadingStyle::Atx,
"{% set unused=\"first \nlast\" %}{{ unused|length }}\n===",
"Adjacent\n===",
"# Adjacent",
),
(
HeadingStyle::Atx,
"{{< note title=`first \nlast` >}}\n===",
"Adjacent\n===",
"# Adjacent",
),
(
HeadingStyle::AtxClosed,
"# {% set unused=\"first \nlast\" %}{{ unused|length }}",
"# Adjacent",
"# Adjacent #",
),
] {
let rule = MD003HeadingStyle::new(style);
let flavor = if source_title.contains("{{<") {
crate::config::MarkdownFlavor::Hugo
} else {
crate::config::MarkdownFlavor::Standard
};
for ending in ["\n", "\r\n"] {
let source = format!("{source_title}\n\n{adjacent}\n").replace('\n', ending);
let expected = format!("{source_title}\n\n{fixed_adjacent}\n").replace('\n', ending);
let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2, "{source}");
assert!(warnings[0].fix.is_none(), "{source}");
assert!(warnings[1].fix.is_some());
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
expected
);
let direct_expected = format!(
"{}{ending}{ending}{fixed_adjacent}{ending}",
source_title.replace('\n', ending)
);
assert_eq!(rule.fix(&ctx).unwrap(), direct_expected);
}
}
}
#[test]
fn test_setext_style_conversion_preserves_literal_markup_and_hard_breaks() {
for title in [
"First\\\nSecond",
"First \nSecond",
"`first \nlast`",
"`first\n last`",
"<span title=\"first\nlast\">Visible</span>",
] {
for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
let rule = MD003HeadingStyle::new(style);
let source = format!("{title}\n===\n\nAdjacent\n===\n");
let adjacent = if style == HeadingStyle::Atx {
"# Adjacent"
} else {
"# Adjacent #"
};
let expected = format!("{title}\n===\n\n{adjacent}\n");
let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].fix.is_none(), "{source}");
assert!(warnings[1].fix.is_some());
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
}
#[test]
fn test_safe_dynamic_and_setext_style_conversions_remain_fixable() {
for (style, source, expected) in [
(HeadingStyle::Setext1, "# {{ title }}\n", "{{ title }}\n===========\n"),
(HeadingStyle::AtxClosed, "# {{ title }}\n", "# {{ title }} #\n"),
(HeadingStyle::Atx, "`first\nlast`\n===\n", "# `first last`\n"),
] {
let rule = MD003HeadingStyle::new(style);
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
#[test]
fn test_literal_closing_hashes_do_not_choose_heading_style() {
for (flavor, prefix, suffix) in [
(
crate::config::MarkdownFlavor::Standard,
"{% set unused=\"",
"\" %}{{ unused|length }}",
),
(crate::config::MarkdownFlavor::Hugo, "{{< note title=`", "` >}}"),
] {
for hashes in ["#", "###", "########"] {
for pad in ["", " ", "\t"] {
for ending in ["\n", "\r\n"] {
let title = format!("# {prefix}naïve {hashes}{pad}\nlast{suffix}");
let source = format!("{title}\n\n{title}\n\n# Adjacent\n").replace('\n', ending);
for style in [HeadingStyle::Atx, HeadingStyle::Consistent] {
let rule = MD003HeadingStyle::new(style);
let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
}
}
}
}
#[test]
fn test_literal_closing_hashes_still_require_a_real_closed_marker() {
let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
let source = "# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent\n";
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].fix.is_none());
assert!(warnings[1].fix.is_some());
assert_eq!(
rule.fix(&ctx).unwrap(),
"# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent #\n"
);
}
#[test]
fn test_real_closing_hashes_after_dynamic_titles_remain_closed() {
let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
for (flavor, source) in [
(crate::config::MarkdownFlavor::Standard, "# {{ title }} ###\n"),
(crate::config::MarkdownFlavor::Standard, "# {{ title }} ### {#custom}\n"),
(crate::config::MarkdownFlavor::MDX, "<div>\n\n# Visible ###\n\n</div>\n"),
] {
let ctx = crate::lint_context::LintContext::new(source, flavor, None);
assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), source);
}
}
#[test]
fn test_multiline_inline_titles_survive_heading_style_conversion() {
for node in ["[Link]", "![Image]"] {
for (open, close) in [('"', '"'), ('\'', '\''), ('(', ')')] {
for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
let rule = MD003HeadingStyle::new(style);
let title = format!("{node}(https://example.com {open}first\nlast{close})\n===");
let adjacent = if style == HeadingStyle::Atx {
"# Adjacent"
} else {
"# Adjacent #"
};
for ending in ["\n", "\r\n"] {
let source = format!("{title}\n\nAdjacent\n===\n").replace('\n', ending);
let expected = format!("{title}\n\n{adjacent}\n").replace('\n', ending);
let ctx = crate::lint_context::LintContext::new(
&source,
crate::config::MarkdownFlavor::Standard,
None,
);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2, "{source}");
assert!(warnings[0].fix.is_none());
assert!(warnings[1].fix.is_some());
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
expected
);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
}
}
}
#[test]
fn test_link_labels_and_external_titles_remain_style_fixable() {
let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
for (source, expected) in [
(
"[first\nlast](https://example.com \"single title\")\n===\n",
"# [first last](https://example.com \"single title\")\n",
),
(
"[first\nlast][ref]\n===\n\n[ref]: https://example.com \"first\nlast\"\n",
"# [first last][ref]\n\n[ref]: https://example.com \"first\nlast\"\n",
),
(
"![Image][ref]\n===\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
"# ![Image][ref]\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
),
] {
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].fix.is_some(), "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
#[test]
fn test_entity_newlines_do_not_block_safe_heading_style_joins() {
let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
for entity in ["
", " ", "
"] {
for (title, joined) in [
(
format!("[first\nlast](https://example.com \"title{entity}line\")"),
format!("[first last](https://example.com \"title{entity}line\")"),
),
(
format!(""),
format!(""),
),
(
format!(
"[\nLabel](https://example.com \"link{entity}title\")"
),
format!(
"[ Label](https://example.com \"link{entity}title\")"
),
),
] {
for flavor in [
crate::config::MarkdownFlavor::Standard,
crate::config::MarkdownFlavor::MDX,
] {
for ending in ["\n", "\r\n"] {
let source = format!("{title}\n===\n").replace('\n', ending);
let expected = format!("# {joined}\n").replace('\n', ending);
let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{source}");
assert!(warnings[0].fix.is_some(), "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
}
}
}
#[test]
fn test_mixed_inline_title_data_still_withholds_unsafe_heading_joins() {
let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
let source = "[Entity](https://example.com \"title
line\") [Raw](https://example.com \"first\nlast\")\n===\n\nAdjacent\n===\n";
let expected = "[Entity](https://example.com \"title
line\") [Raw](https://example.com \"first\nlast\")\n===\n\n# Adjacent\n";
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].fix.is_none());
assert!(warnings[1].fix.is_some());
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
#[test]
fn plain_atx_fixes_preserve_literal_terminal_hashes() {
let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
for title in ["Title #", "标题 ###", "#######", "Title\t####", "**Title** ###"] {
let first_hash = title.len() - title.bytes().rev().take_while(|&byte| byte == b'#').count();
let escaped = format!("{}\\{}", &title[..first_hash], &title[first_hash..]);
for ending in ["\n", "\r\n"] {
for body in [format!("{title}\n===\n"), format!("# {title} #\n")] {
let source = body.replace('\n', ending);
let expected = format!("# {escaped}{ending}");
let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{source}");
assert_eq!(
crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
expected
);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
}
}
}
}
}