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_html_comment(ctx.html_comment_ranges(), start, end) {
142 return false;
143 }
144 }
145 true
146 }
147
148 fn warn_empty_section(&self, ctx: &LintContext, heading: &HeadingPos) -> LintWarning {
149 let line_content = ctx.lines.get(heading.index).map_or("", |l| l.content(ctx.content));
150 let end_column = line_content.chars().count() + 1;
151 LintWarning {
154 rule_name: Some(self.name().to_string()),
155 severity: Severity::Warning,
156 line: heading.first_index + 1,
157 column: 1,
158 end_line: heading.index + 1,
159 end_column,
160 message: format!("Heading '{}' has no content before the next heading", heading.text),
161 fix: None,
162 }
163 }
164}
165
166impl Rule for MD082NoEmptySections {
167 fn name(&self) -> &'static str {
168 "MD082"
169 }
170
171 fn description(&self) -> &'static str {
172 "Headings should have content before the next heading"
173 }
174
175 fn category(&self) -> RuleCategory {
176 RuleCategory::Heading
177 }
178
179 fn should_skip(&self, ctx: &LintContext) -> bool {
180 ctx.headings().next().is_none()
181 }
182
183 fn check(&self, ctx: &LintContext) -> LintResult {
184 let headings: Vec<HeadingPos> = ctx
185 .headings()
186 .filter(|h| {
187 let marker_idx = h.line_num - 1
188 + usize::from(matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2));
189 !ctx.lines.get(marker_idx).is_some_and(|line| {
190 let offset = line.byte_offset + line.indent;
191 ctx.is_inside_template_code(offset) || ctx.is_inside_mdx_code(offset)
192 })
193 })
194 .map(|h| HeadingPos {
195 index: h.line_num - 1,
196 first_index: h.first_line_num() - 1,
197 level: h.heading.level,
198 is_setext: matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
199 id_from_next_line: h.heading.custom_id.is_some()
202 && crate::utils::header_id_utils::extract_header_id(&h.heading.raw_text)
203 .1
204 .is_none(),
205 text: h.heading.text.clone(),
206 })
207 .collect();
208
209 if headings.len() < 2 {
210 return Ok(Vec::new());
211 }
212
213 let mut warnings = Vec::new();
214 for pair in headings.windows(2) {
215 let cur = &pair[0];
216 let next = &pair[1];
217
218 if cur.level < self.config.level {
219 continue;
220 }
221
222 if self.config.allow_parent_headings && next.level > cur.level {
225 continue;
226 }
227
228 let content_start = if cur.is_setext { cur.index + 2 } else { cur.index + 1 };
233
234 let mut scan_start = content_start;
241 if cur.id_from_next_line
242 && let Some(li) = ctx.lines.get(content_start)
243 && crate::utils::header_id_utils::is_standalone_attr_list(li.content(ctx.content))
244 {
245 scan_start = content_start + 1;
246 }
247
248 let has_content = (scan_start..next.first_index).any(|idx| self.is_content_line(ctx, idx));
252 if !has_content {
253 warnings.push(self.warn_empty_section(ctx, cur));
254 }
255 }
256
257 Ok(warnings)
258 }
259
260 fn fix_capability(&self) -> FixCapability {
261 FixCapability::Unfixable
262 }
263
264 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
265 Ok(ctx.content.to_string())
268 }
269
270 fn as_any(&self) -> &dyn std::any::Any {
271 self
272 }
273
274 crate::impl_rule_config_methods!(MD082Config);
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::config::MarkdownFlavor;
281 use crate::rule::LintWarning;
282
283 fn check(content: &str, config: MD082Config) -> Vec<LintWarning> {
284 let rule = MD082NoEmptySections::from_config_struct(config);
285 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
286 rule.check(&ctx).unwrap()
287 }
288
289 fn check_default(content: &str) -> Vec<LintWarning> {
290 check(content, MD082Config::default())
291 }
292
293 #[test]
294 fn default_level_is_one() {
295 assert_eq!(MD082Config::default().level, 1);
296 }
297
298 #[test]
299 fn flags_atx_heading_immediately_followed_by_heading() {
300 let w = check_default("# A\n## B\n\nBody text\n");
301 assert_eq!(w.len(), 1, "got: {w:?}");
302 assert_eq!(w[0].line, 1);
303 assert!(w[0].message.contains('A'), "got: {}", w[0].message);
304 }
305
306 #[test]
307 fn flags_empty_section_before_multi_line_setext_heading() {
308 let w = check_default("# Empty\n\nNext one\nsecond\n===\n\nBody\n");
312 assert_eq!(w.len(), 1, "got: {w:?}");
313 assert_eq!(w[0].line, 1);
314 assert!(w[0].message.contains("Empty"), "got: {}", w[0].message);
315 }
316
317 #[test]
318 fn accepts_heading_with_paragraph_body() {
319 let w = check_default("# A\n\nSome text\n\n## B\n\nMore text\n");
320 assert!(w.is_empty(), "got: {w:?}");
321 }
322
323 #[test]
324 fn flags_nested_empty_section_from_issue() {
325 let content =
328 "# Level 1 heading\n\nLevel 1 content\n\n## Empty Section\n### Level 3 heading\n\nLevel 3 content\n";
329 let w = check_default(content);
330 assert_eq!(w.len(), 1, "got: {w:?}");
331 assert_eq!(w[0].line, 5);
332 assert!(w[0].message.contains("Empty Section"));
333 }
334
335 #[test]
336 fn default_level_flags_h1_into_h2() {
337 let w = check_default("# Title\n## Section\n\nBody\n");
338 assert_eq!(w.len(), 1, "got: {w:?}");
339 assert_eq!(w[0].line, 1);
340 }
341
342 #[test]
343 fn level_2_exempts_h1_but_flags_h2() {
344 let config = MD082Config {
345 level: 2,
346 ..Default::default()
347 };
348 assert!(check("# Title\n## Section\n\nBody\n", config.clone()).is_empty());
350 let w = check("# Title\n\nIntro\n\n## A\n### B\n\nBody\n", config);
352 assert_eq!(w.len(), 1, "got: {w:?}");
353 assert_eq!(w[0].line, 5);
354 }
355
356 #[test]
357 fn flags_setext_heading_into_setext_heading() {
358 let w = check_default("Title\n=====\nSection\n-------\ncontent\n");
361 assert_eq!(w.len(), 1, "got: {w:?}");
362 assert_eq!(w[0].line, 1);
363 }
364
365 #[test]
366 fn accepts_setext_heading_with_body() {
367 let w = check_default("Title\n=====\n\nSome body\n\nSection\n-------\n\nMore\n");
368 assert!(w.is_empty(), "got: {w:?}");
369 }
370
371 #[test]
372 fn blank_lines_do_not_count_as_content() {
373 let w = check_default("# A\n\n\n## B\n\ncontent\n");
374 assert_eq!(w.len(), 1, "got: {w:?}");
375 assert_eq!(w[0].line, 1);
376 }
377
378 #[test]
379 fn html_comment_does_not_count_as_content() {
380 let w = check_default("# A\n\n<!-- a comment -->\n\n## B\n\ncontent\n");
381 assert_eq!(w.len(), 1, "got: {w:?}");
382 assert_eq!(w[0].line, 1);
383 }
384
385 #[test]
386 fn reference_definition_does_not_count_as_content() {
387 let w = check_default("# A\n\n[ref]: https://example.com\n\n## B\n\ncontent\n");
388 assert_eq!(w.len(), 1, "got: {w:?}");
389 assert_eq!(w[0].line, 1);
390 }
391
392 #[test]
393 fn thematic_break_does_not_count_as_content() {
394 for marker in ["---", "***", "___"] {
396 let input = format!("# A\n\n{marker}\n\n## B\n\ncontent\n");
397 let w = check_default(&input);
398 assert_eq!(w.len(), 1, "marker {marker:?}: got: {w:?}");
399 assert_eq!(w[0].line, 1, "marker {marker:?}");
400 }
401 }
402
403 #[test]
404 fn code_block_counts_as_content() {
405 let w = check_default("# A\n\n```\ncode\n```\n\n## B\n\ntext\n");
406 assert!(w.is_empty(), "got: {w:?}");
407 }
408
409 #[test]
410 fn list_counts_as_content() {
411 let w = check_default("# A\n\n- item\n\n## B\n\ntext\n");
412 assert!(w.is_empty(), "got: {w:?}");
413 }
414
415 #[test]
416 fn raw_html_block_counts_as_content() {
417 let w = check_default("# A\n\n<div>hello</div>\n\n## B\n\ntext\n");
418 assert!(w.is_empty(), "got: {w:?}");
419 }
420
421 #[test]
422 fn trailing_heading_at_eof_is_not_flagged() {
423 let w = check_default("# A\n\nbody\n\n## B\n");
426 assert!(w.is_empty(), "got: {w:?}");
427 }
428
429 #[test]
430 fn single_heading_is_not_flagged() {
431 assert!(check_default("# Only heading\n\ncontent\n").is_empty());
432 }
433
434 #[test]
435 fn document_without_headings_is_not_flagged() {
436 assert!(check_default("Just some text\nand more text\n").is_empty());
437 }
438
439 #[test]
440 fn invalid_heading_renders_as_content() {
441 let w = check_default("# A\n\n#nospace is text\n\n## B\n\ntext\n");
446 assert!(w.is_empty(), "got: {w:?}");
447 }
448
449 #[test]
450 fn standalone_attr_list_does_not_count_as_content() {
451 let w = check_default("# A\n{#a}\n## B\n\ntext\n");
453 assert_eq!(w.len(), 1, "got: {w:?}");
454 assert_eq!(w[0].line, 1);
455 }
456
457 #[test]
458 fn setext_standalone_attr_list_does_not_count_as_content() {
459 let w = check_default("Title\n=====\n{#a}\nSection\n-------\ntext\n");
461 assert_eq!(w.len(), 1, "got: {w:?}");
462 assert_eq!(w[0].line, 1);
463 }
464
465 #[test]
466 fn non_folded_attr_list_counts_as_content() {
467 let w = check_default("## A\n\n{#stray}\n\n## B\n\ntext\n");
471 assert!(w.is_empty(), "got: {w:?}");
472 }
473
474 #[test]
475 fn setext_non_folded_attr_list_counts_as_content() {
476 let w = check_default("Title\n=====\n\n{#a}\n\nSection\n-------\ntext\n");
481 assert!(w.is_empty(), "got: {w:?}");
482 }
483
484 #[test]
485 fn attr_list_above_a_setext_underline_is_heading_text() {
486 let w = check_default("Title\n=====\n\n{#a}\nSection\n-------\ntext\n");
490 assert_eq!(w.len(), 1, "got: {w:?}");
491 assert_eq!(w[0].line, 1);
492 }
493
494 #[test]
495 fn inline_id_heading_with_following_attr_list_counts_as_content() {
496 let w = check_default("## A {#x}\n{#y}\n## B\n\ntext\n");
499 assert!(w.is_empty(), "got: {w:?}");
500 }
501
502 #[test]
503 fn inline_id_then_matching_attr_list_counts_as_content() {
504 let w = check_default("## A {#x}\n{#x}\n## B\n\ntext\n");
508 assert!(w.is_empty(), "got: {w:?}");
509 }
510
511 #[test]
512 fn setext_inline_id_then_matching_attr_list_counts_as_content() {
513 let w = check_default("Title {#x}\n======\n{#x}\n## B\n\ntext\n");
515 assert!(w.is_empty(), "got: {w:?}");
516 }
517
518 #[test]
519 fn blockquoted_thematic_break_counts_as_content() {
520 let w = check_default("# A\n\n> ---\n\n## B\n\ntext\n");
523 assert!(w.is_empty(), "got: {w:?}");
524 }
525
526 fn allow_parents() -> MD082Config {
527 MD082Config {
528 allow_parent_headings: true,
529 ..Default::default()
530 }
531 }
532
533 #[test]
534 fn default_does_not_allow_parent_headings() {
535 assert!(!MD082Config::default().allow_parent_headings);
536 }
537
538 #[test]
539 fn allow_parent_headings_accepts_a_heading_followed_by_a_deeper_one() {
540 let content = "# H1\n\n## H2\n\ncontent...\n";
542 assert_eq!(check_default(content).len(), 1, "the default still flags it");
543 assert!(check(content, allow_parents()).is_empty());
544 }
545
546 #[test]
547 fn allow_parent_headings_still_flags_a_sibling_heading() {
548 let content = "# H1\n\n# H1\n\ncontent...\n";
550 let w = check(content, allow_parents());
551 assert_eq!(w.len(), 1, "got: {w:?}");
552 assert_eq!(w[0].line, 1);
553 }
554
555 #[test]
556 fn allow_parent_headings_still_flags_a_shallower_next_heading() {
557 let content = "# Title\n\nIntro\n\n## A\n# B\n\ncontent\n";
559 let w = check(content, allow_parents());
560 assert_eq!(w.len(), 1, "got: {w:?}");
561 assert_eq!(w[0].line, 5);
562 }
563
564 #[test]
565 fn allow_parent_headings_accepts_a_skipped_level() {
566 assert!(check("# A\n\n### C\n\ncontent\n", allow_parents()).is_empty());
568 }
569
570 #[test]
571 fn allow_parent_headings_accepts_setext_into_a_deeper_setext() {
572 assert!(check("Title\n=====\nSection\n-------\ncontent\n", allow_parents()).is_empty());
573 }
574
575 #[test]
576 fn allow_parent_headings_still_flags_the_last_empty_sibling() {
577 let content = "# Title\n\n## A\n\nbody\n\n## B\n## C\n\nbody\n";
580 let w = check(content, allow_parents());
581 assert_eq!(w.len(), 1, "got: {w:?}");
582 assert_eq!(w[0].line, 7);
583 }
584
585 #[test]
586 fn allow_parent_headings_respects_the_level_floor() {
587 let config = MD082Config {
590 level: 2,
591 allow_parent_headings: true,
592 };
593 assert!(check("# Title\n# Other\n\nbody\n", config.clone()).is_empty());
594 let w = check("# Title\n\nIntro\n\n## A\n## B\n\nbody\n", config);
595 assert_eq!(w.len(), 1, "got: {w:?}");
596 assert_eq!(w[0].line, 5);
597 }
598
599 #[test]
600 fn multiline_reference_definition_is_an_empty_section() {
601 let w = check_default("# A\n\n[ref]: https://example.com\n \"title\"\n\n## B\n\ntext\n");
605 assert_eq!(w.len(), 1, "got: {w:?}");
606 assert_eq!(w[0].line, 1);
607 }
608
609 #[test]
610 fn literal_atx_headings_do_not_create_empty_sections() {
611 let rule = MD082NoEmptySections::new();
612 for (flavor, code) in [
613 (
614 MarkdownFlavor::Standard,
615 "{% set unused=\"first\n\n# Fake\n\n## Phantom\nlast\" %}{{ unused|length }}",
616 ),
617 (
618 MarkdownFlavor::Hugo,
619 "{{< note title=`first\n\n# Fake\n\n## Phantom\nlast` >}}",
620 ),
621 ] {
622 for ending in ["\n", "\r\n"] {
623 let source = format!("{code}\n\n# Visible\n\n## Next\n\nBody.\n").replace('\n', ending);
624 let ctx = LintContext::new(&source, flavor, None);
625 let warnings = rule.check(&ctx).unwrap();
626 assert_eq!(warnings.len(), 1, "{source}");
627 assert!(warnings[0].message.contains("'Visible'"));
628 assert_eq!(warnings[0].line, code.lines().count() + 2);
629 assert!(warnings[0].fix.is_none());
630 }
631 }
632 }
633
634 #[test]
635 fn literal_setext_markers_do_not_create_empty_sections() {
636 let rule = MD082NoEmptySections::new();
637 for (flavor, code) in [
638 (
639 MarkdownFlavor::Standard,
640 "{% set unused=\"first\n\nFake\n===\n\nPhantom\n---\nlast\" %}{{ unused|length }}",
641 ),
642 (
643 MarkdownFlavor::Hugo,
644 "{{< note title=`first\n\nFake\n===\n\nPhantom\n---\nlast` >}}",
645 ),
646 ] {
647 let source = format!("{code}\n\n# Visible\n\n## Next\n\nBody.\n");
648 let ctx = LintContext::new(&source, flavor, None);
649 let warnings = rule.check(&ctx).unwrap();
650 assert_eq!(warnings.len(), 1, "{source}");
651 assert!(warnings[0].message.contains("'Visible'"));
652 }
653 }
654
655 #[test]
656 fn dynamic_and_mdx_body_empty_headings_remain_eligible() {
657 let rule = MD082NoEmptySections::new();
658 for (flavor, source, line, end_line) in [
659 (MarkdownFlavor::Standard, "# {{ title }}\n\n## Next\n\nBody.\n", 1, 1),
660 (
661 MarkdownFlavor::Standard,
662 "First\n{{ title }}\n===\n\n# Next\n\nBody.\n",
663 1,
664 2,
665 ),
666 (
667 MarkdownFlavor::MDX,
668 "<div>\n\n# Visible\n\n## Next\n\nBody.\n\n</div>\n",
669 3,
670 3,
671 ),
672 ] {
673 let ctx = LintContext::new(source, flavor, None);
674 let warnings = rule.check(&ctx).unwrap();
675 assert_eq!(warnings.len(), 1, "{source}");
676 assert_eq!((warnings[0].line, warnings[0].end_line), (line, end_line));
677 }
678 }
679 mod quoted_headings {
680 use super::*;
681
682 fn lines(content: &str) -> Vec<usize> {
683 check_default(content).iter().map(|w| w.line).collect()
684 }
685
686 #[test]
687 fn heading_followed_directly_by_a_quoted_heading_is_empty() {
688 assert_eq!(lines("# A\n\n> ## B\n> body\n"), vec![1]);
689 assert_eq!(lines("# A\n\n> > ## B\n> > body\n"), vec![1]);
690 assert!(lines("# A\n\n- x\n\n > ## B\n > body\n").is_empty());
692 assert_eq!(lines("# A\n\n- x\n\n > ## B\n > ## C\n > body\n"), vec![5]);
693 assert_eq!(lines("# A\n\n> - ## B\n> body\n"), vec![1]);
694 }
695
696 #[test]
697 fn quoted_section_with_text_is_not_empty() {
698 assert!(lines("> # A\n> body\n>\n> ## B\n> more\n").is_empty());
699 }
700
701 #[test]
702 fn quoted_empty_section_is_reported_on_its_heading_line() {
703 assert_eq!(lines("> # A\n>\n> ## B\n> body\n"), vec![1]);
704 assert_eq!(lines("> # A\n> body\n\n## B\n\ntext\n"), Vec::<usize>::new());
705 assert_eq!(lines("> # A\n\n# B\n\ntext\n"), vec![1]);
706 }
707
708 #[test]
709 fn lone_quote_markers_and_quoted_definitions_are_not_content() {
710 assert_eq!(lines("> # A\n>\n>\n> ## B\n> body\n"), vec![1]);
711 assert_eq!(lines("> # A\n> [ref]: /url\n> ## B\n> body\n"), vec![1]);
712 }
713
714 #[test]
715 fn quoted_html_comments_are_not_content() {
716 assert_eq!(lines("> # A\n> <!-- c -->\n> # B\n> body\n"), vec![1]);
717 assert_eq!(lines("> # A\n> <!-- c\n> more -->\n> # B\n> body\n"), vec![1]);
718 assert_eq!(lines("> # A\n> <!-- c --> text\n> # B\n> body\n"), Vec::<usize>::new());
719 assert_eq!(lines("# A\n<!-- c -->\n# B\nbody\n"), vec![1]);
720 }
721
722 #[test]
723 fn quoted_setext_heading_ends_the_section_at_its_first_text_line() {
724 assert_eq!(lines("# A\n\n> First\n> second\n> ===\n> body\n"), vec![1]);
725 }
726
727 #[test]
728 fn quote_only_document_is_not_skipped() {
729 assert_eq!(lines("> # A\n> ## B\n> body\n"), vec![1]);
730 let ctx = LintContext::new("> # A\n> ## B\n> body\n", crate::config::MarkdownFlavor::Standard, None);
732 assert!(!MD082NoEmptySections::default().should_skip(&ctx));
733 let ctx = LintContext::new("text\n", crate::config::MarkdownFlavor::Standard, None);
734 assert!(MD082NoEmptySections::default().should_skip(&ctx));
735 }
736 }
737}