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 || line_info.in_admonition || line_info.in_content_tab {
35 continue;
36 }
37
38 if let Some(heading) = &line_info.heading {
39 if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
42 let first_word: String = heading
44 .text
45 .trim()
46 .chars()
47 .take_while(|c| !c.is_whitespace() && *c != ',' && *c != ')')
48 .collect();
49 if let Some(first_char) = first_word.chars().next() {
50 if first_char.is_lowercase() || first_char.is_numeric() {
52 continue;
53 }
54 }
55 }
56
57 let indentation = line_info.indent;
58 let is_setext = matches!(
59 heading.style,
60 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
61 );
62
63 if is_setext {
67 let underline_line = line_num + 1;
69 let first_text_line = line_num + 1 - heading.text_lines;
70
71 for text_line in first_text_line..=line_num {
72 let text_indentation = ctx.lines[text_line].indent;
73 if text_indentation == 0 {
74 continue;
75 }
76
77 let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
79 text_line + 1, 1,
81 text_indentation,
82 );
83
84 warnings.push(LintWarning {
86 rule_name: Some(self.name().to_string()),
87 line: start_line_calc,
88 column: start_col,
89 end_line,
90 end_column: end_col,
91 severity: Severity::Warning,
92 message: format!("Setext heading should not be indented by {text_indentation} spaces"),
93 fix: Some(Fix::new(
94 {
95 let line_start = ctx.line_start_byte(text_line + 1).unwrap_or(0);
97 line_start..line_start + text_indentation
98 },
99 String::new(),
100 )),
101 });
102 }
103
104 if underline_line < ctx.lines.len() {
106 let underline_indentation = ctx.lines[underline_line].indent;
107 if underline_indentation > 0 {
108 let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
109 calculate_single_line_range(underline_line + 1, 1, underline_indentation);
110
111 warnings.push(LintWarning {
112 rule_name: Some(self.name().to_string()),
113 line: underline_start_line,
114 column: underline_start_col,
115 end_line: underline_end_line,
116 end_column: underline_end_col,
117 severity: Severity::Warning,
118 message: "Setext heading underline should not be indented".to_string(),
119 fix: Some(Fix::new(
120 {
121 let line_start = ctx.line_start_byte(underline_line + 1).unwrap_or(0);
122 line_start..line_start + underline_indentation
123 },
124 String::new(),
125 )),
126 });
127 }
128 }
129 } else if indentation > 0 {
130 let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
134 line_num + 1, 1,
136 indentation,
137 );
138
139 warnings.push(LintWarning {
140 rule_name: Some(self.name().to_string()),
141 line: atx_start_line,
142 column: atx_start_col,
143 end_line: atx_end_line,
144 end_column: atx_end_col,
145 severity: Severity::Warning,
146 message: format!("Heading should not be indented by {indentation} spaces"),
147 fix: Some(Fix::new(
148 {
149 let line_start = ctx.line_start_byte(line_num + 1).unwrap_or(0);
150 line_start..line_start + indentation
151 },
152 String::new(),
153 )),
154 });
155 }
156 }
157 }
158
159 warnings.retain(|warning| {
162 warning
163 .fix
164 .as_ref()
165 .is_none_or(|fix| !ctx.overlaps_template_code(fix.range.start, fix.range.end))
166 });
167 Ok(warnings)
168 }
169
170 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
171 if self.should_skip(ctx) {
172 return Ok(ctx.content.to_string());
173 }
174 let warnings = self.check(ctx)?;
175 if warnings.is_empty() {
176 return Ok(ctx.content.to_string());
177 }
178 let warnings =
179 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
180 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
181 .map_err(crate::rule::LintError::InvalidInput)
182 }
183
184 fn category(&self) -> RuleCategory {
186 RuleCategory::Heading
187 }
188
189 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
191 if !ctx.likely_has_headings() {
193 return true;
194 }
195 ctx.lines.iter().all(|line| line.heading.is_none())
197 }
198
199 fn as_any(&self) -> &dyn std::any::Any {
200 self
201 }
202
203 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
204 where
205 Self: Sized,
206 {
207 Box::new(MD023HeadingStartLeft)
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::lint_context::LintContext;
215 #[test]
216 fn test_basic_functionality() {
217 let rule = MD023HeadingStartLeft;
218
219 let content = "# Heading 1\n## Heading 2\n### Heading 3";
221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
222 let result = rule.check(&ctx).unwrap();
223 assert!(result.is_empty());
224
225 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
228 let result = rule.check(&ctx).unwrap();
229 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
231 assert_eq!(result[1].line, 2);
232 assert_eq!(result[2].line, 3);
233
234 let content = "Heading 1\n=========\n Heading 2\n ---------";
236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
237 let result = rule.check(&ctx).unwrap();
238 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
240 assert_eq!(result[1].line, 4);
241 }
242
243 #[test]
244 fn test_issue_refs_skipped_but_real_headings_caught() {
245 let rule = MD023HeadingStartLeft;
246
247 let content = "- fix: issue\n #29039)";
249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
250 let result = rule.check(&ctx).unwrap();
251 assert!(
252 result.is_empty(),
253 "#29039) should not be flagged as indented heading. Got: {result:?}"
254 );
255
256 let content = "Some text\n #hashtag";
258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
259 let result = rule.check(&ctx).unwrap();
260 assert!(
261 result.is_empty(),
262 "#hashtag should not be flagged as indented heading. Got: {result:?}"
263 );
264
265 for content in [
268 "Some text\n #Summary",
269 "Some text\n ##introduction",
270 "Some text\n ##123",
271 ] {
272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
273 let result = rule.check(&ctx).unwrap();
274 assert!(result.is_empty(), "{content:?} is not a heading. Got: {result:?}");
275 }
276
277 let content = "Some text\n\n # Summary";
279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
280 let result = rule.check(&ctx).unwrap();
281 assert_eq!(result.len(), 1, "indented `# Summary` is flagged. Got: {result:?}");
282
283 let content = "# Summary\n## Details";
285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
286 let result = rule.check(&ctx).unwrap();
287 assert!(
288 result.is_empty(),
289 "Properly aligned headings should pass. Got: {result:?}"
290 );
291 }
292
293 #[test]
294 fn test_mkdocs_admonition_indented_heading_not_flagged() {
295 let rule = MD023HeadingStartLeft;
299 let content = "!!! note\n\n # Foo";
300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
301 let result = rule.check(&ctx).unwrap();
302
303 assert!(
304 result.is_empty(),
305 "heading nested in an admonition body should not be flagged, got: {result:?}"
306 );
307 }
308
309 #[test]
310 fn test_mkdocs_content_tab_indented_heading_not_flagged() {
311 let rule = MD023HeadingStartLeft;
313 let content = "=== \"Tab A\"\n\n # Foo";
314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
315 let result = rule.check(&ctx).unwrap();
316
317 assert!(
318 result.is_empty(),
319 "heading nested in a content tab body should not be flagged, got: {result:?}"
320 );
321 }
322
323 #[test]
324 fn test_mkdocs_accidental_indent_still_flagged() {
325 let rule = MD023HeadingStartLeft;
329 let content = "Some text\n\n # Foo";
330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
331 let result = rule.check(&ctx).unwrap();
332 assert_eq!(
333 result.len(),
334 1,
335 "accidentally indented top-level heading should still be flagged, got: {result:?}"
336 );
337
338 let fixed = rule.fix(&ctx).unwrap();
339 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
340 }
341
342 #[test]
343 fn test_standard_flavor_indented_heading_still_flagged_and_fixed() {
344 let rule = MD023HeadingStartLeft;
350 let content = "Some text\n\n # Foo";
351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
352 let result = rule.check(&ctx).unwrap();
353 assert_eq!(
354 result.len(),
355 1,
356 "indented heading should still be flagged under standard flavor, got: {result:?}"
357 );
358
359 let fixed = rule.fix(&ctx).unwrap();
360 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
361 }
362
363 #[test]
364 fn test_html_markdown_div_indented_heading_still_flagged() {
365 let rule = MD023HeadingStartLeft;
369 let content = "<div markdown=\"1\">\n\n # Bar\n\n</div>";
370 for flavor in [
371 crate::config::MarkdownFlavor::Standard,
372 crate::config::MarkdownFlavor::MkDocs,
373 ] {
374 let ctx = LintContext::new(content, flavor, None);
375 let result = rule.check(&ctx).unwrap();
376 assert_eq!(
377 result.len(),
378 1,
379 "indented heading inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
380 );
381 }
382 }
383}