use crate::lint_context::{HeadingStyle, LintContext};
use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use serde::{Deserialize, Serialize};
fn default_level() -> u8 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct MD082Config {
#[serde(default = "default_level")]
pub level: u8,
#[serde(default)]
pub allow_parent_headings: bool,
}
impl Default for MD082Config {
fn default() -> Self {
Self {
level: default_level(),
allow_parent_headings: false,
}
}
}
impl RuleConfig for MD082Config {
const RULE_NAME: &'static str = "MD082";
}
struct HeadingPos {
index: usize,
first_index: usize,
level: u8,
is_setext: bool,
id_from_next_line: bool,
text: String,
}
#[derive(Debug, Clone, Default)]
pub struct MD082NoEmptySections {
config: MD082Config,
}
impl MD082NoEmptySections {
pub fn new() -> Self {
Self::default()
}
pub fn from_config_struct(config: MD082Config) -> Self {
Self { config }
}
fn is_content_line(&self, ctx: &LintContext, idx: usize) -> bool {
let Some(li) = ctx.lines.get(idx) else {
return false;
};
if li.is_blank || li.in_html_comment {
return false;
}
if li.is_horizontal_rule && li.blockquote.is_none() && !li.in_list_block {
return false;
}
let text = li.content(ctx.content);
let content_start = match li.blockquote.as_deref() {
Some(quote) => {
text.len().saturating_sub(quote.content.len())
+ (quote.content.len() - quote.content.trim_start().len())
}
None => li.indent,
};
if ctx.is_in_reference_def(li.byte_offset + content_start) {
return false;
}
if li.blockquote.is_some() {
let start = li.byte_offset + content_start;
let end = li.byte_offset + text.trim_end().len();
if crate::utils::skip_context::is_line_entirely_in_html_comment(ctx.html_comment_ranges(), start, end) {
return false;
}
}
true
}
fn warn_empty_section(&self, ctx: &LintContext, heading: &HeadingPos) -> LintWarning {
let line_content = ctx.lines.get(heading.index).map_or("", |l| l.content(ctx.content));
let end_column = line_content.chars().count() + 1;
LintWarning {
rule_name: Some(self.name().to_string()),
severity: Severity::Warning,
line: heading.first_index + 1,
column: 1,
end_line: heading.index + 1,
end_column,
message: format!("Heading '{}' has no content before the next heading", heading.text),
fix: None,
}
}
}
impl Rule for MD082NoEmptySections {
fn name(&self) -> &'static str {
"MD082"
}
fn description(&self) -> &'static str {
"Headings should have content before the next heading"
}
fn category(&self) -> RuleCategory {
RuleCategory::Heading
}
fn should_skip(&self, ctx: &LintContext) -> bool {
ctx.headings().next().is_none()
}
fn check(&self, ctx: &LintContext) -> LintResult {
let headings: Vec<HeadingPos> = ctx
.headings()
.filter(|h| {
let marker_idx = h.line_num - 1
+ usize::from(matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2));
!ctx.lines.get(marker_idx).is_some_and(|line| {
let offset = line.byte_offset + line.indent;
ctx.is_inside_template_code(offset) || ctx.is_inside_mdx_code(offset)
})
})
.map(|h| HeadingPos {
index: h.line_num - 1,
first_index: h.first_line_num() - 1,
level: h.heading.level,
is_setext: matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
id_from_next_line: h.heading.custom_id.is_some()
&& crate::utils::header_id_utils::extract_header_id(&h.heading.raw_text)
.1
.is_none(),
text: h.heading.text.clone(),
})
.collect();
if headings.len() < 2 {
return Ok(Vec::new());
}
let mut warnings = Vec::new();
for pair in headings.windows(2) {
let cur = &pair[0];
let next = &pair[1];
if cur.level < self.config.level {
continue;
}
if self.config.allow_parent_headings && next.level > cur.level {
continue;
}
let content_start = if cur.is_setext { cur.index + 2 } else { cur.index + 1 };
let mut scan_start = content_start;
if cur.id_from_next_line
&& let Some(li) = ctx.lines.get(content_start)
&& crate::utils::header_id_utils::is_standalone_attr_list(li.content(ctx.content))
{
scan_start = content_start + 1;
}
let has_content = (scan_start..next.first_index).any(|idx| self.is_content_line(ctx, idx));
if !has_content {
warnings.push(self.warn_empty_section(ctx, cur));
}
}
Ok(warnings)
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
Ok(ctx.content.to_string())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_methods!(MD082Config);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
use crate::rule::LintWarning;
fn check(content: &str, config: MD082Config) -> Vec<LintWarning> {
let rule = MD082NoEmptySections::from_config_struct(config);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
rule.check(&ctx).unwrap()
}
fn check_default(content: &str) -> Vec<LintWarning> {
check(content, MD082Config::default())
}
#[test]
fn default_level_is_one() {
assert_eq!(MD082Config::default().level, 1);
}
#[test]
fn flags_atx_heading_immediately_followed_by_heading() {
let w = check_default("# A\n## B\n\nBody text\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
assert!(w[0].message.contains('A'), "got: {}", w[0].message);
}
#[test]
fn flags_empty_section_before_multi_line_setext_heading() {
let w = check_default("# Empty\n\nNext one\nsecond\n===\n\nBody\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
assert!(w[0].message.contains("Empty"), "got: {}", w[0].message);
}
#[test]
fn accepts_heading_with_paragraph_body() {
let w = check_default("# A\n\nSome text\n\n## B\n\nMore text\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn flags_nested_empty_section_from_issue() {
let content =
"# Level 1 heading\n\nLevel 1 content\n\n## Empty Section\n### Level 3 heading\n\nLevel 3 content\n";
let w = check_default(content);
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 5);
assert!(w[0].message.contains("Empty Section"));
}
#[test]
fn default_level_flags_h1_into_h2() {
let w = check_default("# Title\n## Section\n\nBody\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn level_2_exempts_h1_but_flags_h2() {
let config = MD082Config {
level: 2,
..Default::default()
};
assert!(check("# Title\n## Section\n\nBody\n", config.clone()).is_empty());
let w = check("# Title\n\nIntro\n\n## A\n### B\n\nBody\n", config);
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 5);
}
#[test]
fn flags_setext_heading_into_setext_heading() {
let w = check_default("Title\n=====\nSection\n-------\ncontent\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn accepts_setext_heading_with_body() {
let w = check_default("Title\n=====\n\nSome body\n\nSection\n-------\n\nMore\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn blank_lines_do_not_count_as_content() {
let w = check_default("# A\n\n\n## B\n\ncontent\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn html_comment_does_not_count_as_content() {
let w = check_default("# A\n\n<!-- a comment -->\n\n## B\n\ncontent\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn reference_definition_does_not_count_as_content() {
let w = check_default("# A\n\n[ref]: https://example.com\n\n## B\n\ncontent\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn thematic_break_does_not_count_as_content() {
for marker in ["---", "***", "___"] {
let input = format!("# A\n\n{marker}\n\n## B\n\ncontent\n");
let w = check_default(&input);
assert_eq!(w.len(), 1, "marker {marker:?}: got: {w:?}");
assert_eq!(w[0].line, 1, "marker {marker:?}");
}
}
#[test]
fn code_block_counts_as_content() {
let w = check_default("# A\n\n```\ncode\n```\n\n## B\n\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn list_counts_as_content() {
let w = check_default("# A\n\n- item\n\n## B\n\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn raw_html_block_counts_as_content() {
let w = check_default("# A\n\n<div>hello</div>\n\n## B\n\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn trailing_heading_at_eof_is_not_flagged() {
let w = check_default("# A\n\nbody\n\n## B\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn single_heading_is_not_flagged() {
assert!(check_default("# Only heading\n\ncontent\n").is_empty());
}
#[test]
fn document_without_headings_is_not_flagged() {
assert!(check_default("Just some text\nand more text\n").is_empty());
}
#[test]
fn invalid_heading_renders_as_content() {
let w = check_default("# A\n\n#nospace is text\n\n## B\n\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn standalone_attr_list_does_not_count_as_content() {
let w = check_default("# A\n{#a}\n## B\n\ntext\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn setext_standalone_attr_list_does_not_count_as_content() {
let w = check_default("Title\n=====\n{#a}\nSection\n-------\ntext\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn non_folded_attr_list_counts_as_content() {
let w = check_default("## A\n\n{#stray}\n\n## B\n\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn setext_non_folded_attr_list_counts_as_content() {
let w = check_default("Title\n=====\n\n{#a}\n\nSection\n-------\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn attr_list_above_a_setext_underline_is_heading_text() {
let w = check_default("Title\n=====\n\n{#a}\nSection\n-------\ntext\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn inline_id_heading_with_following_attr_list_counts_as_content() {
let w = check_default("## A {#x}\n{#y}\n## B\n\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn inline_id_then_matching_attr_list_counts_as_content() {
let w = check_default("## A {#x}\n{#x}\n## B\n\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn setext_inline_id_then_matching_attr_list_counts_as_content() {
let w = check_default("Title {#x}\n======\n{#x}\n## B\n\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
#[test]
fn blockquoted_thematic_break_counts_as_content() {
let w = check_default("# A\n\n> ---\n\n## B\n\ntext\n");
assert!(w.is_empty(), "got: {w:?}");
}
fn allow_parents() -> MD082Config {
MD082Config {
allow_parent_headings: true,
..Default::default()
}
}
#[test]
fn default_does_not_allow_parent_headings() {
assert!(!MD082Config::default().allow_parent_headings);
}
#[test]
fn allow_parent_headings_accepts_a_heading_followed_by_a_deeper_one() {
let content = "# H1\n\n## H2\n\ncontent...\n";
assert_eq!(check_default(content).len(), 1, "the default still flags it");
assert!(check(content, allow_parents()).is_empty());
}
#[test]
fn allow_parent_headings_still_flags_a_sibling_heading() {
let content = "# H1\n\n# H1\n\ncontent...\n";
let w = check(content, allow_parents());
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn allow_parent_headings_still_flags_a_shallower_next_heading() {
let content = "# Title\n\nIntro\n\n## A\n# B\n\ncontent\n";
let w = check(content, allow_parents());
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 5);
}
#[test]
fn allow_parent_headings_accepts_a_skipped_level() {
assert!(check("# A\n\n### C\n\ncontent\n", allow_parents()).is_empty());
}
#[test]
fn allow_parent_headings_accepts_setext_into_a_deeper_setext() {
assert!(check("Title\n=====\nSection\n-------\ncontent\n", allow_parents()).is_empty());
}
#[test]
fn allow_parent_headings_still_flags_the_last_empty_sibling() {
let content = "# Title\n\n## A\n\nbody\n\n## B\n## C\n\nbody\n";
let w = check(content, allow_parents());
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 7);
}
#[test]
fn allow_parent_headings_respects_the_level_floor() {
let config = MD082Config {
level: 2,
allow_parent_headings: true,
};
assert!(check("# Title\n# Other\n\nbody\n", config.clone()).is_empty());
let w = check("# Title\n\nIntro\n\n## A\n## B\n\nbody\n", config);
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 5);
}
#[test]
fn multiline_reference_definition_is_an_empty_section() {
let w = check_default("# A\n\n[ref]: https://example.com\n \"title\"\n\n## B\n\ntext\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 1);
}
#[test]
fn literal_atx_headings_do_not_create_empty_sections() {
let rule = MD082NoEmptySections::new();
for (flavor, code) in [
(
MarkdownFlavor::Standard,
"{% set unused=\"first\n\n# Fake\n\n## Phantom\nlast\" %}{{ unused|length }}",
),
(
MarkdownFlavor::Hugo,
"{{< note title=`first\n\n# Fake\n\n## Phantom\nlast` >}}",
),
] {
for ending in ["\n", "\r\n"] {
let source = format!("{code}\n\n# Visible\n\n## Next\n\nBody.\n").replace('\n', ending);
let ctx = LintContext::new(&source, flavor, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{source}");
assert!(warnings[0].message.contains("'Visible'"));
assert_eq!(warnings[0].line, code.lines().count() + 2);
assert!(warnings[0].fix.is_none());
}
}
}
#[test]
fn literal_setext_markers_do_not_create_empty_sections() {
let rule = MD082NoEmptySections::new();
for (flavor, code) in [
(
MarkdownFlavor::Standard,
"{% set unused=\"first\n\nFake\n===\n\nPhantom\n---\nlast\" %}{{ unused|length }}",
),
(
MarkdownFlavor::Hugo,
"{{< note title=`first\n\nFake\n===\n\nPhantom\n---\nlast` >}}",
),
] {
let source = format!("{code}\n\n# Visible\n\n## Next\n\nBody.\n");
let ctx = LintContext::new(&source, flavor, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{source}");
assert!(warnings[0].message.contains("'Visible'"));
}
}
#[test]
fn dynamic_and_mdx_body_empty_headings_remain_eligible() {
let rule = MD082NoEmptySections::new();
for (flavor, source, line, end_line) in [
(MarkdownFlavor::Standard, "# {{ title }}\n\n## Next\n\nBody.\n", 1, 1),
(
MarkdownFlavor::Standard,
"First\n{{ title }}\n===\n\n# Next\n\nBody.\n",
1,
2,
),
(
MarkdownFlavor::MDX,
"<div>\n\n# Visible\n\n## Next\n\nBody.\n\n</div>\n",
3,
3,
),
] {
let ctx = LintContext::new(source, flavor, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{source}");
assert_eq!((warnings[0].line, warnings[0].end_line), (line, end_line));
}
}
mod quoted_headings {
use super::*;
fn lines(content: &str) -> Vec<usize> {
check_default(content).iter().map(|w| w.line).collect()
}
#[test]
fn heading_followed_directly_by_a_quoted_heading_is_empty() {
assert_eq!(lines("# A\n\n> ## B\n> body\n"), vec![1]);
assert_eq!(lines("# A\n\n> > ## B\n> > body\n"), vec![1]);
assert!(lines("# A\n\n- x\n\n > ## B\n > body\n").is_empty());
assert_eq!(lines("# A\n\n- x\n\n > ## B\n > ## C\n > body\n"), vec![5]);
assert_eq!(lines("# A\n\n> - ## B\n> body\n"), vec![1]);
}
#[test]
fn quoted_section_with_text_is_not_empty() {
assert!(lines("> # A\n> body\n>\n> ## B\n> more\n").is_empty());
}
#[test]
fn quoted_empty_section_is_reported_on_its_heading_line() {
assert_eq!(lines("> # A\n>\n> ## B\n> body\n"), vec![1]);
assert_eq!(lines("> # A\n> body\n\n## B\n\ntext\n"), Vec::<usize>::new());
assert_eq!(lines("> # A\n\n# B\n\ntext\n"), vec![1]);
}
#[test]
fn lone_quote_markers_and_quoted_definitions_are_not_content() {
assert_eq!(lines("> # A\n>\n>\n> ## B\n> body\n"), vec![1]);
assert_eq!(lines("> # A\n> [ref]: /url\n> ## B\n> body\n"), vec![1]);
}
#[test]
fn quoted_html_comments_are_not_content() {
assert_eq!(lines("> # A\n> <!-- c -->\n> # B\n> body\n"), vec![1]);
assert_eq!(lines("> # A\n> <!-- c\n> more -->\n> # B\n> body\n"), vec![1]);
assert_eq!(lines("> # A\n> <!-- c --> text\n> # B\n> body\n"), Vec::<usize>::new());
assert_eq!(lines("# A\n<!-- c -->\n# B\nbody\n"), vec![1]);
}
#[test]
fn quoted_setext_heading_ends_the_section_at_its_first_text_line() {
assert_eq!(lines("# A\n\n> First\n> second\n> ===\n> body\n"), vec![1]);
}
#[test]
fn quote_only_document_is_not_skipped() {
assert_eq!(lines("> # A\n> ## B\n> body\n"), vec![1]);
let ctx = LintContext::new("> # A\n> ## B\n> body\n", crate::config::MarkdownFlavor::Standard, None);
assert!(!MD082NoEmptySections::default().should_skip(&ctx));
let ctx = LintContext::new("text\n", crate::config::MarkdownFlavor::Standard, None);
assert!(MD082NoEmptySections::default().should_skip(&ctx));
}
}
}