rumdl_lib/rules/
md023_heading_start_left.rs1use 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 let Some(heading) = &line_info.heading {
30 let indentation = line_info.indent;
31
32 if indentation > 0 {
34 let is_setext = matches!(
35 heading.style,
36 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
37 );
38
39 if is_setext {
40 let underline_line = line_num + 1;
42
43 let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
45 line_num + 1, 1,
47 indentation,
48 );
49
50 warnings.push(LintWarning {
52 rule_name: Some(self.name().to_string()),
53 line: start_line_calc,
54 column: start_col,
55 end_line,
56 end_column: end_col,
57 severity: Severity::Warning,
58 message: format!("Setext heading should not be indented by {indentation} spaces"),
59 fix: Some(Fix {
60 range: ctx.line_index.line_col_to_byte_range_with_length(
61 line_num + 1,
62 start_col,
63 indentation,
64 ),
65 replacement: String::new(), }),
67 });
68
69 if underline_line < ctx.lines.len() {
71 let underline_indentation = ctx.lines[underline_line].indent;
72 if underline_indentation > 0 {
73 let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
75 calculate_single_line_range(
76 underline_line + 1, 1,
78 underline_indentation,
79 );
80
81 warnings.push(LintWarning {
82 rule_name: Some(self.name().to_string()),
83 line: underline_start_line,
84 column: underline_start_col,
85 end_line: underline_end_line,
86 end_column: underline_end_col,
87 severity: Severity::Warning,
88 message: "Setext heading underline should not be indented".to_string(),
89 fix: Some(Fix {
90 range: ctx.line_index.line_col_to_byte_range_with_length(
91 underline_line + 1,
92 underline_start_col,
93 underline_indentation,
94 ),
95 replacement: String::new(), }),
97 });
98 }
99 }
100 } else {
101 let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
105 line_num + 1, 1,
107 indentation,
108 );
109
110 warnings.push(LintWarning {
111 rule_name: Some(self.name().to_string()),
112 line: atx_start_line,
113 column: atx_start_col,
114 end_line: atx_end_line,
115 end_column: atx_end_col,
116 severity: Severity::Warning,
117 message: format!("Heading should not be indented by {indentation} spaces"),
118 fix: Some(Fix {
119 range: ctx.line_index.line_col_to_byte_range_with_length(
120 line_num + 1,
121 atx_start_col,
122 indentation,
123 ),
124 replacement: String::new(), }),
126 });
127 }
128 }
129 }
130 }
131
132 Ok(warnings)
133 }
134
135 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
136 let mut fixed_lines = Vec::new();
137 let mut skip_next = false;
138
139 for (i, line_info) in ctx.lines.iter().enumerate() {
140 if skip_next {
141 skip_next = false;
142 continue;
143 }
144
145 if let Some(heading) = &line_info.heading {
147 let indentation = line_info.indent;
148 let is_setext = matches!(
149 heading.style,
150 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
151 );
152
153 if indentation > 0 {
154 if is_setext {
156 fixed_lines.push(line_info.content(ctx.content).trim().to_string());
158 if i + 1 < ctx.lines.len() {
160 fixed_lines.push(ctx.lines[i + 1].content(ctx.content).trim().to_string());
161 skip_next = true;
162 }
163 } else {
164 fixed_lines.push(line_info.content(ctx.content).trim_start().to_string());
166 }
167 } else {
168 fixed_lines.push(line_info.content(ctx.content).to_string());
170 if is_setext && i + 1 < ctx.lines.len() {
171 fixed_lines.push(ctx.lines[i + 1].content(ctx.content).to_string());
172 skip_next = true;
173 }
174 }
175 } else {
176 fixed_lines.push(line_info.content(ctx.content).to_string());
178 }
179 }
180
181 let result = fixed_lines.join("\n");
182 if ctx.content.ends_with('\n') {
183 Ok(result + "\n")
184 } else {
185 Ok(result)
186 }
187 }
188
189 fn category(&self) -> RuleCategory {
191 RuleCategory::Heading
192 }
193
194 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
196 if !ctx.likely_has_headings() {
198 return true;
199 }
200 ctx.lines.iter().all(|line| line.heading.is_none())
202 }
203
204 fn as_any(&self) -> &dyn std::any::Any {
205 self
206 }
207
208 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
209 where
210 Self: Sized,
211 {
212 Box::new(MD023HeadingStartLeft)
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::lint_context::LintContext;
220 #[test]
221 fn test_basic_functionality() {
222 let rule = MD023HeadingStartLeft;
223
224 let content = "# Heading 1\n## Heading 2\n### Heading 3";
226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
227 let result = rule.check(&ctx).unwrap();
228 assert!(result.is_empty());
229
230 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
233 let result = rule.check(&ctx).unwrap();
234 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
236 assert_eq!(result[1].line, 2);
237 assert_eq!(result[2].line, 3);
238
239 let content = "Heading 1\n=========\n Heading 2\n ---------";
241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
242 let result = rule.check(&ctx).unwrap();
243 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
245 assert_eq!(result[1].line, 4);
246 }
247}