1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38#[serde(rename_all = "kebab-case")]
39pub struct MD082Config {
40 #[serde(default = "default_level")]
46 pub level: u8,
47
48 #[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
69struct HeadingPos {
71 index: usize,
74 first_index: usize,
77 level: u8,
79 is_setext: bool,
81 id_from_next_line: bool,
86 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 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 if li.is_horizontal_rule && li.blockquote.is_none() && !li.in_list_block {
120 return false;
121 }
122 if ctx.is_in_reference_def(li.byte_offset + li.indent) {
125 return false;
126 }
127 true
128 }
129
130 fn warn_empty_section(&self, ctx: &LintContext, heading: &HeadingPos) -> LintWarning {
131 let line_content = ctx.lines.get(heading.index).map_or("", |l| l.content(ctx.content));
132 let end_column = line_content.chars().count() + 1;
133 LintWarning {
136 rule_name: Some(self.name().to_string()),
137 severity: Severity::Warning,
138 line: heading.first_index + 1,
139 column: 1,
140 end_line: heading.index + 1,
141 end_column,
142 message: format!("Heading '{}' has no content before the next heading", heading.text),
143 fix: None,
144 }
145 }
146}
147
148impl Rule for MD082NoEmptySections {
149 fn name(&self) -> &'static str {
150 "MD082"
151 }
152
153 fn description(&self) -> &'static str {
154 "Headings should have content before the next heading"
155 }
156
157 fn category(&self) -> RuleCategory {
158 RuleCategory::Heading
159 }
160
161 fn should_skip(&self, ctx: &LintContext) -> bool {
162 !ctx.has_valid_headings()
163 }
164
165 fn check(&self, ctx: &LintContext) -> LintResult {
166 let headings: Vec<HeadingPos> = ctx
167 .valid_headings()
168 .filter(|h| {
169 let marker_idx = h.line_num - 1
170 + usize::from(matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2));
171 !ctx.lines.get(marker_idx).is_some_and(|line| {
172 let offset = line.byte_offset + line.indent;
173 ctx.is_inside_template_code(offset) || ctx.is_inside_mdx_code(offset)
174 })
175 })
176 .map(|h| HeadingPos {
177 index: h.line_num - 1,
178 first_index: h.first_line_num() - 1,
179 level: h.heading.level,
180 is_setext: matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
181 id_from_next_line: h.heading.custom_id.is_some()
184 && crate::utils::header_id_utils::extract_header_id(&h.heading.raw_text)
185 .1
186 .is_none(),
187 text: h.heading.text.clone(),
188 })
189 .collect();
190
191 if headings.len() < 2 {
192 return Ok(Vec::new());
193 }
194
195 let mut warnings = Vec::new();
196 for pair in headings.windows(2) {
197 let cur = &pair[0];
198 let next = &pair[1];
199
200 if cur.level < self.config.level {
201 continue;
202 }
203
204 if self.config.allow_parent_headings && next.level > cur.level {
207 continue;
208 }
209
210 let content_start = if cur.is_setext { cur.index + 2 } else { cur.index + 1 };
215
216 let mut scan_start = content_start;
223 if cur.id_from_next_line
224 && let Some(li) = ctx.lines.get(content_start)
225 && crate::utils::header_id_utils::is_standalone_attr_list(li.content(ctx.content))
226 {
227 scan_start = content_start + 1;
228 }
229
230 let has_content = (scan_start..next.first_index).any(|idx| self.is_content_line(ctx, idx));
234 if !has_content {
235 warnings.push(self.warn_empty_section(ctx, cur));
236 }
237 }
238
239 Ok(warnings)
240 }
241
242 fn fix_capability(&self) -> FixCapability {
243 FixCapability::Unfixable
244 }
245
246 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
247 Ok(ctx.content.to_string())
250 }
251
252 fn as_any(&self) -> &dyn std::any::Any {
253 self
254 }
255
256 crate::impl_rule_config_methods!(MD082Config);
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::config::MarkdownFlavor;
263 use crate::rule::LintWarning;
264
265 fn check(content: &str, config: MD082Config) -> Vec<LintWarning> {
266 let rule = MD082NoEmptySections::from_config_struct(config);
267 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
268 rule.check(&ctx).unwrap()
269 }
270
271 fn check_default(content: &str) -> Vec<LintWarning> {
272 check(content, MD082Config::default())
273 }
274
275 #[test]
276 fn default_level_is_one() {
277 assert_eq!(MD082Config::default().level, 1);
278 }
279
280 #[test]
281 fn flags_atx_heading_immediately_followed_by_heading() {
282 let w = check_default("# A\n## B\n\nBody text\n");
283 assert_eq!(w.len(), 1, "got: {w:?}");
284 assert_eq!(w[0].line, 1);
285 assert!(w[0].message.contains('A'), "got: {}", w[0].message);
286 }
287
288 #[test]
289 fn flags_empty_section_before_multi_line_setext_heading() {
290 let w = check_default("# Empty\n\nNext one\nsecond\n===\n\nBody\n");
294 assert_eq!(w.len(), 1, "got: {w:?}");
295 assert_eq!(w[0].line, 1);
296 assert!(w[0].message.contains("Empty"), "got: {}", w[0].message);
297 }
298
299 #[test]
300 fn accepts_heading_with_paragraph_body() {
301 let w = check_default("# A\n\nSome text\n\n## B\n\nMore text\n");
302 assert!(w.is_empty(), "got: {w:?}");
303 }
304
305 #[test]
306 fn flags_nested_empty_section_from_issue() {
307 let content =
310 "# Level 1 heading\n\nLevel 1 content\n\n## Empty Section\n### Level 3 heading\n\nLevel 3 content\n";
311 let w = check_default(content);
312 assert_eq!(w.len(), 1, "got: {w:?}");
313 assert_eq!(w[0].line, 5);
314 assert!(w[0].message.contains("Empty Section"));
315 }
316
317 #[test]
318 fn default_level_flags_h1_into_h2() {
319 let w = check_default("# Title\n## Section\n\nBody\n");
320 assert_eq!(w.len(), 1, "got: {w:?}");
321 assert_eq!(w[0].line, 1);
322 }
323
324 #[test]
325 fn level_2_exempts_h1_but_flags_h2() {
326 let config = MD082Config {
327 level: 2,
328 ..Default::default()
329 };
330 assert!(check("# Title\n## Section\n\nBody\n", config.clone()).is_empty());
332 let w = check("# Title\n\nIntro\n\n## A\n### B\n\nBody\n", config);
334 assert_eq!(w.len(), 1, "got: {w:?}");
335 assert_eq!(w[0].line, 5);
336 }
337
338 #[test]
339 fn flags_setext_heading_into_setext_heading() {
340 let w = check_default("Title\n=====\nSection\n-------\ncontent\n");
343 assert_eq!(w.len(), 1, "got: {w:?}");
344 assert_eq!(w[0].line, 1);
345 }
346
347 #[test]
348 fn accepts_setext_heading_with_body() {
349 let w = check_default("Title\n=====\n\nSome body\n\nSection\n-------\n\nMore\n");
350 assert!(w.is_empty(), "got: {w:?}");
351 }
352
353 #[test]
354 fn blank_lines_do_not_count_as_content() {
355 let w = check_default("# A\n\n\n## B\n\ncontent\n");
356 assert_eq!(w.len(), 1, "got: {w:?}");
357 assert_eq!(w[0].line, 1);
358 }
359
360 #[test]
361 fn html_comment_does_not_count_as_content() {
362 let w = check_default("# A\n\n<!-- a comment -->\n\n## B\n\ncontent\n");
363 assert_eq!(w.len(), 1, "got: {w:?}");
364 assert_eq!(w[0].line, 1);
365 }
366
367 #[test]
368 fn reference_definition_does_not_count_as_content() {
369 let w = check_default("# A\n\n[ref]: https://example.com\n\n## B\n\ncontent\n");
370 assert_eq!(w.len(), 1, "got: {w:?}");
371 assert_eq!(w[0].line, 1);
372 }
373
374 #[test]
375 fn thematic_break_does_not_count_as_content() {
376 for marker in ["---", "***", "___"] {
378 let input = format!("# A\n\n{marker}\n\n## B\n\ncontent\n");
379 let w = check_default(&input);
380 assert_eq!(w.len(), 1, "marker {marker:?}: got: {w:?}");
381 assert_eq!(w[0].line, 1, "marker {marker:?}");
382 }
383 }
384
385 #[test]
386 fn code_block_counts_as_content() {
387 let w = check_default("# A\n\n```\ncode\n```\n\n## B\n\ntext\n");
388 assert!(w.is_empty(), "got: {w:?}");
389 }
390
391 #[test]
392 fn list_counts_as_content() {
393 let w = check_default("# A\n\n- item\n\n## B\n\ntext\n");
394 assert!(w.is_empty(), "got: {w:?}");
395 }
396
397 #[test]
398 fn raw_html_block_counts_as_content() {
399 let w = check_default("# A\n\n<div>hello</div>\n\n## B\n\ntext\n");
400 assert!(w.is_empty(), "got: {w:?}");
401 }
402
403 #[test]
404 fn trailing_heading_at_eof_is_not_flagged() {
405 let w = check_default("# A\n\nbody\n\n## B\n");
408 assert!(w.is_empty(), "got: {w:?}");
409 }
410
411 #[test]
412 fn single_heading_is_not_flagged() {
413 assert!(check_default("# Only heading\n\ncontent\n").is_empty());
414 }
415
416 #[test]
417 fn document_without_headings_is_not_flagged() {
418 assert!(check_default("Just some text\nand more text\n").is_empty());
419 }
420
421 #[test]
422 fn invalid_heading_renders_as_content() {
423 let w = check_default("# A\n\n#nospace is text\n\n## B\n\ntext\n");
428 assert!(w.is_empty(), "got: {w:?}");
429 }
430
431 #[test]
432 fn standalone_attr_list_does_not_count_as_content() {
433 let w = check_default("# A\n{#a}\n## B\n\ntext\n");
435 assert_eq!(w.len(), 1, "got: {w:?}");
436 assert_eq!(w[0].line, 1);
437 }
438
439 #[test]
440 fn setext_standalone_attr_list_does_not_count_as_content() {
441 let w = check_default("Title\n=====\n{#a}\nSection\n-------\ntext\n");
443 assert_eq!(w.len(), 1, "got: {w:?}");
444 assert_eq!(w[0].line, 1);
445 }
446
447 #[test]
448 fn non_folded_attr_list_counts_as_content() {
449 let w = check_default("## A\n\n{#stray}\n\n## B\n\ntext\n");
453 assert!(w.is_empty(), "got: {w:?}");
454 }
455
456 #[test]
457 fn setext_non_folded_attr_list_counts_as_content() {
458 let w = check_default("Title\n=====\n\n{#a}\n\nSection\n-------\ntext\n");
463 assert!(w.is_empty(), "got: {w:?}");
464 }
465
466 #[test]
467 fn attr_list_above_a_setext_underline_is_heading_text() {
468 let w = check_default("Title\n=====\n\n{#a}\nSection\n-------\ntext\n");
472 assert_eq!(w.len(), 1, "got: {w:?}");
473 assert_eq!(w[0].line, 1);
474 }
475
476 #[test]
477 fn inline_id_heading_with_following_attr_list_counts_as_content() {
478 let w = check_default("## A {#x}\n{#y}\n## B\n\ntext\n");
481 assert!(w.is_empty(), "got: {w:?}");
482 }
483
484 #[test]
485 fn inline_id_then_matching_attr_list_counts_as_content() {
486 let w = check_default("## A {#x}\n{#x}\n## B\n\ntext\n");
490 assert!(w.is_empty(), "got: {w:?}");
491 }
492
493 #[test]
494 fn setext_inline_id_then_matching_attr_list_counts_as_content() {
495 let w = check_default("Title {#x}\n======\n{#x}\n## B\n\ntext\n");
497 assert!(w.is_empty(), "got: {w:?}");
498 }
499
500 #[test]
501 fn blockquoted_thematic_break_counts_as_content() {
502 let w = check_default("# A\n\n> ---\n\n## B\n\ntext\n");
505 assert!(w.is_empty(), "got: {w:?}");
506 }
507
508 fn allow_parents() -> MD082Config {
509 MD082Config {
510 allow_parent_headings: true,
511 ..Default::default()
512 }
513 }
514
515 #[test]
516 fn default_does_not_allow_parent_headings() {
517 assert!(!MD082Config::default().allow_parent_headings);
518 }
519
520 #[test]
521 fn allow_parent_headings_accepts_a_heading_followed_by_a_deeper_one() {
522 let content = "# H1\n\n## H2\n\ncontent...\n";
524 assert_eq!(check_default(content).len(), 1, "the default still flags it");
525 assert!(check(content, allow_parents()).is_empty());
526 }
527
528 #[test]
529 fn allow_parent_headings_still_flags_a_sibling_heading() {
530 let content = "# H1\n\n# H1\n\ncontent...\n";
532 let w = check(content, allow_parents());
533 assert_eq!(w.len(), 1, "got: {w:?}");
534 assert_eq!(w[0].line, 1);
535 }
536
537 #[test]
538 fn allow_parent_headings_still_flags_a_shallower_next_heading() {
539 let content = "# Title\n\nIntro\n\n## A\n# B\n\ncontent\n";
541 let w = check(content, allow_parents());
542 assert_eq!(w.len(), 1, "got: {w:?}");
543 assert_eq!(w[0].line, 5);
544 }
545
546 #[test]
547 fn allow_parent_headings_accepts_a_skipped_level() {
548 assert!(check("# A\n\n### C\n\ncontent\n", allow_parents()).is_empty());
550 }
551
552 #[test]
553 fn allow_parent_headings_accepts_setext_into_a_deeper_setext() {
554 assert!(check("Title\n=====\nSection\n-------\ncontent\n", allow_parents()).is_empty());
555 }
556
557 #[test]
558 fn allow_parent_headings_still_flags_the_last_empty_sibling() {
559 let content = "# Title\n\n## A\n\nbody\n\n## B\n## C\n\nbody\n";
562 let w = check(content, allow_parents());
563 assert_eq!(w.len(), 1, "got: {w:?}");
564 assert_eq!(w[0].line, 7);
565 }
566
567 #[test]
568 fn allow_parent_headings_respects_the_level_floor() {
569 let config = MD082Config {
572 level: 2,
573 allow_parent_headings: true,
574 };
575 assert!(check("# Title\n# Other\n\nbody\n", config.clone()).is_empty());
576 let w = check("# Title\n\nIntro\n\n## A\n## B\n\nbody\n", config);
577 assert_eq!(w.len(), 1, "got: {w:?}");
578 assert_eq!(w[0].line, 5);
579 }
580
581 #[test]
582 fn multiline_reference_definition_is_an_empty_section() {
583 let w = check_default("# A\n\n[ref]: https://example.com\n \"title\"\n\n## B\n\ntext\n");
587 assert_eq!(w.len(), 1, "got: {w:?}");
588 assert_eq!(w[0].line, 1);
589 }
590
591 #[test]
592 fn literal_atx_headings_do_not_create_empty_sections() {
593 let rule = MD082NoEmptySections::new();
594 for (flavor, code) in [
595 (
596 MarkdownFlavor::Standard,
597 "{% set unused=\"first\n\n# Fake\n\n## Phantom\nlast\" %}{{ unused|length }}",
598 ),
599 (
600 MarkdownFlavor::Hugo,
601 "{{< note title=`first\n\n# Fake\n\n## Phantom\nlast` >}}",
602 ),
603 ] {
604 for ending in ["\n", "\r\n"] {
605 let source = format!("{code}\n\n# Visible\n\n## Next\n\nBody.\n").replace('\n', ending);
606 let ctx = LintContext::new(&source, flavor, None);
607 let warnings = rule.check(&ctx).unwrap();
608 assert_eq!(warnings.len(), 1, "{source}");
609 assert!(warnings[0].message.contains("'Visible'"));
610 assert_eq!(warnings[0].line, code.lines().count() + 2);
611 assert!(warnings[0].fix.is_none());
612 }
613 }
614 }
615
616 #[test]
617 fn literal_setext_markers_do_not_create_empty_sections() {
618 let rule = MD082NoEmptySections::new();
619 for (flavor, code) in [
620 (
621 MarkdownFlavor::Standard,
622 "{% set unused=\"first\n\nFake\n===\n\nPhantom\n---\nlast\" %}{{ unused|length }}",
623 ),
624 (
625 MarkdownFlavor::Hugo,
626 "{{< note title=`first\n\nFake\n===\n\nPhantom\n---\nlast` >}}",
627 ),
628 ] {
629 let source = format!("{code}\n\n# Visible\n\n## Next\n\nBody.\n");
630 let ctx = LintContext::new(&source, flavor, None);
631 let warnings = rule.check(&ctx).unwrap();
632 assert_eq!(warnings.len(), 1, "{source}");
633 assert!(warnings[0].message.contains("'Visible'"));
634 }
635 }
636
637 #[test]
638 fn dynamic_and_mdx_body_empty_headings_remain_eligible() {
639 let rule = MD082NoEmptySections::new();
640 for (flavor, source, line, end_line) in [
641 (MarkdownFlavor::Standard, "# {{ title }}\n\n## Next\n\nBody.\n", 1, 1),
642 (
643 MarkdownFlavor::Standard,
644 "First\n{{ title }}\n===\n\n# Next\n\nBody.\n",
645 1,
646 2,
647 ),
648 (
649 MarkdownFlavor::MDX,
650 "<div>\n\n# Visible\n\n## Next\n\nBody.\n\n</div>\n",
651 3,
652 3,
653 ),
654 ] {
655 let ctx = LintContext::new(source, flavor, None);
656 let warnings = rule.check(&ctx).unwrap();
657 assert_eq!(warnings.len(), 1, "{source}");
658 assert_eq!((warnings[0].line, warnings[0].end_line), (line, end_line));
659 }
660 }
661}