1use crate::lint_context::{HeadingStyle, LintContext};
24use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
25use crate::rule_config_serde::RuleConfig;
26use serde::{Deserialize, Serialize};
27
28fn default_level() -> u8 {
29 1
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "kebab-case")]
35pub struct MD082Config {
36 #[serde(default = "default_level")]
42 pub level: u8,
43}
44
45impl Default for MD082Config {
46 fn default() -> Self {
47 Self { level: default_level() }
48 }
49}
50
51impl RuleConfig for MD082Config {
52 const RULE_NAME: &'static str = "MD082";
53}
54
55struct HeadingPos {
57 index: usize,
59 level: u8,
61 is_setext: bool,
63 id_from_next_line: bool,
68 text: String,
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct MD082NoEmptySections {
74 config: MD082Config,
75}
76
77impl MD082NoEmptySections {
78 pub fn new() -> Self {
79 Self::default()
80 }
81
82 pub fn from_config_struct(config: MD082Config) -> Self {
83 Self { config }
84 }
85
86 fn is_content_line(&self, ctx: &LintContext, idx: usize) -> bool {
92 let Some(li) = ctx.lines.get(idx) else {
93 return false;
94 };
95 if li.is_blank || li.in_html_comment {
96 return false;
97 }
98 if li.is_horizontal_rule && li.blockquote.is_none() && !li.in_list_block {
102 return false;
103 }
104 if ctx.is_in_reference_def(li.byte_offset + li.indent) {
107 return false;
108 }
109 true
110 }
111
112 fn warn_empty_section(&self, ctx: &LintContext, heading: &HeadingPos) -> LintWarning {
113 let line_content = ctx.lines.get(heading.index).map_or("", |l| l.content(ctx.content));
114 let end_column = line_content.chars().count() + 1;
115 LintWarning {
116 rule_name: Some(self.name().to_string()),
117 severity: Severity::Warning,
118 line: heading.index + 1,
119 column: 1,
120 end_line: heading.index + 1,
121 end_column,
122 message: format!("Heading '{}' has no content before the next heading", heading.text),
123 fix: None,
124 }
125 }
126}
127
128impl Rule for MD082NoEmptySections {
129 fn name(&self) -> &'static str {
130 "MD082"
131 }
132
133 fn description(&self) -> &'static str {
134 "Headings should have content before the next heading"
135 }
136
137 fn category(&self) -> RuleCategory {
138 RuleCategory::Heading
139 }
140
141 fn should_skip(&self, ctx: &LintContext) -> bool {
142 !ctx.has_valid_headings()
143 }
144
145 fn check(&self, ctx: &LintContext) -> LintResult {
146 let headings: Vec<HeadingPos> = ctx
147 .valid_headings()
148 .map(|h| HeadingPos {
149 index: h.line_num - 1,
150 level: h.heading.level,
151 is_setext: matches!(h.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
152 id_from_next_line: h.heading.custom_id.is_some()
155 && crate::utils::header_id_utils::extract_header_id(&h.heading.raw_text)
156 .1
157 .is_none(),
158 text: h.heading.text.clone(),
159 })
160 .collect();
161
162 if headings.len() < 2 {
163 return Ok(Vec::new());
164 }
165
166 let mut warnings = Vec::new();
167 for pair in headings.windows(2) {
168 let cur = &pair[0];
169 let next = &pair[1];
170
171 if cur.level < self.config.level {
172 continue;
173 }
174
175 let content_start = if cur.is_setext { cur.index + 2 } else { cur.index + 1 };
180
181 let mut scan_start = content_start;
188 if cur.id_from_next_line
189 && let Some(li) = ctx.lines.get(content_start)
190 && crate::utils::header_id_utils::is_standalone_attr_list(li.content(ctx.content))
191 {
192 scan_start = content_start + 1;
193 }
194
195 let has_content = (scan_start..next.index).any(|idx| self.is_content_line(ctx, idx));
196 if !has_content {
197 warnings.push(self.warn_empty_section(ctx, cur));
198 }
199 }
200
201 Ok(warnings)
202 }
203
204 fn fix_capability(&self) -> FixCapability {
205 FixCapability::Unfixable
206 }
207
208 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
209 Ok(ctx.content.to_string())
212 }
213
214 fn as_any(&self) -> &dyn std::any::Any {
215 self
216 }
217
218 crate::impl_rule_config_methods!(MD082Config);
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::config::MarkdownFlavor;
225 use crate::rule::LintWarning;
226
227 fn check(content: &str, config: MD082Config) -> Vec<LintWarning> {
228 let rule = MD082NoEmptySections::from_config_struct(config);
229 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
230 rule.check(&ctx).unwrap()
231 }
232
233 fn check_default(content: &str) -> Vec<LintWarning> {
234 check(content, MD082Config::default())
235 }
236
237 #[test]
238 fn default_level_is_one() {
239 assert_eq!(MD082Config::default().level, 1);
240 }
241
242 #[test]
243 fn flags_atx_heading_immediately_followed_by_heading() {
244 let w = check_default("# A\n## B\n\nBody text\n");
245 assert_eq!(w.len(), 1, "got: {w:?}");
246 assert_eq!(w[0].line, 1);
247 assert!(w[0].message.contains('A'), "got: {}", w[0].message);
248 }
249
250 #[test]
251 fn accepts_heading_with_paragraph_body() {
252 let w = check_default("# A\n\nSome text\n\n## B\n\nMore text\n");
253 assert!(w.is_empty(), "got: {w:?}");
254 }
255
256 #[test]
257 fn flags_nested_empty_section_from_issue() {
258 let content =
261 "# Level 1 heading\n\nLevel 1 content\n\n## Empty Section\n### Level 3 heading\n\nLevel 3 content\n";
262 let w = check_default(content);
263 assert_eq!(w.len(), 1, "got: {w:?}");
264 assert_eq!(w[0].line, 5);
265 assert!(w[0].message.contains("Empty Section"));
266 }
267
268 #[test]
269 fn default_level_flags_h1_into_h2() {
270 let w = check_default("# Title\n## Section\n\nBody\n");
271 assert_eq!(w.len(), 1, "got: {w:?}");
272 assert_eq!(w[0].line, 1);
273 }
274
275 #[test]
276 fn level_2_exempts_h1_but_flags_h2() {
277 let config = MD082Config { level: 2 };
278 assert!(check("# Title\n## Section\n\nBody\n", config.clone()).is_empty());
280 let w = check("# Title\n\nIntro\n\n## A\n### B\n\nBody\n", config);
282 assert_eq!(w.len(), 1, "got: {w:?}");
283 assert_eq!(w[0].line, 5);
284 }
285
286 #[test]
287 fn flags_setext_heading_into_setext_heading() {
288 let w = check_default("Title\n=====\nSection\n-------\ncontent\n");
291 assert_eq!(w.len(), 1, "got: {w:?}");
292 assert_eq!(w[0].line, 1);
293 }
294
295 #[test]
296 fn accepts_setext_heading_with_body() {
297 let w = check_default("Title\n=====\n\nSome body\n\nSection\n-------\n\nMore\n");
298 assert!(w.is_empty(), "got: {w:?}");
299 }
300
301 #[test]
302 fn blank_lines_do_not_count_as_content() {
303 let w = check_default("# A\n\n\n## B\n\ncontent\n");
304 assert_eq!(w.len(), 1, "got: {w:?}");
305 assert_eq!(w[0].line, 1);
306 }
307
308 #[test]
309 fn html_comment_does_not_count_as_content() {
310 let w = check_default("# A\n\n<!-- a comment -->\n\n## B\n\ncontent\n");
311 assert_eq!(w.len(), 1, "got: {w:?}");
312 assert_eq!(w[0].line, 1);
313 }
314
315 #[test]
316 fn reference_definition_does_not_count_as_content() {
317 let w = check_default("# A\n\n[ref]: https://example.com\n\n## B\n\ncontent\n");
318 assert_eq!(w.len(), 1, "got: {w:?}");
319 assert_eq!(w[0].line, 1);
320 }
321
322 #[test]
323 fn thematic_break_does_not_count_as_content() {
324 for marker in ["---", "***", "___"] {
326 let input = format!("# A\n\n{marker}\n\n## B\n\ncontent\n");
327 let w = check_default(&input);
328 assert_eq!(w.len(), 1, "marker {marker:?}: got: {w:?}");
329 assert_eq!(w[0].line, 1, "marker {marker:?}");
330 }
331 }
332
333 #[test]
334 fn code_block_counts_as_content() {
335 let w = check_default("# A\n\n```\ncode\n```\n\n## B\n\ntext\n");
336 assert!(w.is_empty(), "got: {w:?}");
337 }
338
339 #[test]
340 fn list_counts_as_content() {
341 let w = check_default("# A\n\n- item\n\n## B\n\ntext\n");
342 assert!(w.is_empty(), "got: {w:?}");
343 }
344
345 #[test]
346 fn raw_html_block_counts_as_content() {
347 let w = check_default("# A\n\n<div>hello</div>\n\n## B\n\ntext\n");
348 assert!(w.is_empty(), "got: {w:?}");
349 }
350
351 #[test]
352 fn trailing_heading_at_eof_is_not_flagged() {
353 let w = check_default("# A\n\nbody\n\n## B\n");
356 assert!(w.is_empty(), "got: {w:?}");
357 }
358
359 #[test]
360 fn single_heading_is_not_flagged() {
361 assert!(check_default("# Only heading\n\ncontent\n").is_empty());
362 }
363
364 #[test]
365 fn document_without_headings_is_not_flagged() {
366 assert!(check_default("Just some text\nand more text\n").is_empty());
367 }
368
369 #[test]
370 fn invalid_heading_renders_as_content() {
371 let w = check_default("# A\n\n#nospace is text\n\n## B\n\ntext\n");
376 assert!(w.is_empty(), "got: {w:?}");
377 }
378
379 #[test]
380 fn standalone_attr_list_does_not_count_as_content() {
381 let w = check_default("# A\n{#a}\n## B\n\ntext\n");
383 assert_eq!(w.len(), 1, "got: {w:?}");
384 assert_eq!(w[0].line, 1);
385 }
386
387 #[test]
388 fn setext_standalone_attr_list_does_not_count_as_content() {
389 let w = check_default("Title\n=====\n{#a}\nSection\n-------\ntext\n");
391 assert_eq!(w.len(), 1, "got: {w:?}");
392 assert_eq!(w[0].line, 1);
393 }
394
395 #[test]
396 fn non_folded_attr_list_counts_as_content() {
397 let w = check_default("## A\n\n{#stray}\n\n## B\n\ntext\n");
401 assert!(w.is_empty(), "got: {w:?}");
402 }
403
404 #[test]
405 fn setext_non_folded_attr_list_counts_as_content() {
406 let w = check_default("Title\n=====\n\n{#a}\nSection\n-------\ntext\n");
409 assert!(w.is_empty(), "got: {w:?}");
410 }
411
412 #[test]
413 fn inline_id_heading_with_following_attr_list_counts_as_content() {
414 let w = check_default("## A {#x}\n{#y}\n## B\n\ntext\n");
417 assert!(w.is_empty(), "got: {w:?}");
418 }
419
420 #[test]
421 fn inline_id_then_matching_attr_list_counts_as_content() {
422 let w = check_default("## A {#x}\n{#x}\n## B\n\ntext\n");
426 assert!(w.is_empty(), "got: {w:?}");
427 }
428
429 #[test]
430 fn setext_inline_id_then_matching_attr_list_counts_as_content() {
431 let w = check_default("Title {#x}\n======\n{#x}\n## B\n\ntext\n");
433 assert!(w.is_empty(), "got: {w:?}");
434 }
435
436 #[test]
437 fn blockquoted_thematic_break_counts_as_content() {
438 let w = check_default("# A\n\n> ---\n\n## B\n\ntext\n");
441 assert!(w.is_empty(), "got: {w:?}");
442 }
443
444 #[test]
445 fn multiline_reference_definition_is_an_empty_section() {
446 let w = check_default("# A\n\n[ref]: https://example.com\n \"title\"\n\n## B\n\ntext\n");
450 assert_eq!(w.len(), 1, "got: {w:?}");
451 assert_eq!(w[0].line, 1);
452 }
453}