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: {
89 let line_start = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
91 line_start..line_start + indentation
92 },
93 replacement: String::new(),
94 }),
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) =
102 calculate_single_line_range(underline_line + 1, 1, underline_indentation);
103
104 warnings.push(LintWarning {
105 rule_name: Some(self.name().to_string()),
106 line: underline_start_line,
107 column: underline_start_col,
108 end_line: underline_end_line,
109 end_column: underline_end_col,
110 severity: Severity::Warning,
111 message: "Setext heading underline should not be indented".to_string(),
112 fix: Some(Fix {
113 range: {
114 let line_start =
115 ctx.line_index.get_line_start_byte(underline_line + 1).unwrap_or(0);
116 line_start..line_start + underline_indentation
117 },
118 replacement: String::new(),
119 }),
120 });
121 }
122 }
123 } else {
124 let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
128 line_num + 1, 1,
130 indentation,
131 );
132
133 warnings.push(LintWarning {
134 rule_name: Some(self.name().to_string()),
135 line: atx_start_line,
136 column: atx_start_col,
137 end_line: atx_end_line,
138 end_column: atx_end_col,
139 severity: Severity::Warning,
140 message: format!("Heading should not be indented by {indentation} spaces"),
141 fix: Some(Fix {
142 range: {
143 let line_start = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
144 line_start..line_start + indentation
145 },
146 replacement: String::new(),
147 }),
148 });
149 }
150 }
151 }
152 }
153
154 Ok(warnings)
155 }
156
157 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
158 if self.should_skip(ctx) {
159 return Ok(ctx.content.to_string());
160 }
161 let warnings = self.check(ctx)?;
162 if warnings.is_empty() {
163 return Ok(ctx.content.to_string());
164 }
165 let warnings =
166 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
167 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
168 .map_err(crate::rule::LintError::InvalidInput)
169 }
170
171 fn category(&self) -> RuleCategory {
173 RuleCategory::Heading
174 }
175
176 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
178 if !ctx.likely_has_headings() {
180 return true;
181 }
182 ctx.lines.iter().all(|line| line.heading.is_none())
184 }
185
186 fn as_any(&self) -> &dyn std::any::Any {
187 self
188 }
189
190 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
191 where
192 Self: Sized,
193 {
194 Box::new(MD023HeadingStartLeft)
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::lint_context::LintContext;
202 #[test]
203 fn test_basic_functionality() {
204 let rule = MD023HeadingStartLeft;
205
206 let content = "# Heading 1\n## Heading 2\n### Heading 3";
208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
209 let result = rule.check(&ctx).unwrap();
210 assert!(result.is_empty());
211
212 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
215 let result = rule.check(&ctx).unwrap();
216 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
218 assert_eq!(result[1].line, 2);
219 assert_eq!(result[2].line, 3);
220
221 let content = "Heading 1\n=========\n Heading 2\n ---------";
223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
224 let result = rule.check(&ctx).unwrap();
225 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
227 assert_eq!(result[1].line, 4);
228 }
229
230 #[test]
231 fn test_issue_refs_skipped_but_real_headings_caught() {
232 let rule = MD023HeadingStartLeft;
233
234 let content = "- fix: issue\n #29039)";
236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
237 let result = rule.check(&ctx).unwrap();
238 assert!(
239 result.is_empty(),
240 "#29039) should not be flagged as indented heading. Got: {result:?}"
241 );
242
243 let content = "Some text\n #hashtag";
245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
246 let result = rule.check(&ctx).unwrap();
247 assert!(
248 result.is_empty(),
249 "#hashtag should not be flagged as indented heading. Got: {result:?}"
250 );
251
252 let content = "Some text\n #Summary";
254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
255 let result = rule.check(&ctx).unwrap();
256 assert_eq!(
257 result.len(),
258 1,
259 "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
260 );
261
262 let content = "Some text\n ##introduction";
264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
265 let result = rule.check(&ctx).unwrap();
266 assert_eq!(
267 result.len(),
268 1,
269 "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
270 );
271
272 let content = "Some text\n ##123";
274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
275 let result = rule.check(&ctx).unwrap();
276 assert_eq!(
277 result.len(),
278 1,
279 "##123 SHOULD be flagged as indented heading. Got: {result:?}"
280 );
281
282 let content = "# Summary\n## Details";
284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
285 let result = rule.check(&ctx).unwrap();
286 assert!(
287 result.is_empty(),
288 "Properly aligned headings should pass. Got: {result:?}"
289 );
290 }
291}