1use crate::lint_context::HeadingContainer;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::range_utils::calculate_single_line_range;
7
8#[derive(Clone)]
9pub struct MD023HeadingStartLeft;
10
11impl MD023HeadingStartLeft {
12 fn indentation_warning(
22 &self,
23 ctx: &crate::lint_context::LintContext,
24 line_idx: usize,
25 container: HeadingContainer,
26 quoted: Option<QuotedHeading<'_>>,
27 continuation: bool,
28 message: impl Fn(usize) -> String,
29 ) -> Option<LintWarning> {
30 if let Some(quoted) = quoted {
31 return self.quoted_indentation_warning(ctx, line_idx, quoted, continuation, message);
32 }
33 let line = &ctx.lines[line_idx];
34 let (edge, fixable) = match container {
35 HeadingContainer::Outside => (0, true),
36 HeadingContainer::Item { content_col } => {
37 let tab_free = !line.content(ctx.content)[..line.indent].contains('\t');
38 (content_col, tab_free)
39 }
40 HeadingContainer::Unknown => (0, false),
41 };
42 let indent = line.visual_indent.saturating_sub(edge);
43 if indent == 0 {
44 return None;
45 }
46 let fixable = fixable && !(continuation && indent >= 4);
50 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
53 line_idx + 1,
54 edge.min(line.indent) + 1,
55 line.indent.saturating_sub(edge),
56 );
57 let fix = fixable.then(|| {
58 let line_start = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
59 Fix::new(
60 line_start + edge.min(line.indent)..line_start + line.indent,
61 String::new(),
62 )
63 });
64 Some(LintWarning {
65 rule_name: Some(self.name().to_string()),
66 line: start_line,
67 column: start_col,
68 end_line,
69 end_column: end_col,
70 severity: Severity::Warning,
71 message: message(indent),
72 fix,
73 })
74 }
75
76 fn quoted_indentation_warning(
86 &self,
87 ctx: &crate::lint_context::LintContext,
88 line_idx: usize,
89 quoted: QuotedHeading<'_>,
90 continuation: bool,
91 message: impl Fn(usize) -> String,
92 ) -> Option<LintWarning> {
93 let line = &ctx.lines[line_idx];
94 let text = line.content(ctx.content);
95 let (_, start) = quote_margin(text, quoted.depth)?;
96 let content = &text[start..];
97 let indentation = content.len() - content.trim_start_matches([' ', '\t']).len();
98 let tab_rest = if text[..start].ends_with('\t') {
101 let column = visual_width(&text[..start - 1]);
102 3 - column % 4
103 } else {
104 0
105 };
106 if indentation == 0 && tab_rest == 0 {
107 return None;
108 }
109 let (edge, item_margin) = quoted.edges[line_idx];
112 if item_margin > 0 && edge > item_margin && line.list_item.is_none() && !text[..start].contains('\t') {
114 let line_margin = visual_width(&text[..start]);
117 let text_column = visual_width(&text[..start + indentation]) - line_margin;
118 let extra = text_column.saturating_sub(edge - item_margin);
119 if extra == 0 {
120 return None;
121 }
122 let spaces_only = !content[..indentation].contains('\t') && extra <= indentation;
123 let fixable = spaces_only && !(continuation && extra >= 4);
124 let removed_from = start + indentation - extra.min(indentation);
125 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
126 line_idx + 1,
127 text[..removed_from].chars().count() + 1,
128 text[removed_from..start + indentation].chars().count(),
129 );
130 let fix = fixable.then(|| {
131 let line_start = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
132 Fix::new(
133 line_start + removed_from..line_start + start + indentation,
134 String::new(),
135 )
136 });
137 return Some(LintWarning {
138 rule_name: Some(self.name().to_string()),
139 line: start_line,
140 column: start_col,
141 end_line,
142 end_column: end_col,
143 severity: Severity::Warning,
144 message: message(extra),
145 fix,
146 });
147 }
148 if quoted.list_flags[line_idx] {
149 return None;
150 }
151 let spaces_only = !text[..start].ends_with('\t') && !content[..indentation].contains('\t');
152 let fixable = spaces_only && !(continuation && indentation >= 4);
156 let reported_from = if tab_rest > 0 { start - 1 } else { start };
158 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
159 line_idx + 1,
160 text[..reported_from].chars().count() + 1,
161 text[reported_from..start + indentation].chars().count(),
162 );
163 let fix = fixable.then(|| {
164 let line_start = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
165 Fix::new(line_start + start..line_start + start + indentation, String::new())
166 });
167 Some(LintWarning {
168 rule_name: Some(self.name().to_string()),
169 line: start_line,
170 column: start_col,
171 end_line,
172 end_column: end_col,
173 severity: Severity::Warning,
174 message: message(indentation + tab_rest),
175 fix,
176 })
177 }
178}
179
180fn quote_list_flags(ctx: &crate::lint_context::LintContext) -> Vec<bool> {
191 let mut flags = Vec::with_capacity(ctx.lines.len());
192 let mut item_depths: Vec<usize> = Vec::new();
194 let mut open_depth = usize::MAX;
197 let mut blank_above = true;
198 let mut in_list_quote = false;
199 let mut previous_depth = 0;
200 let mut previous_nested = false;
201 let mut previous_paragraph = false;
202 let mut tag_block: Option<usize> = None;
204 let mut raw_block: Option<(&'static str, usize)> = None;
207 for line in &ctx.lines {
208 let text = line.content(ctx.content);
209 let quoted =
212 if !in_list_quote && visual_width(&text[..text.len() - text.trim_start_matches([' ', '\t']).len()]) >= 4 {
213 None
214 } else {
215 quoted_content(text)
216 };
217 let Some((depth, content)) = quoted else {
218 if blank_above || text.trim().is_empty() {
221 item_depths.clear();
222 open_depth = usize::MAX;
223 blank_above = true;
224 in_list_quote = false;
225 }
226 tag_block = None;
228 raw_block = None;
229 flags.push(false);
230 continue;
231 };
232 in_list_quote = quote_start(text).is_some_and(|at| at > 0);
235 if line.in_code_block && line.list_item.is_none() {
238 flags.push(item_depths.last().is_some_and(|&deepest| deepest >= depth));
239 continue;
240 }
241 let blank = content.trim().is_empty();
242 let mut was_raw = false;
246 if let Some((end, open)) = raw_block {
247 if depth < open {
248 raw_block = None;
250 } else {
251 was_raw = true;
252 if content.contains(end) {
253 raw_block = None;
254 }
255 }
256 } else if let Some(end) = raw_block_end(content)
257 && !content.trim_start()[1..].contains(end)
258 {
259 raw_block = Some((end, depth));
260 }
261 if tag_block.is_some_and(|open| depth < open || (blank && depth == open)) {
264 tag_block = None;
265 }
266 if tag_block.is_none() && !blank {
267 if ctx.flavor != crate::config::MarkdownFlavor::MDX
271 && is_lone_tag(content)
272 && !line.in_html_block
273 && !(previous_paragraph && depth == previous_depth && item_depths.is_empty())
274 {
275 tag_block = Some(depth);
276 }
277 }
278 if blank || blank_above || ends_deeper_quotes(text, content) {
280 while item_depths.last().is_some_and(|&deepest| deepest > depth) {
281 item_depths.pop();
282 }
283 }
284 let opens_item = !blank && opens_list_item(content.trim_start());
289 let nested = quote_nested_in_item(text);
293 let replaces_quote = previous_nested && !nested;
294 let opens_here = opens_item
295 && (blank_above
296 || depth != previous_depth
297 || replaces_quote
298 || !previous_paragraph
299 || ends_deeper_quotes(text, content));
300 let mut deepest_item = None;
301 let mut innermost = depth;
302 let before = item_depths.last().is_some_and(|&deepest| deepest >= depth);
303 if opens_item {
304 innermost = for_each_item_depth(text, |item_depth| {
305 deepest_item = deepest_item.max(Some(item_depth));
306 if let Err(at) = item_depths.binary_search(&item_depth) {
307 item_depths.insert(at, item_depth);
308 }
309 });
310 open_depth = open_depth.min(depth);
311 }
312 flags.push(
313 before
314 || tag_block.is_some()
315 || raw_block.is_some()
316 || was_raw
317 || (opens_here && (replaces_quote || deepest_item == Some(innermost))),
318 );
319 previous_depth = depth;
320 previous_nested = nested;
321 previous_paragraph = is_paragraph_text(content);
322 if !blank
323 && !opens_item
324 && depth <= open_depth
325 && !content.starts_with([' ', '\t'])
326 && !tab_marker_indents(text, content)
327 && (blank_above || starts_atx_heading(content))
328 {
329 item_depths.clear();
330 open_depth = usize::MAX;
331 }
332 blank_above = blank;
333 }
334 flags
335}
336
337fn tab_marker_indents(line: &str, content: &str) -> bool {
340 let marker_end = line.len() - content.len();
341 line[..marker_end].ends_with('\t') && visual_width(&line[..marker_end - 1]) % 4 != 3
342}
343
344fn raw_block_end(content: &str) -> Option<&'static str> {
349 let trimmed = content.trim_start_matches([' ', '\t']);
350 if visual_width(&content[..content.len() - trimmed.len()]) >= 4 {
351 return None;
352 }
353 if trimmed.starts_with("<!--") {
354 Some("-->")
355 } else if trimmed.starts_with("<?") {
356 Some("?>")
357 } else if trimmed.starts_with("<![CDATA[") {
358 Some("]]>")
359 } else if trimmed.starts_with("<!") && trimmed[2..].starts_with(|c: char| c.is_ascii_alphabetic()) {
360 Some(">")
361 } else {
362 None
363 }
364}
365
366fn is_paragraph_text(content: &str) -> bool {
369 let trimmed = content.trim_start_matches([' ', '\t']);
370 if trimmed.is_empty() || visual_width(&content[..content.len() - trimmed.len()]) >= 4 {
371 return false;
372 }
373 let first = trimmed.chars().next().unwrap_or(' ');
374 let run = trimmed.chars().take_while(|&c| c == first).count();
375 let marker_like = match first {
376 '#' | '>' | '<' | '=' | '|' => true,
377 '`' | '~' | '_' => run >= 3,
378 '-' | '+' | '*' => {
379 let after = &trimmed[1..];
380 after.is_empty()
381 || after.starts_with([' ', '\t'])
382 || trimmed.chars().filter(|c| !c.is_whitespace()).all(|c| c == first)
383 }
384 c if c.is_ascii_digit() => {
385 let digits = trimmed.chars().take_while(char::is_ascii_digit).count();
386 let after = &trimmed[digits..];
387 after.starts_with(['.', ')']) && (after.len() == 1 || after[1..].starts_with([' ', '\t']))
388 }
389 _ => false,
390 };
391 !marker_like
392}
393
394fn is_lone_tag(content: &str) -> bool {
395 let trimmed = content.trim_start_matches([' ', '\t']);
396 if visual_width(&content[..content.len() - trimmed.len()]) >= 4 || !trimmed.trim_end().ends_with('>') {
397 return false;
398 }
399 let Some(tag) = trimmed.strip_prefix('<') else {
400 return false;
401 };
402 let closing = tag.starts_with('/');
403 let name = tag
404 .trim_start_matches('/')
405 .chars()
406 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
407 .collect::<String>()
408 .to_ascii_lowercase();
409 let read_by_parser = crate::utils::html_block::BLOCK_ELEMENTS.contains(&name.as_str())
412 && !crate::utils::html_block::NON_SPEC_BLOCK_ELEMENTS.contains(&name.as_str())
413 && !(closing && crate::utils::html_block::TYPE_1_BLOCK_ELEMENTS.contains(&name.as_str()));
414 name.starts_with(|c: char| c.is_ascii_alphabetic()) && !read_by_parser && is_complete_tag(tag, closing)
415}
416
417fn is_complete_tag(tag: &str, closing: bool) -> bool {
420 let rest = tag
421 .trim_start_matches('/')
422 .trim_start_matches(|c: char| c.is_ascii_alphanumeric() || c == '-');
423 if !rest.starts_with([' ', '\t', '/', '>']) {
424 return false;
425 }
426 let mut quote = None;
427 for (at, c) in rest.char_indices() {
428 match (quote, c) {
429 (Some(q), c) if c == q => quote = None,
430 (Some(_), _) => {}
431 (None, '"' | '\'') if !closing => quote = Some(c),
432 (None, '<') => return false,
433 (None, '>') => {
434 let head = rest[..at].trim();
435 let head_ok = if closing { head.is_empty() } else { true };
436 return head_ok && rest[at + 1..].trim().is_empty();
437 }
438 _ => {}
439 }
440 }
441 false
442}
443
444fn quote_nested_in_item(line: &str) -> bool {
447 let mut rest = line;
448 let mut markers = 0;
449 loop {
450 let trimmed = rest.trim_start_matches([' ', '\t']);
451 if let Some(after) = trimmed.strip_prefix('>') {
452 if markers > 0 && visual_width(&rest[..rest.len() - trimmed.len()]) >= 3 {
453 return true;
454 }
455 markers += 1;
456 rest = after;
457 continue;
458 }
459 let token = trimmed.split([' ', '\t']).next().unwrap_or("");
460 if !opens_list_item(token) {
461 return false;
462 }
463 rest = &trimmed[token.len()..];
464 }
465}
466
467fn for_each_item_depth(line: &str, mut visit: impl FnMut(usize)) -> usize {
473 let mut rest = line;
474 let mut markers = 0;
475 loop {
476 let trimmed = rest.trim_start_matches([' ', '\t']);
477 if let Some(after) = trimmed.strip_prefix('>') {
478 let gap = visual_width(&rest[..rest.len() - trimmed.len()]);
479 if markers > 0 && gap >= 3 {
482 visit(markers);
483 }
484 markers += 1;
485 rest = after;
486 continue;
487 }
488 let token = trimmed.split([' ', '\t']).next().unwrap_or("");
489 if !opens_list_item(token) {
490 return markers;
491 }
492 visit(markers);
493 rest = &trimmed[token.len()..];
494 }
495}
496
497fn ends_deeper_quotes(line: &str, content: &str) -> bool {
501 let trimmed = content.trim_start_matches([' ', '\t']);
502 let before = &line[..line.len() - trimmed.len()];
505 let marker_end = before.rfind('>').map_or(0, |at| visual_width(&before[..=at]));
506 if visual_width(before).saturating_sub(marker_end + 1) >= 4 {
507 return false;
508 }
509 if starts_atx_heading(trimmed) {
510 return true;
511 }
512 let token = trimmed.split([' ', '\t']).next().unwrap_or("");
513 let has_text = !trimmed[token.len()..].trim().is_empty();
514 has_text && (matches!(token, "-" | "+" | "*") || matches!(token, "1." | "1)"))
515}
516
517fn visual_width(text: &str) -> usize {
519 text.chars().fold(0, |column, c| match c {
520 '\t' => column + 4 - column % 4,
521 _ => column + 1,
522 })
523}
524
525fn starts_atx_heading(text: &str) -> bool {
528 let hashes = text.len() - text.trim_start_matches('#').len();
529 (1..=6).contains(&hashes) && (text.len() == hashes || text[hashes..].starts_with([' ', '\t']))
530 || (1..=6).contains(&hashes) && text.len() == hashes
531}
532
533#[derive(Clone, Copy)]
535struct QuotedHeading<'a> {
536 edges: &'a [(usize, usize)],
539 list_flags: &'a [bool],
543 depth: usize,
545}
546
547fn quoted_content(line: &str) -> Option<(usize, &str)> {
550 let start = quote_start(line)?;
551 let parsed = crate::utils::blockquote::parse_blockquote_prefix(&line[start..])?;
552 Some((parsed.nesting_level, parsed.content))
553}
554
555fn quote_margin(line: &str, max: usize) -> Option<(usize, usize)> {
560 let mut offset = 0;
561 let mut markers = 0;
562 loop {
563 let mut consumed = false;
564 while markers < max {
565 let rest = &line[offset..];
566 let next = rest.trim_start_matches([' ', '\t']);
567 if !next.starts_with('>') {
568 break;
569 }
570 offset += rest.len() - next.len() + 1;
571 markers += 1;
572 consumed = true;
573 }
574 if consumed {
575 offset += usize::from(line[offset..].starts_with([' ', '\t']));
576 }
577 if markers >= max {
578 break;
579 }
580 let mut after = offset;
582 loop {
583 let rest = &line[after..];
584 let trimmed = rest.trim_start_matches([' ', '\t']);
585 let token = trimmed.split([' ', '\t']).next().unwrap_or("");
586 if !opens_list_item(token) {
587 return (markers > 0).then_some((markers, offset));
588 }
589 after += rest.len() - trimmed.len() + token.len();
590 if line[after..].trim_start_matches([' ', '\t']).starts_with('>') {
591 break;
592 }
593 }
594 offset = after;
595 }
596 (markers > 0).then_some((markers, offset))
597}
598
599fn quote_start(line: &str) -> Option<usize> {
603 let mut start = 0;
604 loop {
605 let rest = &line[start..];
606 if crate::utils::blockquote::parse_blockquote_prefix(rest).is_some() {
607 return Some(start);
608 }
609 let trimmed = rest.trim_start_matches([' ', '\t']);
610 let token = trimmed.split([' ', '\t']).next().unwrap_or("");
611 if token.is_empty() || !opens_list_item(token) {
612 return None;
613 }
614 start += rest.len() - trimmed.len() + token.len();
615 }
616}
617
618fn opens_list_item(text: &str) -> bool {
621 let token = text.split([' ', '\t']).next().unwrap_or("");
622 let digits = token.trim_end_matches(['.', ')']);
623 matches!(token, "-" | "+" | "*")
624 || (token.len() == digits.len() + 1
625 && (1..=9).contains(&digits.len())
626 && digits.bytes().all(|b| b.is_ascii_digit()))
627}
628
629impl Rule for MD023HeadingStartLeft {
630 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
631 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(23))
632 }
633
634 fn name(&self) -> &'static str {
635 "MD023"
636 }
637
638 fn description(&self) -> &'static str {
639 "Headings must start at the beginning of the line"
640 }
641
642 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
643 if ctx.lines.is_empty() {
645 return Ok(vec![]);
646 }
647
648 let mut warnings = Vec::new();
649
650 let quote_columns = ctx.headings().any(|parsed| parsed.is_blockquote()).then(|| {
652 (
653 crate::utils::blockquote::container_edges_with_margins(ctx),
654 quote_list_flags(ctx),
655 )
656 });
657
658 for parsed in ctx.headings() {
660 let line_num = parsed.line_num - 1;
661 let line_info = parsed.line_info;
662 let quoted = parsed.is_blockquote();
663 let closing_line = if parsed.is_setext() { line_num + 1 } else { line_num };
667 let quoted_flags = quote_columns
668 .as_ref()
669 .filter(|_| quoted)
670 .map(|(edges, list_flags)| QuotedHeading {
671 edges,
672 list_flags,
673 depth: ctx
674 .lines
675 .get(closing_line)
676 .and_then(|line| quote_margin(line.content(ctx.content), usize::MAX))
677 .map_or(0, |(depth, _)| depth),
678 });
679 if line_info.in_pymdown_block || line_info.in_admonition || line_info.in_content_tab {
685 continue;
686 }
687
688 let heading = parsed.heading;
689 if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
692 let first_word: String = heading
694 .text
695 .trim()
696 .chars()
697 .take_while(|c| !c.is_whitespace() && *c != ',' && *c != ')')
698 .collect();
699 if let Some(first_char) = first_word.chars().next() {
700 if first_char.is_lowercase() || first_char.is_numeric() {
702 continue;
703 }
704 }
705 }
706
707 let is_setext = matches!(
708 heading.style,
709 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
710 );
711 let container = if quoted {
712 HeadingContainer::Unknown
713 } else {
714 ctx.heading_container(line_num + 1)
715 };
716
717 if is_setext {
721 let underline_line = line_num + 1;
722 let first_text_line = line_num + 1 - heading.text_lines;
723
724 for text_line in first_text_line..=line_num {
725 if let Some(warning) = self.indentation_warning(
726 ctx,
727 text_line,
728 container,
729 quoted_flags,
730 text_line > first_text_line,
731 |indent| format!("Setext heading should not be indented by {indent} spaces"),
732 ) {
733 warnings.push(warning);
734 }
735 }
736
737 if underline_line < ctx.lines.len()
739 && let Some(warning) =
740 self.indentation_warning(ctx, underline_line, container, quoted_flags, false, |_| {
741 "Setext heading underline should not be indented".to_string()
742 })
743 {
744 warnings.push(warning);
745 }
746 } else if let Some(warning) =
747 self.indentation_warning(ctx, line_num, container, quoted_flags, false, |indent| {
748 format!("Heading should not be indented by {indent} spaces")
749 })
750 {
751 warnings.push(warning);
752 }
753 }
754
755 warnings.retain(|warning| {
758 warning
759 .fix
760 .as_ref()
761 .is_none_or(|fix| !ctx.overlaps_template_code(fix.range.start, fix.range.end))
762 });
763 Ok(warnings)
764 }
765
766 fn fixes_from_diagnostics(&self) -> bool {
767 true
768 }
769
770 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
771 if self.should_skip(ctx) {
772 return Ok(ctx.content.to_string());
773 }
774 let warnings = self.check(ctx)?;
775 if warnings.is_empty() {
776 return Ok(ctx.content.to_string());
777 }
778 let warnings =
779 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
780 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
781 .map_err(crate::rule::LintError::InvalidInput)
782 }
783
784 fn category(&self) -> RuleCategory {
786 RuleCategory::Heading
787 }
788
789 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
791 if !ctx.likely_has_headings() {
793 return true;
794 }
795 ctx.headings().next().is_none()
797 }
798
799 fn as_any(&self) -> &dyn std::any::Any {
800 self
801 }
802
803 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
804 where
805 Self: Sized,
806 {
807 Box::new(MD023HeadingStartLeft)
808 }
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814 use crate::lint_context::LintContext;
815 #[test]
816 fn test_basic_functionality() {
817 let rule = MD023HeadingStartLeft;
818
819 let content = "# Heading 1\n## Heading 2\n### Heading 3";
821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822 let result = rule.check(&ctx).unwrap();
823 assert!(result.is_empty());
824
825 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
831 assert_eq!(result[1].line, 2);
832 assert_eq!(result[2].line, 3);
833
834 let content = "Heading 1\n=========\n Heading 2\n ---------";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
840 assert_eq!(result[1].line, 4);
841 }
842
843 #[test]
844 fn test_issue_refs_skipped_but_real_headings_caught() {
845 let rule = MD023HeadingStartLeft;
846
847 let content = "- fix: issue\n #29039)";
849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
850 let result = rule.check(&ctx).unwrap();
851 assert!(
852 result.is_empty(),
853 "#29039) should not be flagged as indented heading. Got: {result:?}"
854 );
855
856 let content = "Some text\n #hashtag";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let result = rule.check(&ctx).unwrap();
860 assert!(
861 result.is_empty(),
862 "#hashtag should not be flagged as indented heading. Got: {result:?}"
863 );
864
865 for content in [
868 "Some text\n #Summary",
869 "Some text\n ##introduction",
870 "Some text\n ##123",
871 ] {
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873 let result = rule.check(&ctx).unwrap();
874 assert!(result.is_empty(), "{content:?} is not a heading. Got: {result:?}");
875 }
876
877 let content = "Some text\n\n # Summary";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881 assert_eq!(result.len(), 1, "indented `# Summary` is flagged. Got: {result:?}");
882
883 let content = "# Summary\n## Details";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.check(&ctx).unwrap();
887 assert!(
888 result.is_empty(),
889 "Properly aligned headings should pass. Got: {result:?}"
890 );
891 }
892
893 #[test]
894 fn test_mkdocs_admonition_indented_heading_not_flagged() {
895 let rule = MD023HeadingStartLeft;
899 let content = "!!! note\n\n # Foo";
900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
901 let result = rule.check(&ctx).unwrap();
902
903 assert!(
904 result.is_empty(),
905 "heading nested in an admonition body should not be flagged, got: {result:?}"
906 );
907 }
908
909 #[test]
910 fn test_mkdocs_content_tab_indented_heading_not_flagged() {
911 let rule = MD023HeadingStartLeft;
913 let content = "=== \"Tab A\"\n\n # Foo";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
915 let result = rule.check(&ctx).unwrap();
916
917 assert!(
918 result.is_empty(),
919 "heading nested in a content tab body should not be flagged, got: {result:?}"
920 );
921 }
922
923 #[test]
924 fn test_mkdocs_accidental_indent_still_flagged() {
925 let rule = MD023HeadingStartLeft;
929 let content = "Some text\n\n # Foo";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
931 let result = rule.check(&ctx).unwrap();
932 assert_eq!(
933 result.len(),
934 1,
935 "accidentally indented top-level heading should still be flagged, got: {result:?}"
936 );
937
938 let fixed = rule.fix(&ctx).unwrap();
939 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
940 }
941
942 #[test]
943 fn test_standard_flavor_indented_heading_still_flagged_and_fixed() {
944 let rule = MD023HeadingStartLeft;
950 let content = "Some text\n\n # Foo";
951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let result = rule.check(&ctx).unwrap();
953 assert_eq!(
954 result.len(),
955 1,
956 "indented heading should still be flagged under standard flavor, got: {result:?}"
957 );
958
959 let fixed = rule.fix(&ctx).unwrap();
960 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
961 }
962
963 #[test]
964 fn test_html_markdown_div_indented_heading_still_flagged() {
965 let rule = MD023HeadingStartLeft;
969 let content = "<div markdown=\"1\">\n\n # Bar\n\n</div>";
970 for flavor in [
971 crate::config::MarkdownFlavor::Standard,
972 crate::config::MarkdownFlavor::MkDocs,
973 ] {
974 let ctx = LintContext::new(content, flavor, None);
975 let result = rule.check(&ctx).unwrap();
976 assert_eq!(
977 result.len(),
978 1,
979 "indented heading inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
980 );
981 }
982 }
983}
984
985#[cfg(test)]
989mod list_item_tests {
990 use super::*;
991 use crate::config::MarkdownFlavor;
992 use crate::lint_context::LintContext;
993
994 fn run(content: &str) -> (Vec<LintWarning>, String) {
996 let rule = MD023HeadingStartLeft;
997 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
998 let warnings = rule.check(&ctx).unwrap();
999 let fixed = rule.fix(&ctx).unwrap();
1000 (warnings, fixed)
1001 }
1002
1003 fn assert_clean(content: &str) {
1005 let (warnings, fixed) = run(content);
1006 assert!(warnings.is_empty(), "{content:?} warned: {warnings:?}");
1007 assert_eq!(fixed, content, "{content:?} was rewritten");
1008 }
1009
1010 fn assert_fixed(content: &str, lines: &[usize], expected: &str) {
1013 let (warnings, fixed) = run(content);
1014 assert_eq!(
1015 warnings.iter().map(|warning| warning.line).collect::<Vec<_>>(),
1016 lines,
1017 "{content:?}: {warnings:?}"
1018 );
1019 assert_eq!(fixed, expected, "{content:?}");
1020 assert_clean(expected);
1021 }
1022
1023 #[test]
1024 fn a_heading_at_its_items_content_column_is_not_indented() {
1025 for content in [
1026 "-\n ## a\n text\n",
1027 "- x\n\n ## a\n\n text\n",
1028 "- x\n ## a\n text\n",
1029 "1. x\n\n ## a\n",
1030 "10. x\n\n ## a\n",
1031 "- x\n - y\n\n ## a\n",
1032 "-\n -\n ## a\n",
1033 "- x\n\n a\n ---\n",
1034 "1. x\n\n a\n ===\n",
1035 "-\n a\n ---\n text\n",
1036 "-\tx\n\n ## a\n",
1037 "- code\n\n ## a\n",
1038 ] {
1039 assert_clean(content);
1040 }
1041 }
1042
1043 #[test]
1044 fn only_the_indentation_past_the_content_column_is_removed() {
1045 assert_fixed("- x\n\n ## a\n", &[3], "- x\n\n ## a\n");
1046 assert_fixed("- x\n - y\n\n ## a\n", &[4], "- x\n - y\n\n ## a\n");
1047 assert_fixed("1. x\n\n ## a\n", &[3], "1. x\n\n ## a\n");
1048 assert_fixed("- x\n\n a\n ---\n", &[3, 4], "- x\n\n a\n ---\n");
1049 assert_fixed("- x\n\n a\n b\n ===\n", &[3, 4, 5], "- x\n\n a\n b\n ===\n");
1050 }
1051
1052 #[test]
1053 fn the_item_is_found_by_what_the_line_re_enters() {
1054 assert_fixed("- x\n text\n ## a\n", &[3], "- x\n text\n ## a\n");
1056 assert_clean("- x\nlazy\n ## a\n");
1058 assert_clean("- x\n\n a\nlazy\n ---\n");
1059 assert_fixed("-\n -\n\n ## a\n", &[4], "-\n -\n\n ## a\n");
1062 assert_fixed(
1064 "- x\n - y\n\n paragraph\n\n ## a\n",
1065 &[6],
1066 "- x\n - y\n\n paragraph\n\n ## a\n",
1067 );
1068 }
1069
1070 #[test]
1071 fn a_tab_in_the_indentation_is_reported_but_not_rewritten() {
1072 let content = "- x\n\n\t## a\n";
1073 let (warnings, fixed) = run(content);
1074 assert_eq!(warnings.len(), 1, "{warnings:?}");
1075 assert!(warnings[0].fix.is_none(), "{:?}", warnings[0].fix);
1076 assert_eq!(fixed, content);
1077 assert_clean("- x\n\n\t\t## a\n");
1079 }
1080
1081 #[test]
1082 fn a_heading_outside_every_item_is_still_moved_to_the_margin() {
1083 assert_fixed(" ## a\n", &[1], "## a\n");
1084 assert_fixed("a\n\n b\n ---\n", &[3, 4], "a\n\nb\n---\n");
1085 assert_fixed("1. x\n\n ## a\n", &[3], "1. x\n\n## a\n");
1087 assert_fixed("- x\n\n ## a\n", &[3], "- x\n\n## a\n");
1088 assert_fixed("- x\n\nplain\n\n ## a\n", &[5], "- x\n\nplain\n\n## a\n");
1089 assert_fixed("-\n\n ## a\n", &[3], "-\n\n## a\n");
1090 }
1091
1092 #[test]
1093 fn deep_continuation_lines_of_setext_text_are_reported_but_not_rewritten() {
1094 for content in ["- x\n\n a\n ```\n ---\n", "a\n ```\n---\n"] {
1096 let (warnings, fixed) = run(content);
1097 assert!(!warnings.is_empty(), "{content:?}");
1098 assert_eq!(fixed, content, "{content:?}");
1099 }
1100 let (_, fixed) = run("- x\n\n a\n b\n ---\n");
1102 assert_eq!(fixed, "- x\n\n a\n b\n ---\n");
1103 }
1104
1105 #[test]
1106 fn test_heading_past_a_quoted_list_items_content_column_is_flagged() {
1107 let rule = MD023HeadingStartLeft;
1108 for (item, bad, fixed) in [
1109 ("> - item\n>\n", "> ## Sub\n", "> ## Sub\n"),
1110 ("> 1. item\n>\n", "> ## Sub\n", "> ## Sub\n"),
1111 ("> > - item\n> >\n", "> > ## Sub\n", "> > ## Sub\n"),
1112 ("> - a\n> - b\n>\n", "> ## Sub\n", "> ## Sub\n"),
1113 ] {
1114 let content = format!("# T\n\n{item}{bad}");
1115 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1116 let result = rule.check(&ctx).unwrap();
1117 assert_eq!(result.len(), 1, "{content:?}: {result:?}");
1118 assert_eq!(rule.fix(&ctx).unwrap(), format!("# T\n\n{item}{fixed}"), "{content:?}");
1119 }
1120 }
1121
1122 #[test]
1123 fn test_heading_at_a_quoted_list_items_content_column_is_not_flagged() {
1124 let rule = MD023HeadingStartLeft;
1125 for content in [
1126 "# T\n\n> - item\n>\n> ## Sub\n",
1127 "# T\n\n> 1. item\n>\n> ## Sub\n",
1128 "# T\n\n> > - item\n> >\n> > ## Sub\n",
1129 ] {
1130 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1132 }
1133 }
1134
1135 #[test]
1136 fn test_heading_under_a_bare_marker_in_a_quote_is_judged_from_the_marker_plus_one() {
1137 let rule = MD023HeadingStartLeft;
1138 let ok = "> -\n> ## a\n> text\n";
1139 let ctx = crate::lint_context::LintContext::new(ok, crate::config::MarkdownFlavor::Standard, None);
1140 assert!(rule.check(&ctx).unwrap().is_empty());
1141 let bad = "> -\n> ## a\n> text\n";
1142 let ctx = crate::lint_context::LintContext::new(bad, crate::config::MarkdownFlavor::Standard, None);
1143 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1144 assert_eq!(rule.fix(&ctx).unwrap(), ok);
1145 }
1146
1147 #[test]
1148 fn test_quoted_list_edges_follow_the_innermost_item_and_the_lines_own_markers() {
1149 let rule = MD023HeadingStartLeft;
1150 for content in [
1151 "# T\n\n> - - item\n>\n> ## Sub\n",
1152 "# T\n\n>> - item\n>>\n> > ## Sub\n",
1153 ] {
1154 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1156 }
1157 let bad = "# T\n\n>> - item\n>>\n> > ## Sub\n";
1158 let ctx = crate::lint_context::LintContext::new(bad, crate::config::MarkdownFlavor::Standard, None);
1159 assert_eq!(rule.fix(&ctx).unwrap(), "# T\n\n>> - item\n>>\n> > ## Sub\n");
1160 let bad = "# T\n\n> - - item\n>\n> ## Sub\n";
1161 let ctx = crate::lint_context::LintContext::new(bad, crate::config::MarkdownFlavor::Standard, None);
1162 assert_eq!(rule.fix(&ctx).unwrap(), "# T\n\n> - - item\n>\n> ## Sub\n");
1163 }
1164
1165 #[test]
1166 fn test_indented_quote_marker_before_a_list_item_counts_once_in_the_margin() {
1167 let rule = MD023HeadingStartLeft;
1168 let content = "# T\n\n > - item\n>\n> ## Sub\n";
1169 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1171 }
1172
1173 #[test]
1174 fn test_tab_among_the_quote_markers_of_a_list_item_is_not_judged() {
1175 let rule = MD023HeadingStartLeft;
1176 for content in [
1177 "# T\n\n>\t- item\n>\n> ## Sub\n",
1178 "# T\n\n> 1.\titem\n>\n> ## Sub\n",
1179 ] {
1180 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 assert_eq!(rule.fix(&ctx).unwrap(), content);
1182 }
1183 }
1184
1185 fn fixed_quoted(content: &str) -> (Vec<usize>, String) {
1187 let rule = MD023HeadingStartLeft;
1188 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189 let lines = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1190 (lines, rule.fix(&ctx).unwrap())
1191 }
1192
1193 #[test]
1194 fn test_heading_after_the_quote_of_a_list_item_ends_is_measured_from_its_own_quote() {
1195 for (bad, fixed) in [
1198 ("# T\n\n> > - item\n>\n> ## H\n", "# T\n\n> > - item\n>\n> ## H\n"),
1199 (
1200 "# T\n\n> > 10. item\n>\n> ## H\n",
1201 "# T\n\n> > 10. item\n>\n> ## H\n",
1202 ),
1203 ("# T\n\n> > - item\n> ## H\n", "# T\n\n> > - item\n> ## H\n"),
1204 (
1205 "# T\n\n> > - item\n> lazy\n> ## H\n",
1206 "# T\n\n> > - item\n> lazy\n> ## H\n",
1207 ),
1208 (
1209 "# T\n\n> > - item\n> >\n> ## H\n",
1210 "# T\n\n> > - item\n> >\n> ## H\n",
1211 ),
1212 (
1213 "# T\n\n> > - a\n> - b\n> ## H\n",
1214 "# T\n\n> > - a\n> - b\n> ## H\n",
1215 ),
1216 (
1217 "# T\n\n> > - a\n> > - b\n>\n> ## H\n",
1218 "# T\n\n> > - a\n> > - b\n>\n> ## H\n",
1219 ),
1220 ] {
1221 let (lines, fixed_text) = fixed_quoted(bad);
1222 assert_eq!(fixed_text, fixed, "{bad:?}");
1223 assert_eq!(lines.len(), 1, "{bad:?}");
1224 }
1225 assert!(fixed_quoted("# T\n\n> > - item\n>\n> ## H\n").0.is_empty());
1227 }
1228
1229 #[test]
1230 fn test_quote_nested_inside_a_quoted_list_item_is_not_measured_against_the_item() {
1231 for (bad, fixed) in [
1233 ("> - item\n>\n> > ## H\n", "> - item\n>\n> > ## H\n"),
1234 ("> 10. item\n>\n> > ## H\n", "> 10. item\n>\n> > ## H\n"),
1235 ("> - item\n> >\n> > ## H\n", "> - item\n> >\n> > ## H\n"),
1236 (
1237 "> > - old\n> - new\n> > ## H\n",
1238 "> > - old\n> - new\n> > ## H\n",
1239 ),
1240 ("> - > text\n> >\n> > ## H\n", "> - > text\n> >\n> > ## H\n"),
1241 ] {
1242 let (lines, fixed_text) = fixed_quoted(bad);
1243 assert_eq!(fixed_text, fixed, "{bad:?}");
1244 assert_eq!(lines.len(), 1, "{bad:?}");
1245 }
1246 assert!(fixed_quoted("> - item\n>\n> > ## H\n").0.is_empty());
1248 assert!(fixed_quoted("> - item\n> > - x\n> >\n> > ## H\n").0.is_empty());
1250 }
1251
1252 fn flags(content: &str) -> Vec<bool> {
1253 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 quote_list_flags(&ctx)
1255 }
1256
1257 #[test]
1258 fn test_list_flags_cover_the_items_at_the_lines_depth_or_deeper() {
1259 assert_eq!(flags("> - a\n> b\n"), [true, true]);
1261 assert_eq!(flags("> > - a\n> b\n"), [true, true]);
1262 assert_eq!(flags("> - a\n> > b\n"), [true, false]);
1264 assert_eq!(flags("> text\n> > - x\n> ## H\n"), [false, true, true]);
1266 }
1267
1268 #[test]
1269 fn test_list_flags_end_deeper_quotes_at_a_line_that_cannot_continue_a_paragraph() {
1270 assert_eq!(flags("> > - a\n>\n> b\n"), [true, false, false]);
1272 assert_eq!(flags("> > - a\n>\n> > b\n"), [true, false, false]);
1274 assert_eq!(flags("> > - a\n> >\n> b\n"), [true, true, false]);
1276 assert_eq!(flags("> > - a\n> ## b\n"), [true, false]);
1278 assert_eq!(flags("> > - a\n> - b\n"), [true, true]);
1279 assert_eq!(flags("> > - a\n> 1. b\n"), [true, true]);
1280 assert_eq!(flags("> > - a\n> b\n"), [true, true]);
1282 assert_eq!(flags("> > - a\n> 2. b\n"), [true, true]);
1283 assert_eq!(flags("> > - a\n> -\n"), [true, true]);
1284 assert_eq!(flags("> > - a\n> - code\n> ## b\n"), [true, true, true]);
1286 assert_eq!(flags("> > - a\n> ## b\n"), [true, true]);
1288 assert_eq!(flags("> > - a\n> - b\n"), [true, true]);
1289 assert_eq!(flags("> - > - a\n>\n> > b\n"), [true, true, false]);
1291 assert_eq!(flags("> - > - a\n>\n> b\n"), [true, true, true]);
1292 }
1293
1294 #[test]
1295 fn test_possible_lazy_continuation_of_an_inner_item_withholds_the_item_edge() {
1296 let rule = MD023HeadingStartLeft;
1297 let content = "> - - text\n> lazy\n>\n> ## H\n";
1298 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1299 assert_eq!(rule.fix(&ctx).unwrap(), content);
1300 }
1301
1302 #[test]
1303 fn test_bare_inner_marker_on_a_quoted_item_line_sets_the_item_edge() {
1304 let rule = MD023HeadingStartLeft;
1305 let content = "> - -\n> ## Head\n";
1306 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 assert_eq!(rule.fix(&ctx).unwrap(), content);
1308 }
1309
1310 #[test]
1311 fn test_thematic_break_inside_a_quoted_item_is_not_a_nested_list() {
1312 let rule = MD023HeadingStartLeft;
1313 let content = "> - * * *\n>\n> ## Head\n";
1314 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315 assert_eq!(rule.fix(&ctx).unwrap(), "> - * * *\n>\n> ## Head\n");
1316 }
1317
1318 #[test]
1319 fn test_setext_text_opening_a_nested_quoted_item_is_not_re_indented() {
1320 let rule = MD023HeadingStartLeft;
1321 let content = "> - item\n> - other\n> ---\n> text\n";
1322 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 assert_eq!(rule.fix(&ctx).unwrap(), content);
1324 }
1325
1326 #[test]
1327 fn test_quote_after_a_list_holding_a_quoted_item_does_not_inherit_its_edge() {
1328 let rule = MD023HeadingStartLeft;
1329 let content = "- > - item\n > - item\n> lazy\n> ## Head\n> -\n";
1330 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331 assert_eq!(rule.fix(&ctx).unwrap(), content);
1332 }
1333
1334 #[test]
1335 fn test_unquoted_lazy_continuation_of_an_inner_item_withholds_the_item_edge() {
1336 let rule = MD023HeadingStartLeft;
1337 let content = "> - - item\n lazy\n> ## Head\n";
1338 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339 assert_eq!(rule.fix(&ctx).unwrap(), content);
1340 }
1341}