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