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 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 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 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 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 if self.config.allow_parent_headings && next.level > cur.level {
229 continue;
230 }
231
232 let content_start = if cur.is_setext { cur.index + 2 } else { cur.index + 1 };
237
238 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 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 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 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 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 assert!(check("# Title\n## Section\n\nBody\n", config.clone()).is_empty());
354 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}