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