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> {
187 let mut flags = Vec::with_capacity(ctx.lines.len());
188 let mut open = false;
189 let mut open_depth = usize::MAX;
192 let mut blank_above = true;
193 for line in &ctx.lines {
194 let Some((depth, content)) = quoted_content(line.content(ctx.content)) else {
195 if blank_above || line.content(ctx.content).trim().is_empty() {
198 open = false;
199 open_depth = usize::MAX;
200 blank_above = true;
201 }
202 flags.push(false);
203 continue;
204 };
205 flags.push(open);
206 if line.in_code_block {
208 continue;
209 }
210 let blank = content.trim().is_empty();
211 if !blank {
212 if opens_list_item(content.trim_start()) {
213 open = true;
214 open_depth = open_depth.min(depth);
215 } else if depth <= open_depth
216 && !content.starts_with([' ', '\t'])
217 && (blank_above || starts_atx_heading(content))
218 {
219 open = false;
220 open_depth = usize::MAX;
221 }
222 }
223 blank_above = blank;
224 }
225 flags
226}
227
228fn visual_width(text: &str) -> usize {
230 text.chars().fold(0, |column, c| match c {
231 '\t' => column + 4 - column % 4,
232 _ => column + 1,
233 })
234}
235
236fn starts_atx_heading(text: &str) -> bool {
239 let hashes = text.len() - text.trim_start_matches('#').len();
240 (1..=6).contains(&hashes) && (text.len() == hashes || text[hashes..].starts_with([' ', '\t']))
241 || (1..=6).contains(&hashes) && text.len() == hashes
242}
243
244#[derive(Clone, Copy)]
246struct QuotedHeading<'a> {
247 edges: &'a [(usize, usize)],
250 list_flags: &'a [bool],
254 depth: usize,
256}
257
258fn quoted_content(line: &str) -> Option<(usize, &str)> {
261 let start = quote_start(line)?;
262 let parsed = crate::utils::blockquote::parse_blockquote_prefix(&line[start..])?;
263 Some((parsed.nesting_level, parsed.content))
264}
265
266fn quote_margin(line: &str, max: usize) -> Option<(usize, usize)> {
271 let mut offset = 0;
272 let mut markers = 0;
273 loop {
274 let mut consumed = false;
275 while markers < max {
276 let rest = &line[offset..];
277 let next = rest.trim_start_matches([' ', '\t']);
278 if !next.starts_with('>') {
279 break;
280 }
281 offset += rest.len() - next.len() + 1;
282 markers += 1;
283 consumed = true;
284 }
285 if consumed {
286 offset += usize::from(line[offset..].starts_with([' ', '\t']));
287 }
288 if markers >= max {
289 break;
290 }
291 let mut after = offset;
293 loop {
294 let rest = &line[after..];
295 let trimmed = rest.trim_start_matches([' ', '\t']);
296 let token = trimmed.split([' ', '\t']).next().unwrap_or("");
297 if !opens_list_item(token) {
298 return (markers > 0).then_some((markers, offset));
299 }
300 after += rest.len() - trimmed.len() + token.len();
301 if line[after..].trim_start_matches([' ', '\t']).starts_with('>') {
302 break;
303 }
304 }
305 offset = after;
306 }
307 (markers > 0).then_some((markers, offset))
308}
309
310fn quote_start(line: &str) -> Option<usize> {
314 let mut start = 0;
315 loop {
316 let rest = &line[start..];
317 if crate::utils::blockquote::parse_blockquote_prefix(rest).is_some() {
318 return Some(start);
319 }
320 let trimmed = rest.trim_start_matches([' ', '\t']);
321 let token = trimmed.split([' ', '\t']).next().unwrap_or("");
322 if token.is_empty() || !opens_list_item(token) {
323 return None;
324 }
325 start += rest.len() - trimmed.len() + token.len();
326 }
327}
328
329fn opens_list_item(text: &str) -> bool {
332 let token = text.split([' ', '\t']).next().unwrap_or("");
333 let digits = token.trim_end_matches(['.', ')']);
334 matches!(token, "-" | "+" | "*")
335 || (token.len() == digits.len() + 1
336 && (1..=9).contains(&digits.len())
337 && digits.bytes().all(|b| b.is_ascii_digit()))
338}
339
340impl Rule for MD023HeadingStartLeft {
341 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
342 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(23))
343 }
344
345 fn name(&self) -> &'static str {
346 "MD023"
347 }
348
349 fn description(&self) -> &'static str {
350 "Headings must start at the beginning of the line"
351 }
352
353 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
354 if ctx.lines.is_empty() {
356 return Ok(vec![]);
357 }
358
359 let mut warnings = Vec::new();
360
361 let quote_columns = ctx.headings().any(|parsed| parsed.is_blockquote()).then(|| {
363 (
364 crate::utils::blockquote::container_edges_with_margins(ctx),
365 quote_list_flags(ctx),
366 )
367 });
368
369 for parsed in ctx.headings() {
371 let line_num = parsed.line_num - 1;
372 let line_info = parsed.line_info;
373 let quoted = parsed.is_blockquote();
374 let closing_line = if parsed.is_setext() { line_num + 1 } else { line_num };
378 let quoted_flags = quote_columns
379 .as_ref()
380 .filter(|_| quoted)
381 .map(|(edges, list_flags)| QuotedHeading {
382 edges,
383 list_flags,
384 depth: ctx
385 .lines
386 .get(closing_line)
387 .and_then(|line| quote_margin(line.content(ctx.content), usize::MAX))
388 .map_or(0, |(depth, _)| depth),
389 });
390 if line_info.in_pymdown_block || line_info.in_admonition || line_info.in_content_tab {
396 continue;
397 }
398
399 let heading = parsed.heading;
400 if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
403 let first_word: String = heading
405 .text
406 .trim()
407 .chars()
408 .take_while(|c| !c.is_whitespace() && *c != ',' && *c != ')')
409 .collect();
410 if let Some(first_char) = first_word.chars().next() {
411 if first_char.is_lowercase() || first_char.is_numeric() {
413 continue;
414 }
415 }
416 }
417
418 let is_setext = matches!(
419 heading.style,
420 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
421 );
422 let container = if quoted {
423 HeadingContainer::Unknown
424 } else {
425 ctx.heading_container(line_num + 1)
426 };
427
428 if is_setext {
432 let underline_line = line_num + 1;
433 let first_text_line = line_num + 1 - heading.text_lines;
434
435 for text_line in first_text_line..=line_num {
436 if let Some(warning) = self.indentation_warning(
437 ctx,
438 text_line,
439 container,
440 quoted_flags,
441 text_line > first_text_line,
442 |indent| format!("Setext heading should not be indented by {indent} spaces"),
443 ) {
444 warnings.push(warning);
445 }
446 }
447
448 if underline_line < ctx.lines.len()
450 && let Some(warning) =
451 self.indentation_warning(ctx, underline_line, container, quoted_flags, false, |_| {
452 "Setext heading underline should not be indented".to_string()
453 })
454 {
455 warnings.push(warning);
456 }
457 } else if let Some(warning) =
458 self.indentation_warning(ctx, line_num, container, quoted_flags, false, |indent| {
459 format!("Heading should not be indented by {indent} spaces")
460 })
461 {
462 warnings.push(warning);
463 }
464 }
465
466 warnings.retain(|warning| {
469 warning
470 .fix
471 .as_ref()
472 .is_none_or(|fix| !ctx.overlaps_template_code(fix.range.start, fix.range.end))
473 });
474 Ok(warnings)
475 }
476
477 fn fixes_from_diagnostics(&self) -> bool {
478 true
479 }
480
481 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
482 if self.should_skip(ctx) {
483 return Ok(ctx.content.to_string());
484 }
485 let warnings = self.check(ctx)?;
486 if warnings.is_empty() {
487 return Ok(ctx.content.to_string());
488 }
489 let warnings =
490 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
491 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
492 .map_err(crate::rule::LintError::InvalidInput)
493 }
494
495 fn category(&self) -> RuleCategory {
497 RuleCategory::Heading
498 }
499
500 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
502 if !ctx.likely_has_headings() {
504 return true;
505 }
506 ctx.headings().next().is_none()
508 }
509
510 fn as_any(&self) -> &dyn std::any::Any {
511 self
512 }
513
514 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
515 where
516 Self: Sized,
517 {
518 Box::new(MD023HeadingStartLeft)
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::lint_context::LintContext;
526 #[test]
527 fn test_basic_functionality() {
528 let rule = MD023HeadingStartLeft;
529
530 let content = "# Heading 1\n## Heading 2\n### Heading 3";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534 assert!(result.is_empty());
535
536 let content = " # Heading 1\n ## Heading 2\n ### Heading 3";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 1);
542 assert_eq!(result[1].line, 2);
543 assert_eq!(result[2].line, 3);
544
545 let content = "Heading 1\n=========\n Heading 2\n ---------";
547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548 let result = rule.check(&ctx).unwrap();
549 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 3);
551 assert_eq!(result[1].line, 4);
552 }
553
554 #[test]
555 fn test_issue_refs_skipped_but_real_headings_caught() {
556 let rule = MD023HeadingStartLeft;
557
558 let content = "- fix: issue\n #29039)";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let result = rule.check(&ctx).unwrap();
562 assert!(
563 result.is_empty(),
564 "#29039) should not be flagged as indented heading. Got: {result:?}"
565 );
566
567 let content = "Some text\n #hashtag";
569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570 let result = rule.check(&ctx).unwrap();
571 assert!(
572 result.is_empty(),
573 "#hashtag should not be flagged as indented heading. Got: {result:?}"
574 );
575
576 for content in [
579 "Some text\n #Summary",
580 "Some text\n ##introduction",
581 "Some text\n ##123",
582 ] {
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 let result = rule.check(&ctx).unwrap();
585 assert!(result.is_empty(), "{content:?} is not a heading. Got: {result:?}");
586 }
587
588 let content = "Some text\n\n # Summary";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let result = rule.check(&ctx).unwrap();
592 assert_eq!(result.len(), 1, "indented `# Summary` is flagged. Got: {result:?}");
593
594 let content = "# Summary\n## Details";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597 let result = rule.check(&ctx).unwrap();
598 assert!(
599 result.is_empty(),
600 "Properly aligned headings should pass. Got: {result:?}"
601 );
602 }
603
604 #[test]
605 fn test_mkdocs_admonition_indented_heading_not_flagged() {
606 let rule = MD023HeadingStartLeft;
610 let content = "!!! note\n\n # Foo";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
612 let result = rule.check(&ctx).unwrap();
613
614 assert!(
615 result.is_empty(),
616 "heading nested in an admonition body should not be flagged, got: {result:?}"
617 );
618 }
619
620 #[test]
621 fn test_mkdocs_content_tab_indented_heading_not_flagged() {
622 let rule = MD023HeadingStartLeft;
624 let content = "=== \"Tab A\"\n\n # Foo";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
626 let result = rule.check(&ctx).unwrap();
627
628 assert!(
629 result.is_empty(),
630 "heading nested in a content tab body should not be flagged, got: {result:?}"
631 );
632 }
633
634 #[test]
635 fn test_mkdocs_accidental_indent_still_flagged() {
636 let rule = MD023HeadingStartLeft;
640 let content = "Some text\n\n # Foo";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
642 let result = rule.check(&ctx).unwrap();
643 assert_eq!(
644 result.len(),
645 1,
646 "accidentally indented top-level heading should still be flagged, got: {result:?}"
647 );
648
649 let fixed = rule.fix(&ctx).unwrap();
650 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
651 }
652
653 #[test]
654 fn test_standard_flavor_indented_heading_still_flagged_and_fixed() {
655 let rule = MD023HeadingStartLeft;
661 let content = "Some text\n\n # Foo";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let result = rule.check(&ctx).unwrap();
664 assert_eq!(
665 result.len(),
666 1,
667 "indented heading should still be flagged under standard flavor, got: {result:?}"
668 );
669
670 let fixed = rule.fix(&ctx).unwrap();
671 assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
672 }
673
674 #[test]
675 fn test_html_markdown_div_indented_heading_still_flagged() {
676 let rule = MD023HeadingStartLeft;
680 let content = "<div markdown=\"1\">\n\n # Bar\n\n</div>";
681 for flavor in [
682 crate::config::MarkdownFlavor::Standard,
683 crate::config::MarkdownFlavor::MkDocs,
684 ] {
685 let ctx = LintContext::new(content, flavor, None);
686 let result = rule.check(&ctx).unwrap();
687 assert_eq!(
688 result.len(),
689 1,
690 "indented heading inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
691 );
692 }
693 }
694}
695
696#[cfg(test)]
700mod list_item_tests {
701 use super::*;
702 use crate::config::MarkdownFlavor;
703 use crate::lint_context::LintContext;
704
705 fn run(content: &str) -> (Vec<LintWarning>, String) {
707 let rule = MD023HeadingStartLeft;
708 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
709 let warnings = rule.check(&ctx).unwrap();
710 let fixed = rule.fix(&ctx).unwrap();
711 (warnings, fixed)
712 }
713
714 fn assert_clean(content: &str) {
716 let (warnings, fixed) = run(content);
717 assert!(warnings.is_empty(), "{content:?} warned: {warnings:?}");
718 assert_eq!(fixed, content, "{content:?} was rewritten");
719 }
720
721 fn assert_fixed(content: &str, lines: &[usize], expected: &str) {
724 let (warnings, fixed) = run(content);
725 assert_eq!(
726 warnings.iter().map(|warning| warning.line).collect::<Vec<_>>(),
727 lines,
728 "{content:?}: {warnings:?}"
729 );
730 assert_eq!(fixed, expected, "{content:?}");
731 assert_clean(expected);
732 }
733
734 #[test]
735 fn a_heading_at_its_items_content_column_is_not_indented() {
736 for content in [
737 "-\n ## a\n text\n",
738 "- x\n\n ## a\n\n text\n",
739 "- x\n ## a\n text\n",
740 "1. x\n\n ## a\n",
741 "10. x\n\n ## a\n",
742 "- x\n - y\n\n ## a\n",
743 "-\n -\n ## a\n",
744 "- x\n\n a\n ---\n",
745 "1. x\n\n a\n ===\n",
746 "-\n a\n ---\n text\n",
747 "-\tx\n\n ## a\n",
748 "- code\n\n ## a\n",
749 ] {
750 assert_clean(content);
751 }
752 }
753
754 #[test]
755 fn only_the_indentation_past_the_content_column_is_removed() {
756 assert_fixed("- x\n\n ## a\n", &[3], "- x\n\n ## a\n");
757 assert_fixed("- x\n - y\n\n ## a\n", &[4], "- x\n - y\n\n ## a\n");
758 assert_fixed("1. x\n\n ## a\n", &[3], "1. x\n\n ## a\n");
759 assert_fixed("- x\n\n a\n ---\n", &[3, 4], "- x\n\n a\n ---\n");
760 assert_fixed("- x\n\n a\n b\n ===\n", &[3, 4, 5], "- x\n\n a\n b\n ===\n");
761 }
762
763 #[test]
764 fn the_item_is_found_by_what_the_line_re_enters() {
765 assert_fixed("- x\n text\n ## a\n", &[3], "- x\n text\n ## a\n");
767 assert_clean("- x\nlazy\n ## a\n");
769 assert_clean("- x\n\n a\nlazy\n ---\n");
770 assert_fixed("-\n -\n\n ## a\n", &[4], "-\n -\n\n ## a\n");
773 assert_fixed(
775 "- x\n - y\n\n paragraph\n\n ## a\n",
776 &[6],
777 "- x\n - y\n\n paragraph\n\n ## a\n",
778 );
779 }
780
781 #[test]
782 fn a_tab_in_the_indentation_is_reported_but_not_rewritten() {
783 let content = "- x\n\n\t## a\n";
784 let (warnings, fixed) = run(content);
785 assert_eq!(warnings.len(), 1, "{warnings:?}");
786 assert!(warnings[0].fix.is_none(), "{:?}", warnings[0].fix);
787 assert_eq!(fixed, content);
788 assert_clean("- x\n\n\t\t## a\n");
790 }
791
792 #[test]
793 fn a_heading_outside_every_item_is_still_moved_to_the_margin() {
794 assert_fixed(" ## a\n", &[1], "## a\n");
795 assert_fixed("a\n\n b\n ---\n", &[3, 4], "a\n\nb\n---\n");
796 assert_fixed("1. x\n\n ## a\n", &[3], "1. x\n\n## a\n");
798 assert_fixed("- x\n\n ## a\n", &[3], "- x\n\n## a\n");
799 assert_fixed("- x\n\nplain\n\n ## a\n", &[5], "- x\n\nplain\n\n## a\n");
800 assert_fixed("-\n\n ## a\n", &[3], "-\n\n## a\n");
801 }
802
803 #[test]
804 fn deep_continuation_lines_of_setext_text_are_reported_but_not_rewritten() {
805 for content in ["- x\n\n a\n ```\n ---\n", "a\n ```\n---\n"] {
807 let (warnings, fixed) = run(content);
808 assert!(!warnings.is_empty(), "{content:?}");
809 assert_eq!(fixed, content, "{content:?}");
810 }
811 let (_, fixed) = run("- x\n\n a\n b\n ---\n");
813 assert_eq!(fixed, "- x\n\n a\n b\n ---\n");
814 }
815
816 #[test]
817 fn test_heading_past_a_quoted_list_items_content_column_is_flagged() {
818 let rule = MD023HeadingStartLeft;
819 for (item, bad, fixed) in [
820 ("> - item\n>\n", "> ## Sub\n", "> ## Sub\n"),
821 ("> 1. item\n>\n", "> ## Sub\n", "> ## Sub\n"),
822 ("> > - item\n> >\n", "> > ## Sub\n", "> > ## Sub\n"),
823 ("> - a\n> - b\n>\n", "> ## Sub\n", "> ## Sub\n"),
824 ] {
825 let content = format!("# T\n\n{item}{bad}");
826 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
827 let result = rule.check(&ctx).unwrap();
828 assert_eq!(result.len(), 1, "{content:?}: {result:?}");
829 assert_eq!(rule.fix(&ctx).unwrap(), format!("# T\n\n{item}{fixed}"), "{content:?}");
830 }
831 }
832
833 #[test]
834 fn test_heading_at_a_quoted_list_items_content_column_is_not_flagged() {
835 let rule = MD023HeadingStartLeft;
836 for content in [
837 "# T\n\n> - item\n>\n> ## Sub\n",
838 "# T\n\n> 1. item\n>\n> ## Sub\n",
839 "# T\n\n> > - item\n> >\n> > ## Sub\n",
840 ] {
841 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
843 }
844 }
845
846 #[test]
847 fn test_heading_under_a_bare_marker_in_a_quote_is_judged_from_the_marker_plus_one() {
848 let rule = MD023HeadingStartLeft;
849 let ok = "> -\n> ## a\n> text\n";
850 let ctx = crate::lint_context::LintContext::new(ok, crate::config::MarkdownFlavor::Standard, None);
851 assert!(rule.check(&ctx).unwrap().is_empty());
852 let bad = "> -\n> ## a\n> text\n";
853 let ctx = crate::lint_context::LintContext::new(bad, crate::config::MarkdownFlavor::Standard, None);
854 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
855 assert_eq!(rule.fix(&ctx).unwrap(), ok);
856 }
857
858 #[test]
859 fn test_quoted_list_edges_follow_the_innermost_item_and_the_lines_own_markers() {
860 let rule = MD023HeadingStartLeft;
861 for content in [
862 "# T\n\n> - - item\n>\n> ## Sub\n",
863 "# T\n\n>> - item\n>>\n> > ## Sub\n",
864 ] {
865 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
867 }
868 let bad = "# T\n\n>> - item\n>>\n> > ## Sub\n";
869 let ctx = crate::lint_context::LintContext::new(bad, crate::config::MarkdownFlavor::Standard, None);
870 assert_eq!(rule.fix(&ctx).unwrap(), "# T\n\n>> - item\n>>\n> > ## Sub\n");
871 let bad = "# T\n\n> - - item\n>\n> ## Sub\n";
872 let ctx = crate::lint_context::LintContext::new(bad, crate::config::MarkdownFlavor::Standard, None);
873 assert_eq!(rule.fix(&ctx).unwrap(), "# T\n\n> - - item\n>\n> ## Sub\n");
874 }
875
876 #[test]
877 fn test_indented_quote_marker_before_a_list_item_counts_once_in_the_margin() {
878 let rule = MD023HeadingStartLeft;
879 let content = "# T\n\n > - item\n>\n> ## Sub\n";
880 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
882 }
883
884 #[test]
885 fn test_tab_among_the_quote_markers_of_a_list_item_is_not_judged() {
886 let rule = MD023HeadingStartLeft;
887 for content in [
888 "# T\n\n>\t- item\n>\n> ## Sub\n",
889 "# T\n\n> 1.\titem\n>\n> ## Sub\n",
890 ] {
891 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 assert_eq!(rule.fix(&ctx).unwrap(), content);
893 }
894 }
895
896 #[test]
897 fn test_heading_after_the_quote_of_a_list_item_ends_is_measured_from_its_own_quote() {
898 let rule = MD023HeadingStartLeft;
899 let content = "# T\n\n> > - item\n>\n> ## H\n";
900 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901 assert_ne!(rule.fix(&ctx).unwrap(), "# T\n\n> > - item\n>\n> ## H\n");
903 }
904
905 #[test]
906 fn test_quote_nested_inside_a_quoted_list_item_is_not_measured_against_the_item() {
907 let rule = MD023HeadingStartLeft;
908 let content = "> - item\n>\n> > ## H\n";
909 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 assert_ne!(rule.fix(&ctx).unwrap(), "> - item\n>\n> > ## H\n");
912 }
913
914 #[test]
915 fn test_possible_lazy_continuation_of_an_inner_item_withholds_the_item_edge() {
916 let rule = MD023HeadingStartLeft;
917 let content = "> - - text\n> lazy\n>\n> ## H\n";
918 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919 assert_eq!(rule.fix(&ctx).unwrap(), content);
920 }
921
922 #[test]
923 fn test_bare_inner_marker_on_a_quoted_item_line_sets_the_item_edge() {
924 let rule = MD023HeadingStartLeft;
925 let content = "> - -\n> ## Head\n";
926 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927 assert_eq!(rule.fix(&ctx).unwrap(), content);
928 }
929
930 #[test]
931 fn test_thematic_break_inside_a_quoted_item_is_not_a_nested_list() {
932 let rule = MD023HeadingStartLeft;
933 let content = "> - * * *\n>\n> ## Head\n";
934 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935 assert_eq!(rule.fix(&ctx).unwrap(), "> - * * *\n>\n> ## Head\n");
936 }
937
938 #[test]
939 fn test_setext_text_opening_a_nested_quoted_item_is_not_re_indented() {
940 let rule = MD023HeadingStartLeft;
941 let content = "> - item\n> - other\n> ---\n> text\n";
942 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 assert_eq!(rule.fix(&ctx).unwrap(), content);
944 }
945
946 #[test]
947 fn test_quote_after_a_list_holding_a_quoted_item_does_not_inherit_its_edge() {
948 let rule = MD023HeadingStartLeft;
949 let content = "- > - item\n > - item\n> lazy\n> ## Head\n> -\n";
950 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 assert_eq!(rule.fix(&ctx).unwrap(), content);
952 }
953
954 #[test]
955 fn test_unquoted_lazy_continuation_of_an_inner_item_withholds_the_item_edge() {
956 let rule = MD023HeadingStartLeft;
957 let content = "> - - item\n lazy\n> ## Head\n";
958 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959 assert_eq!(rule.fix(&ctx).unwrap(), content);
960 }
961}