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 the heading is recorded on (the last text line for a
72    /// setext heading, whose underline is the line after it).
73    index: usize,
74    /// 0-indexed first line holding the heading text. A setext heading's text is
75    /// the whole paragraph its underline ends, so the section above it ends here.
76    first_index: usize,
77    /// Heading level (1-6).
78    level: u8,
79    /// Whether the heading uses setext underlining (occupies two source lines).
80    is_setext: bool,
81    /// Whether the heading's anchor id came from a folded next-line `{#id}`
82    /// attribute list rather than inline `{#id}` syntax in the heading text.
83    /// Only a folded attribute list is part of the heading; an inline-id heading
84    /// followed by a `{#id}` line leaves that line as ordinary content.
85    id_from_next_line: bool,
86    /// Heading text, for the diagnostic message.
87    text: String,
88}
89
90#[derive(Debug, Clone, Default)]
91pub struct MD082NoEmptySections {
92    config: MD082Config,
93}
94
95impl MD082NoEmptySections {
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    pub fn from_config_struct(config: MD082Config) -> Self {
101        Self { config }
102    }
103
104    /// Whether the line at `idx` (0-indexed) is a real section body line.
105    /// Blank lines, HTML comments, reference definitions, and lone thematic
106    /// breaks do not count as content. A heading's folded `{#id}` anchor line is
107    /// handled in `check` by advancing the scan past it, not here, because an
108    /// attribute list that is NOT folded renders as ordinary text and is content.
109    fn is_content_line(&self, ctx: &LintContext, idx: usize) -> bool {
110        let Some(li) = ctx.lines.get(idx) else {
111            return false;
112        };
113        if li.is_blank || li.in_html_comment {
114            return false;
115        }
116        // A lone thematic break is not a section body, but a `---` inside a
117        // blockquote or list is that container's content (it renders), so only a
118        // top-level thematic break is excluded.
119        if li.is_horizontal_rule && li.blockquote.is_none() && !li.in_list_block {
120            return false;
121        }
122        // Reference definitions: probe the first non-whitespace byte after any
123        // blockquote prefix, so an indented or quoted definition is still
124        // recognised by the byte-range lookup.
125        let text = li.content(ctx.content);
126        let content_start = match li.blockquote.as_deref() {
127            Some(quote) => {
128                text.len().saturating_sub(quote.content.len())
129                    + (quote.content.len() - quote.content.trim_start().len())
130            }
131            None => li.indent,
132        };
133        if ctx.is_in_reference_def(li.byte_offset + content_start) {
134            return false;
135        }
136        // `in_html_comment` is measured from the line's margin, so a quoted
137        // comment line is checked from where its quote content starts.
138        if li.blockquote.is_some() {
139            let start = li.byte_offset + content_start;
140            let end = li.byte_offset + text.trim_end().len();
141            if crate::utils::skip_context::is_line_entirely_in_sorted_html_comment(
142                ctx.html_comment_ranges(),
143                start,
144                end,
145            ) {
146                return false;
147            }
148        }
149        true
150    }
151
152    fn warn_empty_section(&self, ctx: &LintContext, heading: &HeadingPos) -> LintWarning {
153        let line_content = ctx.lines.get(heading.index).map_or("", |l| l.content(ctx.content));
154        let end_column = line_content.chars().count() + 1;
155        // The warning covers the whole heading, so it starts on the first text
156        // line and ends on the line the heading is recorded on.
157        LintWarning {
158            rule_name: Some(self.name().to_string()),
159            severity: Severity::Warning,
160            line: heading.first_index + 1,
161            column: 1,
162            end_line: heading.index + 1,
163            end_column,
164            message: format!("Heading '{}' has no content before the next heading", heading.text),
165            fix: None,
166        }
167    }
168}
169
170impl Rule for MD082NoEmptySections {
171    fn name(&self) -> &'static str {
172        "MD082"
173    }
174
175    fn description(&self) -> &'static str {
176        "Headings should have content before the next heading"
177    }
178
179    fn category(&self) -> RuleCategory {
180        RuleCategory::Heading
181    }
182
183    fn should_skip(&self, ctx: &LintContext) -> bool {
184        ctx.headings().next().is_none()
185    }
186
187    fn check(&self, ctx: &LintContext) -> LintResult {
188        let headings: Vec<HeadingPos> = ctx
189            .headings()
190            .filter(|h| {
191                let marker_idx = h.line_num - 1
192                    + usize::from(matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2));
193                !ctx.lines.get(marker_idx).is_some_and(|line| {
194                    let offset = line.byte_offset + line.indent;
195                    ctx.is_inside_template_code(offset) || ctx.is_inside_mdx_code(offset)
196                })
197            })
198            .map(|h| HeadingPos {
199                index: h.line_num - 1,
200                first_index: h.first_line_num() - 1,
201                level: h.heading.level,
202                is_setext: matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
203                // The id was folded from the next line when the heading has an id
204                // but its own text carries no inline `{#id}`.
205                id_from_next_line: h.heading.custom_id.is_some()
206                    && crate::utils::header_id_utils::extract_header_id(&h.heading.raw_text)
207                        .1
208                        .is_none(),
209                text: h.heading.text.clone(),
210            })
211            .collect();
212
213        if headings.len() < 2 {
214            return Ok(Vec::new());
215        }
216
217        let mut warnings = Vec::new();
218        for pair in headings.windows(2) {
219            let cur = &pair[0];
220            let next = &pair[1];
221
222            if cur.level < self.config.level {
223                continue;
224            }
225
226            // A deeper heading opens a subsection of this one, so the subsections
227            // stand in for a body the parent never needed.
228            if self.config.allow_parent_headings && next.level > cur.level {
229                continue;
230            }
231
232            // The section body begins after the heading construct. A setext
233            // heading occupies two source lines (text + underline); an ATX
234            // heading occupies one. Skipping the underline avoids counting it
235            // as content.
236            let content_start = if cur.is_setext { cur.index + 2 } else { cur.index + 1 };
237
238            // rumdl folds a `{#id}` attribute list on the line immediately after
239            // the heading into the heading's anchor (only when the heading has no
240            // inline id). That folded line is part of the heading, not the body,
241            // so skip it. Match by id so an unrelated attribute list - or one
242            // after a heading that already had an inline id - still counts as
243            // content, matching how the parser renders it.
244            let mut scan_start = content_start;
245            if cur.id_from_next_line
246                && let Some(li) = ctx.lines.get(content_start)
247                && crate::utils::header_id_utils::is_standalone_attr_list(li.content(ctx.content))
248            {
249                scan_start = content_start + 1;
250            }
251
252            // The section runs up to the next heading's first text line, which
253            // for a setext heading is the start of the paragraph its underline
254            // ends rather than the line the heading is recorded on.
255            let has_content = (scan_start..next.first_index).any(|idx| self.is_content_line(ctx, idx));
256            if !has_content {
257                warnings.push(self.warn_empty_section(ctx, cur));
258            }
259        }
260
261        Ok(warnings)
262    }
263
264    fn fix_capability(&self) -> FixCapability {
265        FixCapability::Unfixable
266    }
267
268    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
269        // Detection only: inventing a section body would be guesswork, so
270        // fixing is a no-op that returns the content unchanged.
271        Ok(ctx.content.to_string())
272    }
273
274    fn as_any(&self) -> &dyn std::any::Any {
275        self
276    }
277
278    crate::impl_rule_config_methods!(MD082Config);
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::config::MarkdownFlavor;
285    use crate::rule::LintWarning;
286
287    fn check(content: &str, config: MD082Config) -> Vec<LintWarning> {
288        let rule = MD082NoEmptySections::from_config_struct(config);
289        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
290        rule.check(&ctx).unwrap()
291    }
292
293    fn check_default(content: &str) -> Vec<LintWarning> {
294        check(content, MD082Config::default())
295    }
296
297    #[test]
298    fn default_level_is_one() {
299        assert_eq!(MD082Config::default().level, 1);
300    }
301
302    #[test]
303    fn flags_atx_heading_immediately_followed_by_heading() {
304        let w = check_default("# A\n## B\n\nBody text\n");
305        assert_eq!(w.len(), 1, "got: {w:?}");
306        assert_eq!(w[0].line, 1);
307        assert!(w[0].message.contains('A'), "got: {}", w[0].message);
308    }
309
310    #[test]
311    fn flags_empty_section_before_multi_line_setext_heading() {
312        // A setext heading's text is the whole paragraph its underline ends, so
313        // the section above it ends at the first of those lines. Nothing sits
314        // between the two headings, so the first section is empty.
315        let w = check_default("# Empty\n\nNext one\nsecond\n===\n\nBody\n");
316        assert_eq!(w.len(), 1, "got: {w:?}");
317        assert_eq!(w[0].line, 1);
318        assert!(w[0].message.contains("Empty"), "got: {}", w[0].message);
319    }
320
321    #[test]
322    fn accepts_heading_with_paragraph_body() {
323        let w = check_default("# A\n\nSome text\n\n## B\n\nMore text\n");
324        assert!(w.is_empty(), "got: {w:?}");
325    }
326
327    #[test]
328    fn flags_nested_empty_section_from_issue() {
329        // The issue's second example: H1 has a body, but the H2 runs straight
330        // into an H3 with nothing between, so the H2 section is empty.
331        let content =
332            "# Level 1 heading\n\nLevel 1 content\n\n## Empty Section\n### Level 3 heading\n\nLevel 3 content\n";
333        let w = check_default(content);
334        assert_eq!(w.len(), 1, "got: {w:?}");
335        assert_eq!(w[0].line, 5);
336        assert!(w[0].message.contains("Empty Section"));
337    }
338
339    #[test]
340    fn default_level_flags_h1_into_h2() {
341        let w = check_default("# Title\n## Section\n\nBody\n");
342        assert_eq!(w.len(), 1, "got: {w:?}");
343        assert_eq!(w[0].line, 1);
344    }
345
346    #[test]
347    fn level_2_exempts_h1_but_flags_h2() {
348        let config = MD082Config {
349            level: 2,
350            ..Default::default()
351        };
352        // H1 -> H2 with no body: exempt at level 2.
353        assert!(check("# Title\n## Section\n\nBody\n", config.clone()).is_empty());
354        // H2 -> H3 with no body: still flagged at level 2.
355        let w = check("# Title\n\nIntro\n\n## A\n### B\n\nBody\n", config);
356        assert_eq!(w.len(), 1, "got: {w:?}");
357        assert_eq!(w[0].line, 5);
358    }
359
360    #[test]
361    fn flags_setext_heading_into_setext_heading() {
362        // "Title" (setext H1) runs straight into "Section" (setext H2) with only
363        // the underline between, so the H1 section is empty.
364        let w = check_default("Title\n=====\nSection\n-------\ncontent\n");
365        assert_eq!(w.len(), 1, "got: {w:?}");
366        assert_eq!(w[0].line, 1);
367    }
368
369    #[test]
370    fn accepts_setext_heading_with_body() {
371        let w = check_default("Title\n=====\n\nSome body\n\nSection\n-------\n\nMore\n");
372        assert!(w.is_empty(), "got: {w:?}");
373    }
374
375    #[test]
376    fn blank_lines_do_not_count_as_content() {
377        let w = check_default("# A\n\n\n## B\n\ncontent\n");
378        assert_eq!(w.len(), 1, "got: {w:?}");
379        assert_eq!(w[0].line, 1);
380    }
381
382    #[test]
383    fn html_comment_does_not_count_as_content() {
384        let w = check_default("# A\n\n<!-- a comment -->\n\n## B\n\ncontent\n");
385        assert_eq!(w.len(), 1, "got: {w:?}");
386        assert_eq!(w[0].line, 1);
387    }
388
389    #[test]
390    fn reference_definition_does_not_count_as_content() {
391        let w = check_default("# A\n\n[ref]: https://example.com\n\n## B\n\ncontent\n");
392        assert_eq!(w.len(), 1, "got: {w:?}");
393        assert_eq!(w[0].line, 1);
394    }
395
396    #[test]
397    fn thematic_break_does_not_count_as_content() {
398        // All three CommonMark thematic-break styles are treated as non-content.
399        for marker in ["---", "***", "___"] {
400            let input = format!("# A\n\n{marker}\n\n## B\n\ncontent\n");
401            let w = check_default(&input);
402            assert_eq!(w.len(), 1, "marker {marker:?}: got: {w:?}");
403            assert_eq!(w[0].line, 1, "marker {marker:?}");
404        }
405    }
406
407    #[test]
408    fn code_block_counts_as_content() {
409        let w = check_default("# A\n\n```\ncode\n```\n\n## B\n\ntext\n");
410        assert!(w.is_empty(), "got: {w:?}");
411    }
412
413    #[test]
414    fn list_counts_as_content() {
415        let w = check_default("# A\n\n- item\n\n## B\n\ntext\n");
416        assert!(w.is_empty(), "got: {w:?}");
417    }
418
419    #[test]
420    fn raw_html_block_counts_as_content() {
421        let w = check_default("# A\n\n<div>hello</div>\n\n## B\n\ntext\n");
422        assert!(w.is_empty(), "got: {w:?}");
423    }
424
425    #[test]
426    fn trailing_heading_at_eof_is_not_flagged() {
427        // Only heading-into-heading is in scope; a final heading with no body is
428        // not flagged.
429        let w = check_default("# A\n\nbody\n\n## B\n");
430        assert!(w.is_empty(), "got: {w:?}");
431    }
432
433    #[test]
434    fn single_heading_is_not_flagged() {
435        assert!(check_default("# Only heading\n\ncontent\n").is_empty());
436    }
437
438    #[test]
439    fn document_without_headings_is_not_flagged() {
440        assert!(check_default("Just some text\nand more text\n").is_empty());
441    }
442
443    #[test]
444    fn invalid_heading_renders_as_content() {
445        // `#nospace` (lowercase, no space after `#`) is not a CommonMark heading;
446        // it renders as a paragraph, so it counts as a body and the section is
447        // not empty. (Uppercase-first variants are classified as valid by the
448        // heading detector's heuristic; use lowercase to ensure invalidity.)
449        let w = check_default("# A\n\n#nospace is text\n\n## B\n\ntext\n");
450        assert!(w.is_empty(), "got: {w:?}");
451    }
452
453    #[test]
454    fn standalone_attr_list_does_not_count_as_content() {
455        // `{#a}` on its own line is folded into the heading's id, not a body.
456        let w = check_default("# A\n{#a}\n## B\n\ntext\n");
457        assert_eq!(w.len(), 1, "got: {w:?}");
458        assert_eq!(w[0].line, 1);
459    }
460
461    #[test]
462    fn setext_standalone_attr_list_does_not_count_as_content() {
463        // For setext the attr list sits on the line after the underline.
464        let w = check_default("Title\n=====\n{#a}\nSection\n-------\ntext\n");
465        assert_eq!(w.len(), 1, "got: {w:?}");
466        assert_eq!(w[0].line, 1);
467    }
468
469    #[test]
470    fn non_folded_attr_list_counts_as_content() {
471        // An attribute list separated from the heading by a blank line is NOT
472        // folded into the heading; the parser renders it as an ordinary
473        // paragraph, so it is real content and the section is not empty.
474        let w = check_default("## A\n\n{#stray}\n\n## B\n\ntext\n");
475        assert!(w.is_empty(), "got: {w:?}");
476    }
477
478    #[test]
479    fn setext_non_folded_attr_list_counts_as_content() {
480        // Setext heading with a blank line between the underline and the attr
481        // list: not folded, so the attr list counts as content. The blank line
482        // below it keeps it out of the paragraph the next underline ends, which
483        // would make it that heading's text rather than a body of its own.
484        let w = check_default("Title\n=====\n\n{#a}\n\nSection\n-------\ntext\n");
485        assert!(w.is_empty(), "got: {w:?}");
486    }
487
488    #[test]
489    fn attr_list_above_a_setext_underline_is_heading_text() {
490        // A setext heading's text is the whole paragraph its underline ends, so
491        // an attr list written directly above that text is part of the heading
492        // and leaves the section above it with no body.
493        let w = check_default("Title\n=====\n\n{#a}\nSection\n-------\ntext\n");
494        assert_eq!(w.len(), 1, "got: {w:?}");
495        assert_eq!(w[0].line, 1);
496    }
497
498    #[test]
499    fn inline_id_heading_with_following_attr_list_counts_as_content() {
500        // The heading already has an inline id, so the next-line attr list is
501        // NOT folded; it renders as a paragraph and counts as content.
502        let w = check_default("## A {#x}\n{#y}\n## B\n\ntext\n");
503        assert!(w.is_empty(), "got: {w:?}");
504    }
505
506    #[test]
507    fn inline_id_then_matching_attr_list_counts_as_content() {
508        // The heading has an INLINE id `{#x}`, so the parser does not fold the
509        // following `{#x}` line; it renders as a paragraph and counts as content
510        // even though its id matches the heading's. Must not flag.
511        let w = check_default("## A {#x}\n{#x}\n## B\n\ntext\n");
512        assert!(w.is_empty(), "got: {w:?}");
513    }
514
515    #[test]
516    fn setext_inline_id_then_matching_attr_list_counts_as_content() {
517        // Same as above for a setext heading with an inline id.
518        let w = check_default("Title {#x}\n======\n{#x}\n## B\n\ntext\n");
519        assert!(w.is_empty(), "got: {w:?}");
520    }
521
522    #[test]
523    fn blockquoted_thematic_break_counts_as_content() {
524        // `> ---` renders as a blockquote containing a thematic break: visible
525        // content, not a bare top-level break. Must not flag.
526        let w = check_default("# A\n\n> ---\n\n## B\n\ntext\n");
527        assert!(w.is_empty(), "got: {w:?}");
528    }
529
530    fn allow_parents() -> MD082Config {
531        MD082Config {
532            allow_parent_headings: true,
533            ..Default::default()
534        }
535    }
536
537    #[test]
538    fn default_does_not_allow_parent_headings() {
539        assert!(!MD082Config::default().allow_parent_headings);
540    }
541
542    #[test]
543    fn allow_parent_headings_accepts_a_heading_followed_by_a_deeper_one() {
544        // The issue's first example: subsections are the parent's content.
545        let content = "# H1\n\n## H2\n\ncontent...\n";
546        assert_eq!(check_default(content).len(), 1, "the default still flags it");
547        assert!(check(content, allow_parents()).is_empty());
548    }
549
550    #[test]
551    fn allow_parent_headings_still_flags_a_sibling_heading() {
552        // The issue's second example: nothing separates two H1 sections.
553        let content = "# H1\n\n# H1\n\ncontent...\n";
554        let w = check(content, allow_parents());
555        assert_eq!(w.len(), 1, "got: {w:?}");
556        assert_eq!(w[0].line, 1);
557    }
558
559    #[test]
560    fn allow_parent_headings_still_flags_a_shallower_next_heading() {
561        // `## A` closes with an H1, so nothing nested under it renders.
562        let content = "# Title\n\nIntro\n\n## A\n# B\n\ncontent\n";
563        let w = check(content, allow_parents());
564        assert_eq!(w.len(), 1, "got: {w:?}");
565        assert_eq!(w[0].line, 5);
566    }
567
568    #[test]
569    fn allow_parent_headings_accepts_a_skipped_level() {
570        // Depth is what matters, not adjacency of levels.
571        assert!(check("# A\n\n### C\n\ncontent\n", allow_parents()).is_empty());
572    }
573
574    #[test]
575    fn allow_parent_headings_accepts_setext_into_a_deeper_setext() {
576        assert!(check("Title\n=====\nSection\n-------\ncontent\n", allow_parents()).is_empty());
577    }
578
579    #[test]
580    fn allow_parent_headings_still_flags_the_last_empty_sibling() {
581        // The H2 chain nests, so only the second `## B` (a sibling of the first)
582        // is an empty section.
583        let content = "# Title\n\n## A\n\nbody\n\n## B\n## C\n\nbody\n";
584        let w = check(content, allow_parents());
585        assert_eq!(w.len(), 1, "got: {w:?}");
586        assert_eq!(w[0].line, 7);
587    }
588
589    #[test]
590    fn allow_parent_headings_respects_the_level_floor() {
591        // A heading below the floor is exempt whatever follows it, and one at the
592        // floor followed by a sibling is still flagged.
593        let config = MD082Config {
594            level: 2,
595            allow_parent_headings: true,
596        };
597        assert!(check("# Title\n# Other\n\nbody\n", config.clone()).is_empty());
598        let w = check("# Title\n\nIntro\n\n## A\n## B\n\nbody\n", config);
599        assert_eq!(w.len(), 1, "got: {w:?}");
600        assert_eq!(w[0].line, 5);
601    }
602
603    #[test]
604    fn multiline_reference_definition_is_an_empty_section() {
605        // A reference definition is invisible, even when its title sits on a
606        // continuation line. A section whose only body is such a definition is
607        // empty and is flagged.
608        let w = check_default("# A\n\n[ref]: https://example.com\n  \"title\"\n\n## B\n\ntext\n");
609        assert_eq!(w.len(), 1, "got: {w:?}");
610        assert_eq!(w[0].line, 1);
611    }
612
613    #[test]
614    fn literal_atx_headings_do_not_create_empty_sections() {
615        let rule = MD082NoEmptySections::new();
616        for (flavor, code) in [
617            (
618                MarkdownFlavor::Standard,
619                "{% set unused=\"first\n\n# Fake\n\n## Phantom\nlast\" %}{{ unused|length }}",
620            ),
621            (
622                MarkdownFlavor::Hugo,
623                "{{< note title=`first\n\n# Fake\n\n## Phantom\nlast` >}}",
624            ),
625        ] {
626            for ending in ["\n", "\r\n"] {
627                let source = format!("{code}\n\n# Visible\n\n## Next\n\nBody.\n").replace('\n', ending);
628                let ctx = LintContext::new(&source, flavor, None);
629                let warnings = rule.check(&ctx).unwrap();
630                assert_eq!(warnings.len(), 1, "{source}");
631                assert!(warnings[0].message.contains("'Visible'"));
632                assert_eq!(warnings[0].line, code.lines().count() + 2);
633                assert!(warnings[0].fix.is_none());
634            }
635        }
636    }
637
638    #[test]
639    fn literal_setext_markers_do_not_create_empty_sections() {
640        let rule = MD082NoEmptySections::new();
641        for (flavor, code) in [
642            (
643                MarkdownFlavor::Standard,
644                "{% set unused=\"first\n\nFake\n===\n\nPhantom\n---\nlast\" %}{{ unused|length }}",
645            ),
646            (
647                MarkdownFlavor::Hugo,
648                "{{< note title=`first\n\nFake\n===\n\nPhantom\n---\nlast` >}}",
649            ),
650        ] {
651            let source = format!("{code}\n\n# Visible\n\n## Next\n\nBody.\n");
652            let ctx = LintContext::new(&source, flavor, None);
653            let warnings = rule.check(&ctx).unwrap();
654            assert_eq!(warnings.len(), 1, "{source}");
655            assert!(warnings[0].message.contains("'Visible'"));
656        }
657    }
658
659    #[test]
660    fn dynamic_and_mdx_body_empty_headings_remain_eligible() {
661        let rule = MD082NoEmptySections::new();
662        for (flavor, source, line, end_line) in [
663            (MarkdownFlavor::Standard, "# {{ title }}\n\n## Next\n\nBody.\n", 1, 1),
664            (
665                MarkdownFlavor::Standard,
666                "First\n{{ title }}\n===\n\n# Next\n\nBody.\n",
667                1,
668                2,
669            ),
670            (
671                MarkdownFlavor::MDX,
672                "<div>\n\n# Visible\n\n## Next\n\nBody.\n\n</div>\n",
673                3,
674                3,
675            ),
676        ] {
677            let ctx = LintContext::new(source, flavor, None);
678            let warnings = rule.check(&ctx).unwrap();
679            assert_eq!(warnings.len(), 1, "{source}");
680            assert_eq!((warnings[0].line, warnings[0].end_line), (line, end_line));
681        }
682    }
683    mod quoted_headings {
684        use super::*;
685
686        fn lines(content: &str) -> Vec<usize> {
687            check_default(content).iter().map(|w| w.line).collect()
688        }
689
690        #[test]
691        fn heading_followed_directly_by_a_quoted_heading_is_empty() {
692            assert_eq!(lines("# A\n\n> ## B\n> body\n"), vec![1]);
693            assert_eq!(lines("# A\n\n> > ## B\n> > body\n"), vec![1]);
694            // The list item text between the two headings is A's body.
695            assert!(lines("# A\n\n- x\n\n  > ## B\n  > body\n").is_empty());
696            assert_eq!(lines("# A\n\n- x\n\n  > ## B\n  > ## C\n  > body\n"), vec![5]);
697            assert_eq!(lines("# A\n\n> - ## B\n>   body\n"), vec![1]);
698        }
699
700        #[test]
701        fn quoted_section_with_text_is_not_empty() {
702            assert!(lines("> # A\n> body\n>\n> ## B\n> more\n").is_empty());
703        }
704
705        #[test]
706        fn quoted_empty_section_is_reported_on_its_heading_line() {
707            assert_eq!(lines("> # A\n>\n> ## B\n> body\n"), vec![1]);
708            assert_eq!(lines("> # A\n> body\n\n## B\n\ntext\n"), Vec::<usize>::new());
709            assert_eq!(lines("> # A\n\n# B\n\ntext\n"), vec![1]);
710        }
711
712        #[test]
713        fn lone_quote_markers_and_quoted_definitions_are_not_content() {
714            assert_eq!(lines("> # A\n>\n>\n> ## B\n> body\n"), vec![1]);
715            assert_eq!(lines("> # A\n> [ref]: /url\n> ## B\n> body\n"), vec![1]);
716        }
717
718        #[test]
719        fn quoted_html_comments_are_not_content() {
720            assert_eq!(lines("> # A\n> <!-- c -->\n> # B\n> body\n"), vec![1]);
721            assert_eq!(lines("> # A\n> <!-- c\n> more -->\n> # B\n> body\n"), vec![1]);
722            assert_eq!(lines("> # A\n> <!-- c --> text\n> # B\n> body\n"), Vec::<usize>::new());
723            assert_eq!(lines("# A\n<!-- c -->\n# B\nbody\n"), vec![1]);
724        }
725
726        #[test]
727        fn quoted_setext_heading_ends_the_section_at_its_first_text_line() {
728            assert_eq!(lines("# A\n\n> First\n> second\n> ===\n> body\n"), vec![1]);
729        }
730
731        #[test]
732        fn quote_only_document_is_not_skipped() {
733            assert_eq!(lines("> # A\n> ## B\n> body\n"), vec![1]);
734            // The engine consults `should_skip` before `check`.
735            let ctx = LintContext::new("> # A\n> ## B\n> body\n", crate::config::MarkdownFlavor::Standard, None);
736            assert!(!MD082NoEmptySections::default().should_skip(&ctx));
737            let ctx = LintContext::new("text\n", crate::config::MarkdownFlavor::Standard, None);
738            assert!(MD082NoEmptySections::default().should_skip(&ctx));
739        }
740    }
741}