rumdl_lib/rules/
md001_heading_increment.rs1use crate::HeadingStyle;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rules::heading_utils::HeadingUtils;
4use crate::utils::range_utils::{LineIndex, calculate_heading_range};
5
6#[derive(Debug, Default, Clone)]
60pub struct MD001HeadingIncrement;
61
62impl Rule for MD001HeadingIncrement {
63 fn name(&self) -> &'static str {
64 "MD001"
65 }
66
67 fn description(&self) -> &'static str {
68 "Heading levels should only increment by one level at a time"
69 }
70
71 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
72 let line_index = LineIndex::new(ctx.content.to_string());
73 let mut warnings = Vec::new();
74 let mut prev_level: Option<usize> = None;
75
76 for (line_num, line_info) in ctx.lines.iter().enumerate() {
78 if let Some(heading) = &line_info.heading {
79 let level = heading.level as usize;
80
81 if let Some(prev) = prev_level
83 && level > prev + 1
84 {
85 let indentation = line_info.indent;
86 let heading_text = &heading.text;
87
88 let style = match heading.style {
90 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
91 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
92 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
93 };
94
95 let fixed_level = prev + 1;
97 let replacement = HeadingUtils::convert_heading_style(heading_text, fixed_level as u32, style);
98
99 let line_content = &line_info.content;
101 let (start_line, start_col, end_line, end_col) =
102 calculate_heading_range(line_num + 1, line_content);
103
104 warnings.push(LintWarning {
105 rule_name: Some(self.name()),
106 line: start_line,
107 column: start_col,
108 end_line,
109 end_column: end_col,
110 message: format!("Expected heading level {}, but found heading level {}", prev + 1, level),
111 severity: Severity::Warning,
112 fix: Some(Fix {
113 range: line_index.line_content_range(line_num + 1),
114 replacement: format!("{}{}", " ".repeat(indentation), replacement),
115 }),
116 });
117 }
118
119 prev_level = Some(level);
120 }
121 }
122
123 Ok(warnings)
124 }
125
126 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
127 let mut fixed_lines = Vec::new();
128 let mut prev_level: Option<usize> = None;
129
130 for line_info in ctx.lines.iter() {
131 if let Some(heading) = &line_info.heading {
132 let level = heading.level as usize;
133 let mut fixed_level = level;
134
135 if let Some(prev) = prev_level
137 && level > prev + 1
138 {
139 fixed_level = prev + 1;
140 }
141
142 let style = match heading.style {
144 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
145 crate::lint_context::HeadingStyle::Setext1 => {
146 if fixed_level == 1 {
147 HeadingStyle::Setext1
148 } else {
149 HeadingStyle::Setext2
150 }
151 }
152 crate::lint_context::HeadingStyle::Setext2 => {
153 if fixed_level == 1 {
154 HeadingStyle::Setext1
155 } else {
156 HeadingStyle::Setext2
157 }
158 }
159 };
160
161 let replacement = HeadingUtils::convert_heading_style(&heading.text, fixed_level as u32, style);
162 fixed_lines.push(format!("{}{}", " ".repeat(line_info.indent), replacement));
163
164 prev_level = Some(fixed_level);
165 } else {
166 fixed_lines.push(line_info.content.clone());
167 }
168 }
169
170 let mut result = fixed_lines.join("\n");
171 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
172 result.push('\n');
173 }
174 Ok(result)
175 }
176
177 fn category(&self) -> RuleCategory {
178 RuleCategory::Heading
179 }
180
181 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
182 if ctx.content.is_empty() || !ctx.likely_has_headings() {
184 return true;
185 }
186 !ctx.lines.iter().any(|line| line.heading.is_some())
188 }
189
190 fn as_any(&self) -> &dyn std::any::Any {
191 self
192 }
193
194 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
195 where
196 Self: Sized,
197 {
198 Box::new(MD001HeadingIncrement)
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::lint_context::LintContext;
206
207 #[test]
208 fn test_basic_functionality() {
209 let rule = MD001HeadingIncrement;
210
211 let content = "# Heading 1\n## Heading 2\n### Heading 3";
213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
214 let result = rule.check(&ctx).unwrap();
215 assert!(result.is_empty());
216
217 let content = "# Heading 1\n### Heading 3\n#### Heading 4";
219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
220 let result = rule.check(&ctx).unwrap();
221 assert_eq!(result.len(), 1);
222 assert_eq!(result[0].line, 2);
223 }
224}