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 Ok(warnings)
160 }
161
162 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
163 if self.should_skip(ctx) {
164 return Ok(ctx.content.to_string());
165 }
166 let warnings = self.check(ctx)?;
167 if warnings.is_empty() {
168 return Ok(ctx.content.to_string());
169 }
170 let warnings =
171 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
172 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
173 .map_err(crate::rule::LintError::InvalidInput)
174 }
175
176 fn category(&self) -> RuleCategory {
178 RuleCategory::Heading
179 }
180
181 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
183 if !ctx.likely_has_headings() {
185 return true;
186 }
187 ctx.lines.iter().all(|line| line.heading.is_none())
189 }
190
191 fn as_any(&self) -> &dyn std::any::Any {
192 self
193 }
194
195 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
196 where
197 Self: Sized,
198 {
199 Box::new(MD023HeadingStartLeft)
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::lint_context::LintContext;
207 #[test]
208 fn test_basic_functionality() {
209 let rule = MD023HeadingStartLeft;
210
211 let content = "# Heading 1\n## Heading 2\n### Heading 3";
213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
214 let result = rule.check(&ctx).unwrap();
215 assert!(result.is_empty());
216
217 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
220 let result = rule.check(&ctx).unwrap();
221 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
223 assert_eq!(result[1].line, 2);
224 assert_eq!(result[2].line, 3);
225
226 let content = "Heading 1\n=========\n Heading 2\n ---------";
228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
229 let result = rule.check(&ctx).unwrap();
230 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
232 assert_eq!(result[1].line, 4);
233 }
234
235 #[test]
236 fn test_issue_refs_skipped_but_real_headings_caught() {
237 let rule = MD023HeadingStartLeft;
238
239 let content = "- fix: issue\n #29039)";
241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
242 let result = rule.check(&ctx).unwrap();
243 assert!(
244 result.is_empty(),
245 "#29039) should not be flagged as indented heading. Got: {result:?}"
246 );
247
248 let content = "Some text\n #hashtag";
250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
251 let result = rule.check(&ctx).unwrap();
252 assert!(
253 result.is_empty(),
254 "#hashtag should not be flagged as indented heading. Got: {result:?}"
255 );
256
257 for content in [
260 "Some text\n #Summary",
261 "Some text\n ##introduction",
262 "Some text\n ##123",
263 ] {
264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
265 let result = rule.check(&ctx).unwrap();
266 assert!(result.is_empty(), "{content:?} is not a heading. Got: {result:?}");
267 }
268
269 let content = "Some text\n\n # Summary";
271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
272 let result = rule.check(&ctx).unwrap();
273 assert_eq!(result.len(), 1, "indented `# Summary` is flagged. Got: {result:?}");
274
275 let content = "# Summary\n## Details";
277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
278 let result = rule.check(&ctx).unwrap();
279 assert!(
280 result.is_empty(),
281 "Properly aligned headings should pass. Got: {result:?}"
282 );
283 }
284
285 #[test]
286 fn test_mkdocs_admonition_indented_heading_not_flagged() {
287 let rule = MD023HeadingStartLeft;
291 let content = "!!! note\n\n # Foo";
292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
293 let result = rule.check(&ctx).unwrap();
294
295 assert!(
296 result.is_empty(),
297 "heading nested in an admonition body should not be flagged, got: {result:?}"
298 );
299 }
300
301 #[test]
302 fn test_mkdocs_content_tab_indented_heading_not_flagged() {
303 let rule = MD023HeadingStartLeft;
305 let content = "=== \"Tab A\"\n\n # Foo";
306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
307 let result = rule.check(&ctx).unwrap();
308
309 assert!(
310 result.is_empty(),
311 "heading nested in a content tab body should not be flagged, got: {result:?}"
312 );
313 }
314
315 #[test]
316 fn test_mkdocs_accidental_indent_still_flagged() {
317 let rule = MD023HeadingStartLeft;
321 let content = "Some text\n\n # Foo";
322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
323 let result = rule.check(&ctx).unwrap();
324 assert_eq!(
325 result.len(),
326 1,
327 "accidentally indented top-level heading should still be flagged, got: {result:?}"
328 );
329
330 let fixed = rule.fix(&ctx).unwrap();
331 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
332 }
333
334 #[test]
335 fn test_standard_flavor_indented_heading_still_flagged_and_fixed() {
336 let rule = MD023HeadingStartLeft;
342 let content = "Some text\n\n # Foo";
343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
344 let result = rule.check(&ctx).unwrap();
345 assert_eq!(
346 result.len(),
347 1,
348 "indented heading should still be flagged under standard flavor, got: {result:?}"
349 );
350
351 let fixed = rule.fix(&ctx).unwrap();
352 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
353 }
354
355 #[test]
356 fn test_html_markdown_div_indented_heading_still_flagged() {
357 let rule = MD023HeadingStartLeft;
361 let content = "<div markdown=\"1\">\n\n # Bar\n\n</div>";
362 for flavor in [
363 crate::config::MarkdownFlavor::Standard,
364 crate::config::MarkdownFlavor::MkDocs,
365 ] {
366 let ctx = LintContext::new(content, flavor, None);
367 let result = rule.check(&ctx).unwrap();
368 assert_eq!(
369 result.len(),
370 1,
371 "indented heading inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
372 );
373 }
374 }
375}