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 line = line_info.content(ctx.content);
172 let original_indent = &line[..line_info.indent];
173 let heading_text = &heading.text;
174
175 let style = match heading.style {
177 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
178 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
179 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
180 };
181
182 let fixed_level = prev + 1;
184 let replacement = HeadingUtils::convert_heading_style(heading_text, fixed_level as u32, style);
185
186 let line_content = line_info.content(ctx.content);
188 let (start_line, start_col, end_line, end_col) =
189 calculate_heading_range(valid_heading.line_num, line_content);
190
191 warnings.push(LintWarning {
192 rule_name: Some(self.name().to_string()),
193 line: start_line,
194 column: start_col,
195 end_line,
196 end_column: end_col,
197 message: format!("Expected heading level {}, but found heading level {}", prev + 1, level),
198 severity: Severity::Error,
199 fix: Some(Fix {
200 range: ctx.line_index.line_content_range(valid_heading.line_num),
201 replacement: format!("{original_indent}{replacement}"),
202 }),
203 });
204 }
205
206 prev_level = Some(level);
207 }
208
209 Ok(warnings)
210 }
211
212 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
213 let mut fixed_lines = Vec::new();
214
215 let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
217 Some(1)
218 } else {
219 None
220 };
221
222 for line_info in ctx.lines.iter() {
223 if let Some(heading) = &line_info.heading {
224 if !heading.is_valid {
226 fixed_lines.push(line_info.content(ctx.content).to_string());
227 continue;
228 }
229
230 let level = heading.level as usize;
231 let mut fixed_level = level;
232
233 if let Some(prev) = prev_level
235 && level > prev + 1
236 {
237 fixed_level = prev + 1;
238 }
239
240 let style = match heading.style {
242 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
243 crate::lint_context::HeadingStyle::Setext1 => {
244 if fixed_level == 1 {
245 HeadingStyle::Setext1
246 } else {
247 HeadingStyle::Setext2
248 }
249 }
250 crate::lint_context::HeadingStyle::Setext2 => {
251 if fixed_level == 1 {
252 HeadingStyle::Setext1
253 } else {
254 HeadingStyle::Setext2
255 }
256 }
257 };
258
259 let replacement = HeadingUtils::convert_heading_style(&heading.text, fixed_level as u32, style);
260 let line = line_info.content(ctx.content);
262 let original_indent = &line[..line_info.indent];
263 fixed_lines.push(format!("{original_indent}{replacement}"));
264
265 prev_level = Some(fixed_level);
266 } else {
267 fixed_lines.push(line_info.content(ctx.content).to_string());
268 }
269 }
270
271 let mut result = fixed_lines.join("\n");
272 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
273 result.push('\n');
274 }
275 Ok(result)
276 }
277
278 fn category(&self) -> RuleCategory {
279 RuleCategory::Heading
280 }
281
282 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
283 if ctx.content.is_empty() || !ctx.likely_has_headings() {
285 return true;
286 }
287 !ctx.has_valid_headings()
289 }
290
291 fn as_any(&self) -> &dyn std::any::Any {
292 self
293 }
294
295 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
296 where
297 Self: Sized,
298 {
299 let (front_matter_title, front_matter_title_pattern) = if let Some(rule_config) = config.rules.get("MD001") {
301 let fmt = rule_config
302 .values
303 .get("front-matter-title")
304 .or_else(|| rule_config.values.get("front_matter_title"))
305 .and_then(|v| v.as_bool())
306 .unwrap_or(true);
307
308 let pattern = rule_config
309 .values
310 .get("front-matter-title-pattern")
311 .or_else(|| rule_config.values.get("front_matter_title_pattern"))
312 .and_then(|v| v.as_str())
313 .filter(|s: &&str| !s.is_empty())
314 .map(String::from);
315
316 (fmt, pattern)
317 } else {
318 (true, None)
319 };
320
321 Box::new(MD001HeadingIncrement::with_pattern(
322 front_matter_title,
323 front_matter_title_pattern,
324 ))
325 }
326
327 fn default_config_section(&self) -> Option<(String, toml::Value)> {
328 Some((
329 "MD001".to_string(),
330 toml::toml! {
331 front-matter-title = true
332 }
333 .into(),
334 ))
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::lint_context::LintContext;
342
343 #[test]
344 fn test_basic_functionality() {
345 let rule = MD001HeadingIncrement::default();
346
347 let content = "# Heading 1\n## Heading 2\n### Heading 3";
349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
350 let result = rule.check(&ctx).unwrap();
351 assert!(result.is_empty());
352
353 let content = "# Heading 1\n### Heading 3\n#### Heading 4";
355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
356 let result = rule.check(&ctx).unwrap();
357 assert_eq!(result.len(), 1);
358 assert_eq!(result[0].line, 2);
359 }
360
361 #[test]
362 fn test_frontmatter_title_counts_as_h1() {
363 let rule = MD001HeadingIncrement::default();
364
365 let content = "---\ntitle: My Document\n---\n\n## First Section";
367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368 let result = rule.check(&ctx).unwrap();
369 assert!(
370 result.is_empty(),
371 "H2 after frontmatter title should not trigger warning"
372 );
373
374 let content = "---\ntitle: My Document\n---\n\n### Third Level";
376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377 let result = rule.check(&ctx).unwrap();
378 assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
379 assert!(result[0].message.contains("Expected heading level 2"));
380 }
381
382 #[test]
383 fn test_frontmatter_without_title() {
384 let rule = MD001HeadingIncrement::default();
385
386 let content = "---\nauthor: John\n---\n\n## First Section";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390 let result = rule.check(&ctx).unwrap();
391 assert!(
392 result.is_empty(),
393 "First heading after frontmatter without title has no predecessor"
394 );
395 }
396
397 #[test]
398 fn test_frontmatter_title_disabled() {
399 let rule = MD001HeadingIncrement::new(false);
400
401 let content = "---\ntitle: My Document\n---\n\n## First Section";
403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
404 let result = rule.check(&ctx).unwrap();
405 assert!(
406 result.is_empty(),
407 "With front_matter_title disabled, first heading has no predecessor"
408 );
409 }
410
411 #[test]
412 fn test_frontmatter_title_with_subsequent_headings() {
413 let rule = MD001HeadingIncrement::default();
414
415 let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
418 let result = rule.check(&ctx).unwrap();
419 assert!(result.is_empty(), "Valid heading progression after frontmatter title");
420 }
421
422 #[test]
423 fn test_frontmatter_title_fix() {
424 let rule = MD001HeadingIncrement::default();
425
426 let content = "---\ntitle: My Document\n---\n\n### Third Level";
428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429 let fixed = rule.fix(&ctx).unwrap();
430 assert!(
431 fixed.contains("## Third Level"),
432 "H3 should be fixed to H2 when frontmatter has title"
433 );
434 }
435
436 #[test]
437 fn test_toml_frontmatter_title() {
438 let rule = MD001HeadingIncrement::default();
439
440 let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443 let result = rule.check(&ctx).unwrap();
444 assert!(result.is_empty(), "TOML frontmatter title should count as H1");
445 }
446
447 #[test]
448 fn test_no_frontmatter_no_h1() {
449 let rule = MD001HeadingIncrement::default();
450
451 let content = "## First Section\n\n### Subsection";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454 let result = rule.check(&ctx).unwrap();
455 assert!(
456 result.is_empty(),
457 "First heading (even if H2) has no predecessor to compare against"
458 );
459 }
460}