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::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 mut warnings = Vec::new();
73 let mut prev_level: Option<usize> = None;
74
75 for (line_num, line_info) in ctx.lines.iter().enumerate() {
77 if let Some(heading) = &line_info.heading {
78 let level = heading.level as usize;
79
80 if let Some(prev) = prev_level
82 && level > prev + 1
83 {
84 let indentation = line_info.indent;
85 let heading_text = &heading.text;
86
87 let style = match heading.style {
89 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
90 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
91 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
92 };
93
94 let fixed_level = prev + 1;
96 let replacement = HeadingUtils::convert_heading_style(heading_text, fixed_level as u32, style);
97
98 let line_content = line_info.content(ctx.content);
100 let (start_line, start_col, end_line, end_col) =
101 calculate_heading_range(line_num + 1, line_content);
102
103 warnings.push(LintWarning {
104 rule_name: Some(self.name().to_string()),
105 line: start_line,
106 column: start_col,
107 end_line,
108 end_column: end_col,
109 message: format!("Expected heading level {}, but found heading level {}", prev + 1, level),
110 severity: Severity::Warning,
111 fix: Some(Fix {
112 range: ctx.line_index.line_content_range(line_num + 1),
113 replacement: format!("{}{}", " ".repeat(indentation), replacement),
114 }),
115 });
116 }
117
118 prev_level = Some(level);
119 }
120 }
121
122 Ok(warnings)
123 }
124
125 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
126 let mut fixed_lines = Vec::new();
127 let mut prev_level: Option<usize> = None;
128
129 for line_info in ctx.lines.iter() {
130 if let Some(heading) = &line_info.heading {
131 let level = heading.level as usize;
132 let mut fixed_level = level;
133
134 if let Some(prev) = prev_level
136 && level > prev + 1
137 {
138 fixed_level = prev + 1;
139 }
140
141 let style = match heading.style {
143 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
144 crate::lint_context::HeadingStyle::Setext1 => {
145 if fixed_level == 1 {
146 HeadingStyle::Setext1
147 } else {
148 HeadingStyle::Setext2
149 }
150 }
151 crate::lint_context::HeadingStyle::Setext2 => {
152 if fixed_level == 1 {
153 HeadingStyle::Setext1
154 } else {
155 HeadingStyle::Setext2
156 }
157 }
158 };
159
160 let replacement = HeadingUtils::convert_heading_style(&heading.text, fixed_level as u32, style);
161 fixed_lines.push(format!("{}{}", " ".repeat(line_info.indent), replacement));
162
163 prev_level = Some(fixed_level);
164 } else {
165 fixed_lines.push(line_info.content(ctx.content).to_string());
166 }
167 }
168
169 let mut result = fixed_lines.join("\n");
170 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
171 result.push('\n');
172 }
173 Ok(result)
174 }
175
176 fn category(&self) -> RuleCategory {
177 RuleCategory::Heading
178 }
179
180 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
181 if ctx.content.is_empty() || !ctx.likely_has_headings() {
183 return true;
184 }
185 !ctx.lines.iter().any(|line| line.heading.is_some())
187 }
188
189 fn as_any(&self) -> &dyn std::any::Any {
190 self
191 }
192
193 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
194 where
195 Self: Sized,
196 {
197 Box::new(MD001HeadingIncrement)
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::lint_context::LintContext;
205
206 #[test]
207 fn test_basic_functionality() {
208 let rule = MD001HeadingIncrement;
209
210 let content = "# Heading 1\n## Heading 2\n### Heading 3";
212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
213 let result = rule.check(&ctx).unwrap();
214 assert!(result.is_empty());
215
216 let content = "# Heading 1\n### Heading 3\n#### Heading 4";
218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
219 let result = rule.check(&ctx).unwrap();
220 assert_eq!(result.len(), 1);
221 assert_eq!(result[0].line, 2);
222 }
223}