rumdl_lib/rules/
md023_heading_start_left.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::{LineIndex, calculate_single_line_range};
6
7#[derive(Clone)]
8pub struct MD023HeadingStartLeft;
9
10impl Rule for MD023HeadingStartLeft {
11 fn name(&self) -> &'static str {
12 "MD023"
13 }
14
15 fn description(&self) -> &'static str {
16 "Headings must start at the beginning of the line"
17 }
18
19 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
20 if ctx.lines.is_empty() {
22 return Ok(vec![]);
23 }
24
25 let line_index = LineIndex::new(ctx.content.to_string());
26 let mut warnings = Vec::new();
27
28 for (line_num, line_info) in ctx.lines.iter().enumerate() {
30 if let Some(heading) = &line_info.heading {
31 let indentation = line_info.indent;
32
33 if indentation > 0 {
35 let is_setext = matches!(
36 heading.style,
37 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
38 );
39
40 if is_setext {
41 let underline_line = line_num + 1;
43
44 let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
46 line_num + 1, 1,
48 indentation,
49 );
50
51 warnings.push(LintWarning {
53 rule_name: Some(self.name()),
54 line: start_line_calc,
55 column: start_col,
56 end_line,
57 end_column: end_col,
58 severity: Severity::Warning,
59 message: format!("Setext heading should not be indented by {indentation} spaces"),
60 fix: Some(Fix {
61 range: line_index.line_col_to_byte_range_with_length(
62 line_num + 1,
63 start_col,
64 indentation,
65 ),
66 replacement: String::new(), }),
68 });
69
70 if underline_line < ctx.lines.len() {
72 let underline_indentation = ctx.lines[underline_line].indent;
73 if underline_indentation > 0 {
74 let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
76 calculate_single_line_range(
77 underline_line + 1, 1,
79 underline_indentation,
80 );
81
82 warnings.push(LintWarning {
83 rule_name: Some(self.name()),
84 line: underline_start_line,
85 column: underline_start_col,
86 end_line: underline_end_line,
87 end_column: underline_end_col,
88 severity: Severity::Warning,
89 message: "Setext heading underline should not be indented".to_string(),
90 fix: Some(Fix {
91 range: line_index.line_col_to_byte_range_with_length(
92 underline_line + 1,
93 underline_start_col,
94 underline_indentation,
95 ),
96 replacement: String::new(), }),
98 });
99 }
100 }
101 } else {
102 let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
106 line_num + 1, 1,
108 indentation,
109 );
110
111 warnings.push(LintWarning {
112 rule_name: Some(self.name()),
113 line: atx_start_line,
114 column: atx_start_col,
115 end_line: atx_end_line,
116 end_column: atx_end_col,
117 severity: Severity::Warning,
118 message: format!("Heading should not be indented by {indentation} spaces"),
119 fix: Some(Fix {
120 range: line_index.line_col_to_byte_range_with_length(
121 line_num + 1,
122 atx_start_col,
123 indentation,
124 ),
125 replacement: String::new(), }),
127 });
128 }
129 }
130 }
131 }
132
133 Ok(warnings)
134 }
135
136 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
137 let mut fixed_lines = Vec::new();
138 let mut skip_next = false;
139
140 for (i, line_info) in ctx.lines.iter().enumerate() {
141 if skip_next {
142 skip_next = false;
143 continue;
144 }
145
146 if let Some(heading) = &line_info.heading {
148 let indentation = line_info.indent;
149 let is_setext = matches!(
150 heading.style,
151 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
152 );
153
154 if indentation > 0 {
155 if is_setext {
157 fixed_lines.push(line_info.content.trim().to_string());
159 if i + 1 < ctx.lines.len() {
161 fixed_lines.push(ctx.lines[i + 1].content.trim().to_string());
162 skip_next = true;
163 }
164 } else {
165 fixed_lines.push(line_info.content.trim_start().to_string());
167 }
168 } else {
169 fixed_lines.push(line_info.content.clone());
171 if is_setext && i + 1 < ctx.lines.len() {
172 fixed_lines.push(ctx.lines[i + 1].content.clone());
173 skip_next = true;
174 }
175 }
176 } else {
177 fixed_lines.push(line_info.content.clone());
179 }
180 }
181
182 let result = fixed_lines.join("\n");
183 if ctx.content.ends_with('\n') {
184 Ok(result + "\n")
185 } else {
186 Ok(result)
187 }
188 }
189
190 fn category(&self) -> RuleCategory {
192 RuleCategory::Heading
193 }
194
195 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
197 if !ctx.likely_has_headings() {
199 return true;
200 }
201 ctx.lines.iter().all(|line| line.heading.is_none())
203 }
204
205 fn as_any(&self) -> &dyn std::any::Any {
206 self
207 }
208
209 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
210 where
211 Self: Sized,
212 {
213 Box::new(MD023HeadingStartLeft)
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::lint_context::LintContext;
221 #[test]
222 fn test_basic_functionality() {
223 let rule = MD023HeadingStartLeft;
224
225 let content = "# Heading 1\n## Heading 2\n### Heading 3";
227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
228 let result = rule.check(&ctx).unwrap();
229 assert!(result.is_empty());
230
231 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
234 let result = rule.check(&ctx).unwrap();
235 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
237 assert_eq!(result[1].line, 2);
238 assert_eq!(result[2].line, 3);
239
240 let content = "Heading 1\n=========\n Heading 2\n ---------";
242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
243 let result = rule.check(&ctx).unwrap();
244 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
246 assert_eq!(result[1].line, 4);
247 }
248}