Skip to main content

rumdl_lib/rules/
md082_no_empty_sections.rs

1//! Rule MD082: Flag headings with no content before the next heading.
2//!
3//! A heading immediately followed by another heading, with no rendered body in
4//! between, is an empty section. It usually signals a document that needs
5//! restructuring: a parent heading with nothing under it before its first
6//! child, or sibling headings with no body. This rule (opt-in) flags the
7//! heading whose section is empty.
8//!
9//! Detection only: a fix would have to invent placeholder prose, so there is no
10//! auto-fix. The `level` knob sets the minimum heading level that must have a
11//! body. With the default `level = 1` every heading is checked, including
12//! `# Title` straight into `## Section`. Set `level = 2` to exempt H1 while
13//! still requiring content under H2 and deeper.
14//!
15//! What does not count as a section body: blank lines, HTML comments,
16//! reference-link definitions (`[x]: url`), and lone thematic breaks (`---`).
17//! Everything else that renders counts: paragraphs, lists, code blocks, tables,
18//! blockquotes, and raw HTML. A `{#id}` attribute list on the line immediately
19//! after a heading is its anchor (the parser folds it into the heading), so it
20//! is treated as part of the heading rather than as the section body; an
21//! attribute list anywhere else renders as ordinary text and counts as content.
22
23use crate::lint_context::{HeadingStyle, LintContext};
24use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
25use crate::rule_config_serde::RuleConfig;
26use serde::{Deserialize, Serialize};
27
28fn default_level() -> u8 {
29    1
30}
31
32/// Configuration for MD082 (No empty sections).
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "kebab-case")]
35pub struct MD082Config {
36    /// Minimum heading level (1-6) that must be followed by content. A heading
37    /// whose level is at least this value is flagged when it is immediately
38    /// followed by another heading with no body in between. Default 1 checks
39    /// every heading; set to 2 to exempt H1 (so `# Title` straight into
40    /// `## Section` is allowed) while still requiring content under H2 and below.
41    #[serde(default = "default_level")]
42    pub level: u8,
43}
44
45impl Default for MD082Config {
46    fn default() -> Self {
47        Self { level: default_level() }
48    }
49}
50
51impl RuleConfig for MD082Config {
52    const RULE_NAME: &'static str = "MD082";
53}
54
55/// Position of a heading in the document, captured for adjacency analysis.
56struct HeadingPos {
57    /// 0-indexed line of the heading (the text line for a setext heading).
58    index: usize,
59    /// Heading level (1-6).
60    level: u8,
61    /// Whether the heading uses setext underlining (occupies two source lines).
62    is_setext: bool,
63    /// Whether the heading's anchor id came from a folded next-line `{#id}`
64    /// attribute list rather than inline `{#id}` syntax in the heading text.
65    /// Only a folded attribute list is part of the heading; an inline-id heading
66    /// followed by a `{#id}` line leaves that line as ordinary content.
67    id_from_next_line: bool,
68    /// Heading text, for the diagnostic message.
69    text: String,
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct MD082NoEmptySections {
74    config: MD082Config,
75}
76
77impl MD082NoEmptySections {
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    pub fn from_config_struct(config: MD082Config) -> Self {
83        Self { config }
84    }
85
86    /// Whether the line at `idx` (0-indexed) is a real section body line.
87    /// Blank lines, HTML comments, reference definitions, and lone thematic
88    /// breaks do not count as content. A heading's folded `{#id}` anchor line is
89    /// handled in `check` by advancing the scan past it, not here, because an
90    /// attribute list that is NOT folded renders as ordinary text and is content.
91    fn is_content_line(&self, ctx: &LintContext, idx: usize) -> bool {
92        let Some(li) = ctx.lines.get(idx) else {
93            return false;
94        };
95        if li.is_blank || li.in_html_comment {
96            return false;
97        }
98        // A lone thematic break is not a section body, but a `---` inside a
99        // blockquote or list is that container's content (it renders), so only a
100        // top-level thematic break is excluded.
101        if li.is_horizontal_rule && li.blockquote.is_none() && !li.in_list_block {
102            return false;
103        }
104        // Reference definitions: probe the first non-whitespace byte so an
105        // indented definition is still recognised by the byte-range lookup.
106        if ctx.is_in_reference_def(li.byte_offset + li.indent) {
107            return false;
108        }
109        true
110    }
111
112    fn warn_empty_section(&self, ctx: &LintContext, heading: &HeadingPos) -> LintWarning {
113        let line_content = ctx.lines.get(heading.index).map_or("", |l| l.content(ctx.content));
114        let end_column = line_content.chars().count() + 1;
115        LintWarning {
116            rule_name: Some(self.name().to_string()),
117            severity: Severity::Warning,
118            line: heading.index + 1,
119            column: 1,
120            end_line: heading.index + 1,
121            end_column,
122            message: format!("Heading '{}' has no content before the next heading", heading.text),
123            fix: None,
124        }
125    }
126}
127
128impl Rule for MD082NoEmptySections {
129    fn name(&self) -> &'static str {
130        "MD082"
131    }
132
133    fn description(&self) -> &'static str {
134        "Headings should have content before the next heading"
135    }
136
137    fn category(&self) -> RuleCategory {
138        RuleCategory::Heading
139    }
140
141    fn should_skip(&self, ctx: &LintContext) -> bool {
142        !ctx.has_valid_headings()
143    }
144
145    fn check(&self, ctx: &LintContext) -> LintResult {
146        let headings: Vec<HeadingPos> = ctx
147            .valid_headings()
148            .map(|h| HeadingPos {
149                index: h.line_num - 1,
150                level: h.heading.level,
151                is_setext: matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
152                // The id was folded from the next line when the heading has an id
153                // but its own text carries no inline `{#id}`.
154                id_from_next_line: h.heading.custom_id.is_some()
155                    && crate::utils::header_id_utils::extract_header_id(&h.heading.raw_text)
156                        .1
157                        .is_none(),
158                text: h.heading.text.clone(),
159            })
160            .collect();
161
162        if headings.len() < 2 {
163            return Ok(Vec::new());
164        }
165
166        let mut warnings = Vec::new();
167        for pair in headings.windows(2) {
168            let cur = &pair[0];
169            let next = &pair[1];
170
171            if cur.level < self.config.level {
172                continue;
173            }
174
175            // The section body begins after the heading construct. A setext
176            // heading occupies two source lines (text + underline); an ATX
177            // heading occupies one. Skipping the underline avoids counting it
178            // as content.
179            let content_start = if cur.is_setext { cur.index + 2 } else { cur.index + 1 };
180
181            // rumdl folds a `{#id}` attribute list on the line immediately after
182            // the heading into the heading's anchor (only when the heading has no
183            // inline id). That folded line is part of the heading, not the body,
184            // so skip it. Match by id so an unrelated attribute list - or one
185            // after a heading that already had an inline id - still counts as
186            // content, matching how the parser renders it.
187            let mut scan_start = content_start;
188            if cur.id_from_next_line
189                && let Some(li) = ctx.lines.get(content_start)
190                && crate::utils::header_id_utils::is_standalone_attr_list(li.content(ctx.content))
191            {
192                scan_start = content_start + 1;
193            }
194
195            let has_content = (scan_start..next.index).any(|idx| self.is_content_line(ctx, idx));
196            if !has_content {
197                warnings.push(self.warn_empty_section(ctx, cur));
198            }
199        }
200
201        Ok(warnings)
202    }
203
204    fn fix_capability(&self) -> FixCapability {
205        FixCapability::Unfixable
206    }
207
208    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
209        // Detection only: inventing a section body would be guesswork, so
210        // fixing is a no-op that returns the content unchanged.
211        Ok(ctx.content.to_string())
212    }
213
214    fn as_any(&self) -> &dyn std::any::Any {
215        self
216    }
217
218    crate::impl_rule_config_methods!(MD082Config);
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::config::MarkdownFlavor;
225    use crate::rule::LintWarning;
226
227    fn check(content: &str, config: MD082Config) -> Vec<LintWarning> {
228        let rule = MD082NoEmptySections::from_config_struct(config);
229        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
230        rule.check(&ctx).unwrap()
231    }
232
233    fn check_default(content: &str) -> Vec<LintWarning> {
234        check(content, MD082Config::default())
235    }
236
237    #[test]
238    fn default_level_is_one() {
239        assert_eq!(MD082Config::default().level, 1);
240    }
241
242    #[test]
243    fn flags_atx_heading_immediately_followed_by_heading() {
244        let w = check_default("# A\n## B\n\nBody text\n");
245        assert_eq!(w.len(), 1, "got: {w:?}");
246        assert_eq!(w[0].line, 1);
247        assert!(w[0].message.contains('A'), "got: {}", w[0].message);
248    }
249
250    #[test]
251    fn accepts_heading_with_paragraph_body() {
252        let w = check_default("# A\n\nSome text\n\n## B\n\nMore text\n");
253        assert!(w.is_empty(), "got: {w:?}");
254    }
255
256    #[test]
257    fn flags_nested_empty_section_from_issue() {
258        // The issue's second example: H1 has a body, but the H2 runs straight
259        // into an H3 with nothing between, so the H2 section is empty.
260        let content =
261            "# Level 1 heading\n\nLevel 1 content\n\n## Empty Section\n### Level 3 heading\n\nLevel 3 content\n";
262        let w = check_default(content);
263        assert_eq!(w.len(), 1, "got: {w:?}");
264        assert_eq!(w[0].line, 5);
265        assert!(w[0].message.contains("Empty Section"));
266    }
267
268    #[test]
269    fn default_level_flags_h1_into_h2() {
270        let w = check_default("# Title\n## Section\n\nBody\n");
271        assert_eq!(w.len(), 1, "got: {w:?}");
272        assert_eq!(w[0].line, 1);
273    }
274
275    #[test]
276    fn level_2_exempts_h1_but_flags_h2() {
277        let config = MD082Config { level: 2 };
278        // H1 -> H2 with no body: exempt at level 2.
279        assert!(check("# Title\n## Section\n\nBody\n", config.clone()).is_empty());
280        // H2 -> H3 with no body: still flagged at level 2.
281        let w = check("# Title\n\nIntro\n\n## A\n### B\n\nBody\n", config);
282        assert_eq!(w.len(), 1, "got: {w:?}");
283        assert_eq!(w[0].line, 5);
284    }
285
286    #[test]
287    fn flags_setext_heading_into_setext_heading() {
288        // "Title" (setext H1) runs straight into "Section" (setext H2) with only
289        // the underline between, so the H1 section is empty.
290        let w = check_default("Title\n=====\nSection\n-------\ncontent\n");
291        assert_eq!(w.len(), 1, "got: {w:?}");
292        assert_eq!(w[0].line, 1);
293    }
294
295    #[test]
296    fn accepts_setext_heading_with_body() {
297        let w = check_default("Title\n=====\n\nSome body\n\nSection\n-------\n\nMore\n");
298        assert!(w.is_empty(), "got: {w:?}");
299    }
300
301    #[test]
302    fn blank_lines_do_not_count_as_content() {
303        let w = check_default("# A\n\n\n## B\n\ncontent\n");
304        assert_eq!(w.len(), 1, "got: {w:?}");
305        assert_eq!(w[0].line, 1);
306    }
307
308    #[test]
309    fn html_comment_does_not_count_as_content() {
310        let w = check_default("# A\n\n<!-- a comment -->\n\n## B\n\ncontent\n");
311        assert_eq!(w.len(), 1, "got: {w:?}");
312        assert_eq!(w[0].line, 1);
313    }
314
315    #[test]
316    fn reference_definition_does_not_count_as_content() {
317        let w = check_default("# A\n\n[ref]: https://example.com\n\n## B\n\ncontent\n");
318        assert_eq!(w.len(), 1, "got: {w:?}");
319        assert_eq!(w[0].line, 1);
320    }
321
322    #[test]
323    fn thematic_break_does_not_count_as_content() {
324        // All three CommonMark thematic-break styles are treated as non-content.
325        for marker in ["---", "***", "___"] {
326            let input = format!("# A\n\n{marker}\n\n## B\n\ncontent\n");
327            let w = check_default(&input);
328            assert_eq!(w.len(), 1, "marker {marker:?}: got: {w:?}");
329            assert_eq!(w[0].line, 1, "marker {marker:?}");
330        }
331    }
332
333    #[test]
334    fn code_block_counts_as_content() {
335        let w = check_default("# A\n\n```\ncode\n```\n\n## B\n\ntext\n");
336        assert!(w.is_empty(), "got: {w:?}");
337    }
338
339    #[test]
340    fn list_counts_as_content() {
341        let w = check_default("# A\n\n- item\n\n## B\n\ntext\n");
342        assert!(w.is_empty(), "got: {w:?}");
343    }
344
345    #[test]
346    fn raw_html_block_counts_as_content() {
347        let w = check_default("# A\n\n<div>hello</div>\n\n## B\n\ntext\n");
348        assert!(w.is_empty(), "got: {w:?}");
349    }
350
351    #[test]
352    fn trailing_heading_at_eof_is_not_flagged() {
353        // Only heading-into-heading is in scope; a final heading with no body is
354        // not flagged.
355        let w = check_default("# A\n\nbody\n\n## B\n");
356        assert!(w.is_empty(), "got: {w:?}");
357    }
358
359    #[test]
360    fn single_heading_is_not_flagged() {
361        assert!(check_default("# Only heading\n\ncontent\n").is_empty());
362    }
363
364    #[test]
365    fn document_without_headings_is_not_flagged() {
366        assert!(check_default("Just some text\nand more text\n").is_empty());
367    }
368
369    #[test]
370    fn invalid_heading_renders_as_content() {
371        // `#nospace` (lowercase, no space after `#`) is not a CommonMark heading;
372        // it renders as a paragraph, so it counts as a body and the section is
373        // not empty. (Uppercase-first variants are classified as valid by the
374        // heading detector's heuristic; use lowercase to ensure invalidity.)
375        let w = check_default("# A\n\n#nospace is text\n\n## B\n\ntext\n");
376        assert!(w.is_empty(), "got: {w:?}");
377    }
378
379    #[test]
380    fn standalone_attr_list_does_not_count_as_content() {
381        // `{#a}` on its own line is folded into the heading's id, not a body.
382        let w = check_default("# A\n{#a}\n## B\n\ntext\n");
383        assert_eq!(w.len(), 1, "got: {w:?}");
384        assert_eq!(w[0].line, 1);
385    }
386
387    #[test]
388    fn setext_standalone_attr_list_does_not_count_as_content() {
389        // For setext the attr list sits on the line after the underline.
390        let w = check_default("Title\n=====\n{#a}\nSection\n-------\ntext\n");
391        assert_eq!(w.len(), 1, "got: {w:?}");
392        assert_eq!(w[0].line, 1);
393    }
394
395    #[test]
396    fn non_folded_attr_list_counts_as_content() {
397        // An attribute list separated from the heading by a blank line is NOT
398        // folded into the heading; the parser renders it as an ordinary
399        // paragraph, so it is real content and the section is not empty.
400        let w = check_default("## A\n\n{#stray}\n\n## B\n\ntext\n");
401        assert!(w.is_empty(), "got: {w:?}");
402    }
403
404    #[test]
405    fn setext_non_folded_attr_list_counts_as_content() {
406        // Setext heading with a blank line between the underline and the attr
407        // list: not folded, so the attr list counts as content.
408        let w = check_default("Title\n=====\n\n{#a}\nSection\n-------\ntext\n");
409        assert!(w.is_empty(), "got: {w:?}");
410    }
411
412    #[test]
413    fn inline_id_heading_with_following_attr_list_counts_as_content() {
414        // The heading already has an inline id, so the next-line attr list is
415        // NOT folded; it renders as a paragraph and counts as content.
416        let w = check_default("## A {#x}\n{#y}\n## B\n\ntext\n");
417        assert!(w.is_empty(), "got: {w:?}");
418    }
419
420    #[test]
421    fn inline_id_then_matching_attr_list_counts_as_content() {
422        // The heading has an INLINE id `{#x}`, so the parser does not fold the
423        // following `{#x}` line; it renders as a paragraph and counts as content
424        // even though its id matches the heading's. Must not flag.
425        let w = check_default("## A {#x}\n{#x}\n## B\n\ntext\n");
426        assert!(w.is_empty(), "got: {w:?}");
427    }
428
429    #[test]
430    fn setext_inline_id_then_matching_attr_list_counts_as_content() {
431        // Same as above for a setext heading with an inline id.
432        let w = check_default("Title {#x}\n======\n{#x}\n## B\n\ntext\n");
433        assert!(w.is_empty(), "got: {w:?}");
434    }
435
436    #[test]
437    fn blockquoted_thematic_break_counts_as_content() {
438        // `> ---` renders as a blockquote containing a thematic break: visible
439        // content, not a bare top-level break. Must not flag.
440        let w = check_default("# A\n\n> ---\n\n## B\n\ntext\n");
441        assert!(w.is_empty(), "got: {w:?}");
442    }
443
444    #[test]
445    fn multiline_reference_definition_is_an_empty_section() {
446        // A reference definition is invisible, even when its title sits on a
447        // continuation line. A section whose only body is such a definition is
448        // empty and is flagged.
449        let w = check_default("# A\n\n[ref]: https://example.com\n  \"title\"\n\n## B\n\ntext\n");
450        assert_eq!(w.len(), 1, "got: {w:?}");
451        assert_eq!(w[0].line, 1);
452    }
453}