1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::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 mut warnings = Vec::new();
26
27 for (line_num, line_info) in ctx.lines.iter().enumerate() {
29 if let Some(heading) = &line_info.heading {
30 if !heading.is_valid {
32 continue;
33 }
34
35 if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
38 let first_word: String = heading
40 .text
41 .trim()
42 .chars()
43 .take_while(|c| !c.is_whitespace() && *c != ',' && *c != ')')
44 .collect();
45 if let Some(first_char) = first_word.chars().next() {
46 if first_char.is_lowercase() || first_char.is_numeric() {
48 continue;
49 }
50 }
51 }
52
53 let indentation = line_info.indent;
54
55 if indentation > 0 {
57 let is_setext = matches!(
58 heading.style,
59 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
60 );
61
62 if is_setext {
63 let underline_line = line_num + 1;
65
66 let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
68 line_num + 1, 1,
70 indentation,
71 );
72
73 warnings.push(LintWarning {
75 rule_name: Some(self.name().to_string()),
76 line: start_line_calc,
77 column: start_col,
78 end_line,
79 end_column: end_col,
80 severity: Severity::Warning,
81 message: format!("Setext heading should not be indented by {indentation} spaces"),
82 fix: Some(Fix {
83 range: ctx.line_index.line_col_to_byte_range_with_length(
84 line_num + 1,
85 start_col,
86 indentation,
87 ),
88 replacement: String::new(), }),
90 });
91
92 if underline_line < ctx.lines.len() {
94 let underline_indentation = ctx.lines[underline_line].indent;
95 if underline_indentation > 0 {
96 let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
98 calculate_single_line_range(
99 underline_line + 1, 1,
101 underline_indentation,
102 );
103
104 warnings.push(LintWarning {
105 rule_name: Some(self.name().to_string()),
106 line: underline_start_line,
107 column: underline_start_col,
108 end_line: underline_end_line,
109 end_column: underline_end_col,
110 severity: Severity::Warning,
111 message: "Setext heading underline should not be indented".to_string(),
112 fix: Some(Fix {
113 range: ctx.line_index.line_col_to_byte_range_with_length(
114 underline_line + 1,
115 underline_start_col,
116 underline_indentation,
117 ),
118 replacement: String::new(), }),
120 });
121 }
122 }
123 } else {
124 let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
128 line_num + 1, 1,
130 indentation,
131 );
132
133 warnings.push(LintWarning {
134 rule_name: Some(self.name().to_string()),
135 line: atx_start_line,
136 column: atx_start_col,
137 end_line: atx_end_line,
138 end_column: atx_end_col,
139 severity: Severity::Warning,
140 message: format!("Heading should not be indented by {indentation} spaces"),
141 fix: Some(Fix {
142 range: ctx.line_index.line_col_to_byte_range_with_length(
143 line_num + 1,
144 atx_start_col,
145 indentation,
146 ),
147 replacement: String::new(), }),
149 });
150 }
151 }
152 }
153 }
154
155 Ok(warnings)
156 }
157
158 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
159 let mut fixed_lines = Vec::new();
160 let mut skip_next = false;
161
162 for (i, line_info) in ctx.lines.iter().enumerate() {
163 if skip_next {
164 skip_next = false;
165 continue;
166 }
167
168 if let Some(heading) = &line_info.heading {
170 if !heading.is_valid {
172 fixed_lines.push(line_info.content(ctx.content).to_string());
173 continue;
174 }
175
176 let indentation = line_info.indent;
177 let is_setext = matches!(
178 heading.style,
179 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
180 );
181
182 if indentation > 0 {
183 if is_setext {
185 fixed_lines.push(line_info.content(ctx.content).trim().to_string());
187 if i + 1 < ctx.lines.len() {
189 fixed_lines.push(ctx.lines[i + 1].content(ctx.content).trim().to_string());
190 skip_next = true;
191 }
192 } else {
193 fixed_lines.push(line_info.content(ctx.content).trim_start().to_string());
195 }
196 } else {
197 fixed_lines.push(line_info.content(ctx.content).to_string());
199 if is_setext && i + 1 < ctx.lines.len() {
200 fixed_lines.push(ctx.lines[i + 1].content(ctx.content).to_string());
201 skip_next = true;
202 }
203 }
204 } else {
205 fixed_lines.push(line_info.content(ctx.content).to_string());
207 }
208 }
209
210 let result = fixed_lines.join("\n");
211 if ctx.content.ends_with('\n') {
212 Ok(result + "\n")
213 } else {
214 Ok(result)
215 }
216 }
217
218 fn category(&self) -> RuleCategory {
220 RuleCategory::Heading
221 }
222
223 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
225 if !ctx.likely_has_headings() {
227 return true;
228 }
229 ctx.lines.iter().all(|line| line.heading.is_none())
231 }
232
233 fn as_any(&self) -> &dyn std::any::Any {
234 self
235 }
236
237 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
238 where
239 Self: Sized,
240 {
241 Box::new(MD023HeadingStartLeft)
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::lint_context::LintContext;
249 #[test]
250 fn test_basic_functionality() {
251 let rule = MD023HeadingStartLeft;
252
253 let content = "# Heading 1\n## Heading 2\n### Heading 3";
255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
256 let result = rule.check(&ctx).unwrap();
257 assert!(result.is_empty());
258
259 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
262 let result = rule.check(&ctx).unwrap();
263 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
265 assert_eq!(result[1].line, 2);
266 assert_eq!(result[2].line, 3);
267
268 let content = "Heading 1\n=========\n Heading 2\n ---------";
270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
271 let result = rule.check(&ctx).unwrap();
272 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
274 assert_eq!(result[1].line, 4);
275 }
276
277 #[test]
278 fn test_issue_refs_skipped_but_real_headings_caught() {
279 let rule = MD023HeadingStartLeft;
280
281 let content = "- fix: issue\n #29039)";
283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
284 let result = rule.check(&ctx).unwrap();
285 assert!(
286 result.is_empty(),
287 "#29039) should not be flagged as indented heading. Got: {result:?}"
288 );
289
290 let content = "Some text\n #hashtag";
292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
293 let result = rule.check(&ctx).unwrap();
294 assert!(
295 result.is_empty(),
296 "#hashtag should not be flagged as indented heading. Got: {result:?}"
297 );
298
299 let content = "Some text\n #Summary";
301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
302 let result = rule.check(&ctx).unwrap();
303 assert_eq!(
304 result.len(),
305 1,
306 "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
307 );
308
309 let content = "Some text\n ##introduction";
311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
312 let result = rule.check(&ctx).unwrap();
313 assert_eq!(
314 result.len(),
315 1,
316 "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
317 );
318
319 let content = "Some text\n ##123";
321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
322 let result = rule.check(&ctx).unwrap();
323 assert_eq!(
324 result.len(),
325 1,
326 "##123 SHOULD be flagged as indented heading. Got: {result:?}"
327 );
328
329 let content = "# Summary\n## Details";
331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332 let result = rule.check(&ctx).unwrap();
333 assert!(
334 result.is_empty(),
335 "Properly aligned headings should pass. Got: {result:?}"
336 );
337 }
338}