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 line_info.in_pymdown_block {
31 continue;
32 }
33
34 if let Some(heading) = &line_info.heading {
35 if !heading.is_valid {
37 continue;
38 }
39
40 if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
43 let first_word: String = heading
45 .text
46 .trim()
47 .chars()
48 .take_while(|c| !c.is_whitespace() && *c != ',' && *c != ')')
49 .collect();
50 if let Some(first_char) = first_word.chars().next() {
51 if first_char.is_lowercase() || first_char.is_numeric() {
53 continue;
54 }
55 }
56 }
57
58 let indentation = line_info.indent;
59
60 if indentation > 0 {
62 let is_setext = matches!(
63 heading.style,
64 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
65 );
66
67 if is_setext {
68 let underline_line = line_num + 1;
70
71 let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
73 line_num + 1, 1,
75 indentation,
76 );
77
78 warnings.push(LintWarning {
80 rule_name: Some(self.name().to_string()),
81 line: start_line_calc,
82 column: start_col,
83 end_line,
84 end_column: end_col,
85 severity: Severity::Warning,
86 message: format!("Setext heading should not be indented by {indentation} spaces"),
87 fix: Some(Fix {
88 range: ctx.line_index.line_col_to_byte_range_with_length(
89 line_num + 1,
90 start_col,
91 indentation,
92 ),
93 replacement: String::new(), }),
95 });
96
97 if underline_line < ctx.lines.len() {
99 let underline_indentation = ctx.lines[underline_line].indent;
100 if underline_indentation > 0 {
101 let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
103 calculate_single_line_range(
104 underline_line + 1, 1,
106 underline_indentation,
107 );
108
109 warnings.push(LintWarning {
110 rule_name: Some(self.name().to_string()),
111 line: underline_start_line,
112 column: underline_start_col,
113 end_line: underline_end_line,
114 end_column: underline_end_col,
115 severity: Severity::Warning,
116 message: "Setext heading underline should not be indented".to_string(),
117 fix: Some(Fix {
118 range: ctx.line_index.line_col_to_byte_range_with_length(
119 underline_line + 1,
120 underline_start_col,
121 underline_indentation,
122 ),
123 replacement: String::new(), }),
125 });
126 }
127 }
128 } else {
129 let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
133 line_num + 1, 1,
135 indentation,
136 );
137
138 warnings.push(LintWarning {
139 rule_name: Some(self.name().to_string()),
140 line: atx_start_line,
141 column: atx_start_col,
142 end_line: atx_end_line,
143 end_column: atx_end_col,
144 severity: Severity::Warning,
145 message: format!("Heading should not be indented by {indentation} spaces"),
146 fix: Some(Fix {
147 range: ctx.line_index.line_col_to_byte_range_with_length(
148 line_num + 1,
149 atx_start_col,
150 indentation,
151 ),
152 replacement: String::new(), }),
154 });
155 }
156 }
157 }
158 }
159
160 Ok(warnings)
161 }
162
163 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
164 let mut fixed_lines = Vec::new();
165 let mut skip_next = false;
166
167 for (i, line_info) in ctx.lines.iter().enumerate() {
168 let line_num = i + 1;
169 if skip_next {
170 skip_next = false;
171 continue;
172 }
173
174 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
176 fixed_lines.push(line_info.content(ctx.content).to_string());
177 if let Some(heading) = &line_info.heading
179 && matches!(
180 heading.style,
181 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
182 )
183 && i + 1 < ctx.lines.len()
184 {
185 fixed_lines.push(ctx.lines[i + 1].content(ctx.content).to_string());
186 skip_next = true;
187 }
188 continue;
189 }
190
191 if let Some(heading) = &line_info.heading {
193 if !heading.is_valid {
195 fixed_lines.push(line_info.content(ctx.content).to_string());
196 continue;
197 }
198
199 let indentation = line_info.indent;
200 let is_setext = matches!(
201 heading.style,
202 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
203 );
204
205 if indentation > 0 {
206 if is_setext {
208 fixed_lines.push(line_info.content(ctx.content).trim().to_string());
210 if i + 1 < ctx.lines.len() {
212 fixed_lines.push(ctx.lines[i + 1].content(ctx.content).trim().to_string());
213 skip_next = true;
214 }
215 } else {
216 fixed_lines.push(line_info.content(ctx.content).trim_start().to_string());
218 }
219 } else {
220 fixed_lines.push(line_info.content(ctx.content).to_string());
222 if is_setext && i + 1 < ctx.lines.len() {
223 fixed_lines.push(ctx.lines[i + 1].content(ctx.content).to_string());
224 skip_next = true;
225 }
226 }
227 } else {
228 fixed_lines.push(line_info.content(ctx.content).to_string());
230 }
231 }
232
233 let result = fixed_lines.join("\n");
234 if ctx.content.ends_with('\n') {
235 Ok(result + "\n")
236 } else {
237 Ok(result)
238 }
239 }
240
241 fn category(&self) -> RuleCategory {
243 RuleCategory::Heading
244 }
245
246 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
248 if !ctx.likely_has_headings() {
250 return true;
251 }
252 ctx.lines.iter().all(|line| line.heading.is_none())
254 }
255
256 fn as_any(&self) -> &dyn std::any::Any {
257 self
258 }
259
260 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
261 where
262 Self: Sized,
263 {
264 Box::new(MD023HeadingStartLeft)
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::lint_context::LintContext;
272 #[test]
273 fn test_basic_functionality() {
274 let rule = MD023HeadingStartLeft;
275
276 let content = "# Heading 1\n## Heading 2\n### Heading 3";
278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279 let result = rule.check(&ctx).unwrap();
280 assert!(result.is_empty());
281
282 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
285 let result = rule.check(&ctx).unwrap();
286 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
288 assert_eq!(result[1].line, 2);
289 assert_eq!(result[2].line, 3);
290
291 let content = "Heading 1\n=========\n Heading 2\n ---------";
293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
294 let result = rule.check(&ctx).unwrap();
295 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
297 assert_eq!(result[1].line, 4);
298 }
299
300 #[test]
301 fn test_issue_refs_skipped_but_real_headings_caught() {
302 let rule = MD023HeadingStartLeft;
303
304 let content = "- fix: issue\n #29039)";
306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
307 let result = rule.check(&ctx).unwrap();
308 assert!(
309 result.is_empty(),
310 "#29039) should not be flagged as indented heading. Got: {result:?}"
311 );
312
313 let content = "Some text\n #hashtag";
315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
316 let result = rule.check(&ctx).unwrap();
317 assert!(
318 result.is_empty(),
319 "#hashtag should not be flagged as indented heading. Got: {result:?}"
320 );
321
322 let content = "Some text\n #Summary";
324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
325 let result = rule.check(&ctx).unwrap();
326 assert_eq!(
327 result.len(),
328 1,
329 "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
330 );
331
332 let content = "Some text\n ##introduction";
334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
335 let result = rule.check(&ctx).unwrap();
336 assert_eq!(
337 result.len(),
338 1,
339 "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
340 );
341
342 let content = "Some text\n ##123";
344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345 let result = rule.check(&ctx).unwrap();
346 assert_eq!(
347 result.len(),
348 1,
349 "##123 SHOULD be flagged as indented heading. Got: {result:?}"
350 );
351
352 let content = "# Summary\n## Details";
354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
355 let result = rule.check(&ctx).unwrap();
356 assert!(
357 result.is_empty(),
358 "Properly aligned headings should pass. Got: {result:?}"
359 );
360 }
361}