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_index.get_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 =
119 ctx.line_index.get_line_start_byte(underline_line + 1).unwrap_or(0);
120 line_start..line_start + underline_indentation
121 },
122 String::new(),
123 )),
124 });
125 }
126 }
127 } else {
128 let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
132 line_num + 1, 1,
134 indentation,
135 );
136
137 warnings.push(LintWarning {
138 rule_name: Some(self.name().to_string()),
139 line: atx_start_line,
140 column: atx_start_col,
141 end_line: atx_end_line,
142 end_column: atx_end_col,
143 severity: Severity::Warning,
144 message: format!("Heading should not be indented by {indentation} spaces"),
145 fix: Some(Fix::new(
146 {
147 let line_start = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
148 line_start..line_start + indentation
149 },
150 String::new(),
151 )),
152 });
153 }
154 }
155 }
156 }
157
158 Ok(warnings)
159 }
160
161 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
162 if self.should_skip(ctx) {
163 return Ok(ctx.content.to_string());
164 }
165 let warnings = self.check(ctx)?;
166 if warnings.is_empty() {
167 return Ok(ctx.content.to_string());
168 }
169 let warnings =
170 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
171 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
172 .map_err(crate::rule::LintError::InvalidInput)
173 }
174
175 fn category(&self) -> RuleCategory {
177 RuleCategory::Heading
178 }
179
180 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
182 if !ctx.likely_has_headings() {
184 return true;
185 }
186 ctx.lines.iter().all(|line| line.heading.is_none())
188 }
189
190 fn as_any(&self) -> &dyn std::any::Any {
191 self
192 }
193
194 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
195 where
196 Self: Sized,
197 {
198 Box::new(MD023HeadingStartLeft)
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::lint_context::LintContext;
206 #[test]
207 fn test_basic_functionality() {
208 let rule = MD023HeadingStartLeft;
209
210 let content = "# Heading 1\n## Heading 2\n### Heading 3";
212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
213 let result = rule.check(&ctx).unwrap();
214 assert!(result.is_empty());
215
216 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
219 let result = rule.check(&ctx).unwrap();
220 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
222 assert_eq!(result[1].line, 2);
223 assert_eq!(result[2].line, 3);
224
225 let content = "Heading 1\n=========\n Heading 2\n ---------";
227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
228 let result = rule.check(&ctx).unwrap();
229 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
231 assert_eq!(result[1].line, 4);
232 }
233
234 #[test]
235 fn test_issue_refs_skipped_but_real_headings_caught() {
236 let rule = MD023HeadingStartLeft;
237
238 let content = "- fix: issue\n #29039)";
240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
241 let result = rule.check(&ctx).unwrap();
242 assert!(
243 result.is_empty(),
244 "#29039) should not be flagged as indented heading. Got: {result:?}"
245 );
246
247 let content = "Some text\n #hashtag";
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 "#hashtag should not be flagged as indented heading. Got: {result:?}"
254 );
255
256 let content = "Some text\n #Summary";
258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
259 let result = rule.check(&ctx).unwrap();
260 assert_eq!(
261 result.len(),
262 1,
263 "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
264 );
265
266 let content = "Some text\n ##introduction";
268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
269 let result = rule.check(&ctx).unwrap();
270 assert_eq!(
271 result.len(),
272 1,
273 "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
274 );
275
276 let content = "Some text\n ##123";
278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279 let result = rule.check(&ctx).unwrap();
280 assert_eq!(
281 result.len(),
282 1,
283 "##123 SHOULD be flagged as indented heading. Got: {result:?}"
284 );
285
286 let content = "# Summary\n## Details";
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 "Properly aligned headings should pass. Got: {result:?}"
293 );
294 }
295
296 #[test]
297 fn test_mkdocs_admonition_indented_heading_not_flagged() {
298 let rule = MD023HeadingStartLeft;
302 let content = "!!! note\n\n # Foo";
303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
304 let result = rule.check(&ctx).unwrap();
305
306 assert!(
307 result.is_empty(),
308 "heading nested in an admonition body should not be flagged, got: {result:?}"
309 );
310 }
311
312 #[test]
313 fn test_mkdocs_content_tab_indented_heading_not_flagged() {
314 let rule = MD023HeadingStartLeft;
316 let content = "=== \"Tab A\"\n\n # Foo";
317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
318 let result = rule.check(&ctx).unwrap();
319
320 assert!(
321 result.is_empty(),
322 "heading nested in a content tab body should not be flagged, got: {result:?}"
323 );
324 }
325
326 #[test]
327 fn test_mkdocs_accidental_indent_still_flagged() {
328 let rule = MD023HeadingStartLeft;
332 let content = "Some text\n\n # Foo";
333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
334 let result = rule.check(&ctx).unwrap();
335 assert_eq!(
336 result.len(),
337 1,
338 "accidentally indented top-level heading should still be flagged, got: {result:?}"
339 );
340
341 let fixed = rule.fix(&ctx).unwrap();
342 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
343 }
344
345 #[test]
346 fn test_standard_flavor_indented_heading_still_flagged_and_fixed() {
347 let rule = MD023HeadingStartLeft;
353 let content = "Some text\n\n # Foo";
354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
355 let result = rule.check(&ctx).unwrap();
356 assert_eq!(
357 result.len(),
358 1,
359 "indented heading should still be flagged under standard flavor, got: {result:?}"
360 );
361
362 let fixed = rule.fix(&ctx).unwrap();
363 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
364 }
365
366 #[test]
367 fn test_html_markdown_div_indented_heading_still_flagged() {
368 let rule = MD023HeadingStartLeft;
372 let content = "<div markdown=\"1\">\n\n # Bar\n\n</div>";
373 for flavor in [
374 crate::config::MarkdownFlavor::Standard,
375 crate::config::MarkdownFlavor::MkDocs,
376 ] {
377 let ctx = LintContext::new(content, flavor, None);
378 let result = rule.check(&ctx).unwrap();
379 assert_eq!(
380 result.len(),
381 1,
382 "indented heading inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
383 );
384 }
385 }
386}