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,
73 level: u8,
75 is_setext: bool,
77 id_from_next_line: bool,
82 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 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 if li.is_horizontal_rule && li.blockquote.is_none() && !li.in_list_block {
116 return false;
117 }
118 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 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 if self.config.allow_parent_headings && next.level > cur.level {
192 continue;
193 }
194
195 let content_start = if cur.is_setext { cur.index + 2 } else { cur.index + 1 };
200
201 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 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 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 assert!(check("# Title\n## Section\n\nBody\n", config.clone()).is_empty());
303 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}