1use crate::HeadingStyle;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rules::front_matter_utils::FrontMatterUtils;
4use crate::rules::heading_utils::HeadingUtils;
5use crate::utils::range_utils::calculate_heading_range;
6use regex::Regex;
7
8#[derive(Debug, Clone)]
78pub struct MD001HeadingIncrement {
79 pub front_matter_title: bool,
81 pub front_matter_title_pattern: Option<Regex>,
83}
84
85impl Default for MD001HeadingIncrement {
86 fn default() -> Self {
87 Self {
88 front_matter_title: true,
89 front_matter_title_pattern: None,
90 }
91 }
92}
93
94impl MD001HeadingIncrement {
95 pub fn new(front_matter_title: bool) -> Self {
97 Self {
98 front_matter_title,
99 front_matter_title_pattern: None,
100 }
101 }
102
103 pub fn with_pattern(front_matter_title: bool, pattern: Option<String>) -> Self {
105 let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
106 Ok(regex) => Some(regex),
107 Err(e) => {
108 log::warn!("Invalid front_matter_title_pattern regex for MD001: {e}");
109 None
110 }
111 });
112
113 Self {
114 front_matter_title,
115 front_matter_title_pattern,
116 }
117 }
118
119 fn has_front_matter_title(&self, content: &str) -> bool {
121 if !self.front_matter_title {
122 return false;
123 }
124
125 if let Some(ref pattern) = self.front_matter_title_pattern {
127 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
128 for line in front_matter_lines {
129 if pattern.is_match(line) {
130 return true;
131 }
132 }
133 return false;
134 }
135
136 FrontMatterUtils::has_front_matter_field(content, "title:")
138 }
139}
140
141impl Rule for MD001HeadingIncrement {
142 fn name(&self) -> &'static str {
143 "MD001"
144 }
145
146 fn description(&self) -> &'static str {
147 "Heading levels should only increment by one level at a time"
148 }
149
150 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
151 let mut warnings = Vec::new();
152
153 let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
155 Some(1)
156 } else {
157 None
158 };
159
160 for valid_heading in ctx.valid_headings() {
162 let heading = valid_heading.heading;
163 let line_info = valid_heading.line_info;
164 let level = heading.level as usize;
165
166 if let Some(prev) = prev_level
168 && level > prev + 1
169 {
170 let indentation = line_info.indent;
171 let heading_text = &heading.text;
172
173 let style = match heading.style {
175 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
176 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
177 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
178 };
179
180 let fixed_level = prev + 1;
182 let replacement = HeadingUtils::convert_heading_style(heading_text, fixed_level as u32, style);
183
184 let line_content = line_info.content(ctx.content);
186 let (start_line, start_col, end_line, end_col) =
187 calculate_heading_range(valid_heading.line_num, line_content);
188
189 warnings.push(LintWarning {
190 rule_name: Some(self.name().to_string()),
191 line: start_line,
192 column: start_col,
193 end_line,
194 end_column: end_col,
195 message: format!("Expected heading level {}, but found heading level {}", prev + 1, level),
196 severity: Severity::Error,
197 fix: Some(Fix {
198 range: ctx.line_index.line_content_range(valid_heading.line_num),
199 replacement: format!("{}{}", " ".repeat(indentation), replacement),
200 }),
201 });
202 }
203
204 prev_level = Some(level);
205 }
206
207 Ok(warnings)
208 }
209
210 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
211 let mut fixed_lines = Vec::new();
212
213 let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
215 Some(1)
216 } else {
217 None
218 };
219
220 for line_info in ctx.lines.iter() {
221 if let Some(heading) = &line_info.heading {
222 if !heading.is_valid {
224 fixed_lines.push(line_info.content(ctx.content).to_string());
225 continue;
226 }
227
228 let level = heading.level as usize;
229 let mut fixed_level = level;
230
231 if let Some(prev) = prev_level
233 && level > prev + 1
234 {
235 fixed_level = prev + 1;
236 }
237
238 let style = match heading.style {
240 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
241 crate::lint_context::HeadingStyle::Setext1 => {
242 if fixed_level == 1 {
243 HeadingStyle::Setext1
244 } else {
245 HeadingStyle::Setext2
246 }
247 }
248 crate::lint_context::HeadingStyle::Setext2 => {
249 if fixed_level == 1 {
250 HeadingStyle::Setext1
251 } else {
252 HeadingStyle::Setext2
253 }
254 }
255 };
256
257 let replacement = HeadingUtils::convert_heading_style(&heading.text, fixed_level as u32, style);
258 fixed_lines.push(format!("{}{}", " ".repeat(line_info.indent), replacement));
259
260 prev_level = Some(fixed_level);
261 } else {
262 fixed_lines.push(line_info.content(ctx.content).to_string());
263 }
264 }
265
266 let mut result = fixed_lines.join("\n");
267 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
268 result.push('\n');
269 }
270 Ok(result)
271 }
272
273 fn category(&self) -> RuleCategory {
274 RuleCategory::Heading
275 }
276
277 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
278 if ctx.content.is_empty() || !ctx.likely_has_headings() {
280 return true;
281 }
282 !ctx.has_valid_headings()
284 }
285
286 fn as_any(&self) -> &dyn std::any::Any {
287 self
288 }
289
290 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
291 where
292 Self: Sized,
293 {
294 let (front_matter_title, front_matter_title_pattern) = if let Some(rule_config) = config.rules.get("MD001") {
296 let fmt = rule_config
297 .values
298 .get("front-matter-title")
299 .or_else(|| rule_config.values.get("front_matter_title"))
300 .and_then(|v| v.as_bool())
301 .unwrap_or(true);
302
303 let pattern = rule_config
304 .values
305 .get("front-matter-title-pattern")
306 .or_else(|| rule_config.values.get("front_matter_title_pattern"))
307 .and_then(|v| v.as_str())
308 .filter(|s: &&str| !s.is_empty())
309 .map(String::from);
310
311 (fmt, pattern)
312 } else {
313 (true, None)
314 };
315
316 Box::new(MD001HeadingIncrement::with_pattern(
317 front_matter_title,
318 front_matter_title_pattern,
319 ))
320 }
321
322 fn default_config_section(&self) -> Option<(String, toml::Value)> {
323 Some((
324 "MD001".to_string(),
325 toml::toml! {
326 front-matter-title = true
327 }
328 .into(),
329 ))
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::lint_context::LintContext;
337
338 #[test]
339 fn test_basic_functionality() {
340 let rule = MD001HeadingIncrement::default();
341
342 let content = "# Heading 1\n## Heading 2\n### Heading 3";
344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345 let result = rule.check(&ctx).unwrap();
346 assert!(result.is_empty());
347
348 let content = "# Heading 1\n### Heading 3\n#### Heading 4";
350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
351 let result = rule.check(&ctx).unwrap();
352 assert_eq!(result.len(), 1);
353 assert_eq!(result[0].line, 2);
354 }
355
356 #[test]
357 fn test_frontmatter_title_counts_as_h1() {
358 let rule = MD001HeadingIncrement::default();
359
360 let content = "---\ntitle: My Document\n---\n\n## First Section";
362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363 let result = rule.check(&ctx).unwrap();
364 assert!(
365 result.is_empty(),
366 "H2 after frontmatter title should not trigger warning"
367 );
368
369 let content = "---\ntitle: My Document\n---\n\n### Third Level";
371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
372 let result = rule.check(&ctx).unwrap();
373 assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
374 assert!(result[0].message.contains("Expected heading level 2"));
375 }
376
377 #[test]
378 fn test_frontmatter_without_title() {
379 let rule = MD001HeadingIncrement::default();
380
381 let content = "---\nauthor: John\n---\n\n## First Section";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385 let result = rule.check(&ctx).unwrap();
386 assert!(
387 result.is_empty(),
388 "First heading after frontmatter without title has no predecessor"
389 );
390 }
391
392 #[test]
393 fn test_frontmatter_title_disabled() {
394 let rule = MD001HeadingIncrement::new(false);
395
396 let content = "---\ntitle: My Document\n---\n\n## First Section";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let result = rule.check(&ctx).unwrap();
400 assert!(
401 result.is_empty(),
402 "With front_matter_title disabled, first heading has no predecessor"
403 );
404 }
405
406 #[test]
407 fn test_frontmatter_title_with_subsequent_headings() {
408 let rule = MD001HeadingIncrement::default();
409
410 let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413 let result = rule.check(&ctx).unwrap();
414 assert!(result.is_empty(), "Valid heading progression after frontmatter title");
415 }
416
417 #[test]
418 fn test_frontmatter_title_fix() {
419 let rule = MD001HeadingIncrement::default();
420
421 let content = "---\ntitle: My Document\n---\n\n### Third Level";
423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424 let fixed = rule.fix(&ctx).unwrap();
425 assert!(
426 fixed.contains("## Third Level"),
427 "H3 should be fixed to H2 when frontmatter has title"
428 );
429 }
430
431 #[test]
432 fn test_toml_frontmatter_title() {
433 let rule = MD001HeadingIncrement::default();
434
435 let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
438 let result = rule.check(&ctx).unwrap();
439 assert!(result.is_empty(), "TOML frontmatter title should count as H1");
440 }
441
442 #[test]
443 fn test_no_frontmatter_no_h1() {
444 let rule = MD001HeadingIncrement::default();
445
446 let content = "## First Section\n\n### Subsection";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.check(&ctx).unwrap();
450 assert!(
451 result.is_empty(),
452 "First heading (even if H2) has no predecessor to compare against"
453 );
454 }
455}