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 if skip_next {
169 skip_next = false;
170 continue;
171 }
172
173 if let Some(heading) = &line_info.heading {
175 if !heading.is_valid {
177 fixed_lines.push(line_info.content(ctx.content).to_string());
178 continue;
179 }
180
181 let indentation = line_info.indent;
182 let is_setext = matches!(
183 heading.style,
184 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
185 );
186
187 if indentation > 0 {
188 if is_setext {
190 fixed_lines.push(line_info.content(ctx.content).trim().to_string());
192 if i + 1 < ctx.lines.len() {
194 fixed_lines.push(ctx.lines[i + 1].content(ctx.content).trim().to_string());
195 skip_next = true;
196 }
197 } else {
198 fixed_lines.push(line_info.content(ctx.content).trim_start().to_string());
200 }
201 } else {
202 fixed_lines.push(line_info.content(ctx.content).to_string());
204 if is_setext && i + 1 < ctx.lines.len() {
205 fixed_lines.push(ctx.lines[i + 1].content(ctx.content).to_string());
206 skip_next = true;
207 }
208 }
209 } else {
210 fixed_lines.push(line_info.content(ctx.content).to_string());
212 }
213 }
214
215 let result = fixed_lines.join("\n");
216 if ctx.content.ends_with('\n') {
217 Ok(result + "\n")
218 } else {
219 Ok(result)
220 }
221 }
222
223 fn category(&self) -> RuleCategory {
225 RuleCategory::Heading
226 }
227
228 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
230 if !ctx.likely_has_headings() {
232 return true;
233 }
234 ctx.lines.iter().all(|line| line.heading.is_none())
236 }
237
238 fn as_any(&self) -> &dyn std::any::Any {
239 self
240 }
241
242 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
243 where
244 Self: Sized,
245 {
246 Box::new(MD023HeadingStartLeft)
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::lint_context::LintContext;
254 #[test]
255 fn test_basic_functionality() {
256 let rule = MD023HeadingStartLeft;
257
258 let content = "# Heading 1\n## Heading 2\n### Heading 3";
260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
261 let result = rule.check(&ctx).unwrap();
262 assert!(result.is_empty());
263
264 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
267 let result = rule.check(&ctx).unwrap();
268 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
270 assert_eq!(result[1].line, 2);
271 assert_eq!(result[2].line, 3);
272
273 let content = "Heading 1\n=========\n Heading 2\n ---------";
275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
276 let result = rule.check(&ctx).unwrap();
277 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
279 assert_eq!(result[1].line, 4);
280 }
281
282 #[test]
283 fn test_issue_refs_skipped_but_real_headings_caught() {
284 let rule = MD023HeadingStartLeft;
285
286 let content = "- fix: issue\n #29039)";
288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
289 let result = rule.check(&ctx).unwrap();
290 assert!(
291 result.is_empty(),
292 "#29039) should not be flagged as indented heading. Got: {result:?}"
293 );
294
295 let content = "Some text\n #hashtag";
297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
298 let result = rule.check(&ctx).unwrap();
299 assert!(
300 result.is_empty(),
301 "#hashtag should not be flagged as indented heading. Got: {result:?}"
302 );
303
304 let content = "Some text\n #Summary";
306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
307 let result = rule.check(&ctx).unwrap();
308 assert_eq!(
309 result.len(),
310 1,
311 "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
312 );
313
314 let content = "Some text\n ##introduction";
316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
317 let result = rule.check(&ctx).unwrap();
318 assert_eq!(
319 result.len(),
320 1,
321 "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
322 );
323
324 let content = "Some text\n ##123";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327 let result = rule.check(&ctx).unwrap();
328 assert_eq!(
329 result.len(),
330 1,
331 "##123 SHOULD be flagged as indented heading. Got: {result:?}"
332 );
333
334 let content = "# Summary\n## Details";
336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
337 let result = rule.check(&ctx).unwrap();
338 assert!(
339 result.is_empty(),
340 "Properly aligned headings should pass. Got: {result:?}"
341 );
342 }
343}