mdbook_lint_core/rules/standard/
md003.rs1use crate::Document;
7use crate::error::Result;
8use crate::rule::{RuleCategory, RuleMetadata};
9use crate::violation::{Severity, Violation};
10use comrak::nodes::{AstNode, NodeValue};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Md003Config {
16 pub style: String,
23}
24
25impl Default for Md003Config {
26 fn default() -> Self {
27 Self {
28 style: "consistent".to_string(),
29 }
30 }
31}
32
33pub struct MD003 {
35 config: Md003Config,
36}
37
38impl MD003 {
39 pub fn new() -> Self {
40 Self {
41 config: Md003Config::default(),
42 }
43 }
44
45 #[allow(dead_code)]
46 pub fn with_config(config: Md003Config) -> Self {
47 Self { config }
48 }
49}
50
51impl Default for MD003 {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl crate::rule::AstRule for MD003 {
58 fn id(&self) -> &'static str {
59 "MD003"
60 }
61
62 fn name(&self) -> &'static str {
63 "heading-style"
64 }
65
66 fn description(&self) -> &'static str {
67 "Heading style should be consistent throughout the document"
68 }
69
70 fn metadata(&self) -> RuleMetadata {
71 RuleMetadata::stable(RuleCategory::Structure).introduced_in("markdownlint v0.1.0")
72 }
73
74 fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
75 let mut violations = Vec::new();
76 let mut headings = Vec::new();
77
78 self.collect_headings(ast, document, &mut headings);
80
81 if headings.is_empty() {
82 return Ok(violations);
83 }
84
85 let expected_style = self.determine_expected_style(&headings);
87
88 for heading in &headings {
90 if !self.is_valid_style(&heading.style, &expected_style, heading.level) {
91 violations.push(self.create_violation(
92 format!(
93 "Expected '{}' style heading but found '{}' style",
94 expected_style, heading.style
95 ),
96 heading.line,
97 heading.column,
98 Severity::Error,
99 ));
100 }
101 }
102
103 Ok(violations)
104 }
105}
106
107impl MD003 {
108 fn collect_headings<'a>(
110 &self,
111 node: &'a AstNode<'a>,
112 document: &Document,
113 headings: &mut Vec<HeadingInfo>,
114 ) {
115 if let NodeValue::Heading(heading_data) = &node.data.borrow().value {
116 let position = node.data.borrow().sourcepos;
117 let style = self.determine_heading_style(node, document, position.start.line);
118 headings.push(HeadingInfo {
119 level: heading_data.level,
120 style,
121 line: position.start.line,
122 column: position.start.column,
123 });
124 }
125
126 for child in node.children() {
128 self.collect_headings(child, document, headings);
129 }
130 }
131
132 fn determine_heading_style(
134 &self,
135 _node: &AstNode,
136 document: &Document,
137 line_number: usize,
138 ) -> HeadingStyle {
139 let line_index = line_number.saturating_sub(1);
141 if line_index >= document.lines.len() {
142 return HeadingStyle::Atx;
143 }
144
145 let line = &document.lines[line_index];
146 let trimmed = line.trim();
147
148 if trimmed.starts_with('#') {
150 if trimmed.ends_with('#') && trimmed.len() > 1 {
152 let content = trimmed.trim_start_matches('#').trim_end_matches('#').trim();
154 if !content.is_empty() {
155 return HeadingStyle::AtxClosed;
156 }
157 }
158 return HeadingStyle::Atx;
159 }
160
161 if line_index + 1 < document.lines.len() {
163 let next_line = &document.lines[line_index + 1];
164 let next_trimmed = next_line.trim();
165
166 if !next_trimmed.is_empty() {
167 let first_char = next_trimmed.chars().next().unwrap();
168 if (first_char == '=' || first_char == '-')
169 && next_trimmed.chars().all(|c| c == first_char)
170 {
171 return HeadingStyle::Setext;
172 }
173 }
174 }
175
176 HeadingStyle::Atx
178 }
179
180 fn determine_expected_style(&self, headings: &[HeadingInfo]) -> HeadingStyle {
182 match self.config.style.as_str() {
183 "atx" => HeadingStyle::Atx,
184 "atx_closed" => HeadingStyle::AtxClosed,
185 "setext" => HeadingStyle::Setext,
186 "setext_with_atx" => HeadingStyle::SetextWithAtx,
187 "consistent" => {
188 headings
190 .first()
191 .map(|h| h.style.clone())
192 .unwrap_or(HeadingStyle::Atx)
193 }
194 _ => {
195 headings
197 .first()
198 .map(|h| h.style.clone())
199 .unwrap_or(HeadingStyle::Atx)
200 }
201 }
202 }
203
204 fn is_valid_style(&self, actual: &HeadingStyle, expected: &HeadingStyle, level: u8) -> bool {
206 match expected {
207 HeadingStyle::SetextWithAtx => {
208 if level <= 2 {
210 matches!(actual, HeadingStyle::Setext)
211 } else {
212 matches!(actual, HeadingStyle::Atx)
213 }
214 }
215 _ => actual == expected,
216 }
217 }
218}
219
220#[derive(Debug, Clone)]
222struct HeadingInfo {
223 level: u8,
224 style: HeadingStyle,
225 line: usize,
226 column: usize,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
231enum HeadingStyle {
232 Atx,
234 AtxClosed,
236 Setext,
238 SetextWithAtx,
240}
241
242impl std::fmt::Display for HeadingStyle {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 HeadingStyle::Atx => write!(f, "atx"),
246 HeadingStyle::AtxClosed => write!(f, "atx_closed"),
247 HeadingStyle::Setext => write!(f, "setext"),
248 HeadingStyle::SetextWithAtx => write!(f, "setext_with_atx"),
249 }
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::Document;
257 use crate::rule::Rule;
258 use std::path::PathBuf;
259
260 fn create_test_document(content: &str) -> Document {
261 Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
262 }
263
264 #[test]
265 fn test_md003_consistent_atx_style() {
266 let content = r#"# Main Title
267
268## Section A
269
270### Subsection 1
271
272## Section B
273
274### Subsection 2
275"#;
276 let doc = create_test_document(content);
277 let rule = MD003::new();
278 let violations = rule.check(&doc).unwrap();
279
280 assert_eq!(
281 violations.len(),
282 0,
283 "Consistent ATX style should not trigger violations"
284 );
285 }
286
287 #[test]
288 fn test_md003_consistent_atx_closed_style() {
289 let content = r#"# Main Title #
290
291## Section A ##
292
293### Subsection 1 ###
294
295## Section B ##
296"#;
297 let doc = create_test_document(content);
298 let rule = MD003::new();
299 let violations = rule.check(&doc).unwrap();
300 assert_eq!(
301 violations.len(),
302 0,
303 "Consistent ATX closed style should not trigger violations"
304 );
305 }
306
307 #[test]
308 fn test_md003_consistent_setext_style() {
309 let content = r#"Main Title
310==========
311
312Section A
313---------
314
315Section B
316---------
317"#;
318 let doc = create_test_document(content);
319 let rule = MD003::new();
320 let violations = rule.check(&doc).unwrap();
321 assert_eq!(
322 violations.len(),
323 0,
324 "Consistent Setext style should not trigger violations"
325 );
326 }
327
328 #[test]
329 fn test_md003_mixed_styles_violation() {
330 let content = r#"# Main Title
331
332Section A
333---------
334
335## Section B
336"#;
337 let doc = create_test_document(content);
338 let rule = MD003::new();
339 let violations = rule.check(&doc).unwrap();
340
341 assert!(
343 !violations.is_empty(),
344 "Mixed heading styles should trigger violations"
345 );
346
347 let violation_messages: Vec<&str> = violations.iter().map(|v| v.message.as_str()).collect();
348
349 assert!(
351 violation_messages
352 .iter()
353 .any(|msg| msg.contains("Expected 'atx' style"))
354 );
355 }
356
357 #[test]
358 fn test_md003_atx_and_atx_closed_mixed() {
359 let content = r#"# Main Title
360
361## Section A ##
362
363### Subsection 1
364
365## Section B ##
366"#;
367 let doc = create_test_document(content);
368 let rule = MD003::new();
369 let violations = rule.check(&doc).unwrap();
370
371 assert!(
373 !violations.is_empty(),
374 "Mixed ATX and ATX closed styles should trigger violations"
375 );
376 }
377
378 #[test]
379 fn test_md003_configured_atx_style() {
380 let content = r#"Main Title
381==========
382
383Section A
384---------
385"#;
386 let doc = create_test_document(content);
387 let config = Md003Config {
388 style: "atx".to_string(),
389 };
390 let rule = MD003::with_config(config);
391 let violations = rule.check(&doc).unwrap();
392
393 assert!(
395 !violations.is_empty(),
396 "Setext headings should violate when ATX is required"
397 );
398 }
399
400 #[test]
401 fn test_md003_configured_setext_style() {
402 let content = r#"# Main Title
403
404## Section A
405"#;
406 let doc = create_test_document(content);
407 let config = Md003Config {
408 style: "setext".to_string(),
409 };
410 let rule = MD003::with_config(config);
411 let violations = rule.check(&doc).unwrap();
412
413 assert!(
415 !violations.is_empty(),
416 "ATX headings should violate when Setext is required"
417 );
418 }
419
420 #[test]
421 fn test_md003_setext_with_atx_valid() {
422 let content = r#"Main Title
423==========
424
425Section A
426---------
427
428### Subsection 1
429
430#### Deep Section
431"#;
432 let doc = create_test_document(content);
433 let config = Md003Config {
434 style: "setext_with_atx".to_string(),
435 };
436 let rule = MD003::with_config(config);
437 let violations = rule.check(&doc).unwrap();
438
439 assert_eq!(
440 violations.len(),
441 0,
442 "Setext for levels 1-2 and ATX for 3+ should be valid"
443 );
444 }
445
446 #[test]
447 fn test_md003_setext_with_atx_violation() {
448 let content = r#"# Main Title
449
450Section A
451---------
452
453### Subsection 1
454"#;
455 let doc = create_test_document(content);
456 let config = Md003Config {
457 style: "setext_with_atx".to_string(),
458 };
459 let rule = MD003::with_config(config);
460 let violations = rule.check(&doc).unwrap();
461
462 assert!(
464 !violations.is_empty(),
465 "ATX level 1 should violate setext_with_atx style"
466 );
467 }
468
469 #[test]
470 fn test_md003_no_headings() {
471 let content = r#"This is a document with no headings.
472
473Just some regular text content.
474"#;
475 let doc = create_test_document(content);
476 let rule = MD003::new();
477 let violations = rule.check(&doc).unwrap();
478 assert_eq!(
479 violations.len(),
480 0,
481 "Documents with no headings should not trigger violations"
482 );
483 }
484
485 #[test]
486 fn test_md003_single_heading() {
487 let content = r#"# Only One Heading
488
489Some content here.
490"#;
491 let doc = create_test_document(content);
492 let rule = MD003::new();
493 let violations = rule.check(&doc).unwrap();
494 assert_eq!(
495 violations.len(),
496 0,
497 "Documents with single heading should not trigger violations"
498 );
499 }
500}