Skip to main content

rumdl_lib/rules/
md023_heading_start_left.rs

1/// Rule MD023: Headings must start at the left margin
2///
3/// See [docs/md023.md](../../docs/md023.md) for full documentation, configuration, and examples.
4use 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    /// The warning for the indentation of one line of a heading held by
13    /// `container`, or `None` when the line sits at its container's edge.
14    ///
15    /// The container's own indentation is not the heading's: removing it takes the
16    /// heading out of the list item holding it. Only the columns past the item's
17    /// content column are removed. The warning carries no fix where the edit
18    /// cannot be shown to keep the heading where it is: a container the pass does
19    /// not model, or a tab among the indentation of a line inside an item, whose
20    /// width depends on the column it starts at.
21    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        // A continuation line of a setext heading's text indented four columns
47        // or more is text, whatever it spells. With the indentation gone it could
48        // start a code fence, a quote or a list, and end the heading.
49        let fixable = fixable && !(continuation && indent >= 4);
50        // The indentation to remove is the bytes from the container's edge to the
51        // text; the edge is spaces on every line that is fixed, so columns are bytes.
52        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    /// The warning for the indentation of one line of a heading inside a
77    /// blockquote, or `None` when its text follows the quote marker directly.
78    ///
79    /// The quote marker and the one optional space after it are the quote's own
80    /// and stay; only the whitespace past them is the heading's indentation. A
81    /// line that does not begin with a quote marker (a heading opening a list item
82    /// that holds the quote) has no indentation of this kind. A tab after the
83    /// marker has a width that depends on its column, so it is reported without a
84    /// fix. In a quote that holds a list item, the item's content column is the edge.
85    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        // A tab that serves as the optional space after the marker is only
99        // partly the marker's own: the rest of its width is indentation.
100        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        // A list item opened in this quote owns the columns up to its content
110        // column: the heading is over-indented only by what lies past it.
111        let (edge, item_margin) = quoted.edges[line_idx];
112        // A line that opens an item has its marker's indentation, not a heading's.
113        if item_margin > 0 && edge > item_margin && line.list_item.is_none() && !text[..start].contains('\t') {
114            // Both columns are measured from the text after the line's own quote
115            // markers, which may be written with another width than the item's.
116            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        // Text indented four columns or more is not a heading line, except as a
153        // continuation of setext text, where removing the indentation could open
154        // a fence, a quote or a list and end the heading.
155        let fixable = spaces_only && !(continuation && indentation >= 4);
156        // The range starts at the marker's tab when part of that tab is indentation.
157        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
180/// For each line, whether a list item is open in the run of quoted lines above
181/// it: the nearest earlier line deciding it opens an item, or is text written
182/// flush with the quote after a blank line, or a flush heading, either of which
183/// ends any list before it. Flush text without a blank line above may be lazy continuation of an item, so it
184/// decides nothing, and an unprefixed line under quoted text is such a
185/// continuation of the quote.
186fn 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    // The shallowest quote depth a line of the open list was seen at: text in a
190    // quote nested deeper says nothing about the list around that quote.
191    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            // Unprefixed text straight under quoted text continues its paragraph,
196            // so the quote and its open item go on; anything else ends the quote.
197            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        // List-shaped text inside a fenced block decides nothing.
207        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
228/// The width of `text` in columns, with tabs advancing to multiples of four.
229fn 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
236/// Whether `text` opens with an ATX heading marker: one to six `#`, then a space,
237/// a tab or the end of the line.
238fn 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/// What the indentation check needs to know about a heading inside a quote.
245#[derive(Clone, Copy)]
246struct QuotedHeading<'a> {
247    /// Per line, the column its innermost open list item holds its content at
248    /// (`container_edges`), zero outside a list.
249    edges: &'a [(usize, usize)],
250    /// Per line, whether a list item may be open in the quote above it: an
251    /// unindented line under an item can be its lazy continuation, which
252    /// `edges` reads as ending the item.
253    list_flags: &'a [bool],
254    /// How many quote markers the heading's own line has.
255    depth: usize,
256}
257
258/// The quote depth of `line` and its text inside the quote, where the quote may
259/// open after list markers (`- > text`), or `None` for a line outside any quote.
260fn 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
266/// How many quote markers `line` opens with, up to `max`, and the byte offset
267/// where its content starts after them and the one optional space or tab that
268/// follows the last. The markers may be split by list markers (`> - > text`).
269/// `None` when the line has no marker.
270fn 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        // Step over list markers to a quote that opens inside the item.
292        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
310/// The byte offset in `line` where its quote markers begin: 0 for a line that
311/// opens with one, after the list markers for a quote inside a list item
312/// (`- > text`), or `None` for a line outside any quote.
313fn 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
329/// Whether `text` starts with a list marker followed by a space, a tab or the
330/// end of the line.
331fn 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        // Early return for empty content
355        if ctx.lines.is_empty() {
356            return Ok(vec![]);
357        }
358
359        let mut warnings = Vec::new();
360
361        // Computed once, and only for a document with a heading in a quote.
362        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        // Process all headings using cached heading information
370        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            // The quote depth is read off the line that closes the heading: the
375            // underline of a setext heading, whose text lines may start with
376            // markers that are only text.
377            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            // Skip lines inside PyMdown blocks, admonitions, and content tabs:
391            // those containers are indentation-scoped, so a heading indented
392            // there is intentionally nested, not an accidental indent. This
393            // deliberately excludes markdown="1" HTML divs (tag-scoped, content
394            // needs no indentation, and detected in every flavor).
395            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            // Skip hashtag-like patterns (e.g., #tag, #123, #29039) for ATX level 1
401            // These are likely issue refs or social hashtags, not intended headings
402            if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
403                // Get first "word" of heading text (up to space, comma, or closing paren)
404                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                    // Skip if first word starts with lowercase or number
412                    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            // A setext heading's text is the whole paragraph its underline
429            // ends, and CommonMark strips the leading whitespace of every one
430            // of those lines, so each of them carries indentation of its own
431            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                // The underline is indented independently of the text
449                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        // Protect the indentation edits themselves: visible template headings
467        // can have Markdown indentation outside the template delimiters.
468        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    /// Get the category of this rule for selective processing
496    fn category(&self) -> RuleCategory {
497        RuleCategory::Heading
498    }
499
500    /// Check if this rule should be skipped
501    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
502        // Fast path: check if document likely has headings
503        if !ctx.likely_has_headings() {
504            return true;
505        }
506        // Verify headings actually exist
507        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        // Test with properly aligned headings
531        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        // Test with indented headings
537        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); // Should flag all three indented headings
541        assert_eq!(result[0].line, 1);
542        assert_eq!(result[1].line, 2);
543        assert_eq!(result[2].line, 3);
544
545        // Test with setext headings
546        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); // Should flag the indented heading and underline
550        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        // Issue refs should NOT be flagged (starts with number)
559        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        // Hashtags should NOT be flagged (starts with lowercase)
568        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        // A `#` run with no space after it is paragraph text in CommonMark,
577        // whatever follows it, so there is no heading to move left
578        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        // The same line with its space is an indented heading
589        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        // Properly aligned headings should pass
595        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        // A heading intentionally indented to stay nested
607        // inside a MkDocs admonition body must not be flagged, since the
608        // indentation is required for it to belong to the admonition.
609        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        // The same false positive occurs inside a MkDocs content tab body.
623        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        // Control: a heading indented outside any admonition/tab (accidental
637        // indentation, not container nesting) must still be flagged under
638        // MkDocs flavor, and its fix must still de-indent it.
639        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        // Control: standard flavor has no MkDocs container concept, so an
656        // indented heading is still an accidental indent and must still be
657        // flagged and de-indented by the fix. Uses 3 spaces: at 4+ spaces
658        // CommonMark parses the line as an indented code block rather than a
659        // heading at all, which is unrelated to this rule's guard.
660        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        // A markdown="1" HTML div is tag-scoped, not indentation-scoped:
677        // content needs no indentation to belong to it, so an indented
678        // heading inside one is an accidental indent in every flavor.
679        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/// A heading written inside a list item is indented by the item's own content
697/// column. Only the indentation past that column is the heading's, and removing
698/// the rest moves the heading out of the item.
699#[cfg(test)]
700mod list_item_tests {
701    use super::*;
702    use crate::config::MarkdownFlavor;
703    use crate::lint_context::LintContext;
704
705    /// The warnings for `content` and the document after the rule's fix.
706    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    /// Content the rule leaves alone: no warning and no change.
715    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    /// Content the rule flags once per entry of `lines`, fixed to `expected`,
722    /// and that stays fixed when fixed again.
723    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        // Text between the item and the heading does not end the item.
766        assert_fixed("- x\n  text\n    ## a\n", &[3], "- x\n  text\n  ## a\n");
767        // A lazy continuation line holds the heading in the item.
768        assert_clean("- x\nlazy\n  ## a\n");
769        assert_clean("- x\n\n  a\nlazy\n  ---\n");
770        // An item that opened empty is closed by the blank line, and the heading
771        // belongs to the item around it, whose margin it keeps.
772        assert_fixed("-\n  -\n\n    ## a\n", &[4], "-\n  -\n\n  ## a\n");
773        // A paragraph that re-enters only the outer item closes the inner one.
774        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        // Two tabs reach column 8, six past the item's edge: indented code.
789        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        // Written under the content column of its item, a heading is outside it.
797        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        // The fence line is text of the heading; unindented it would open a fence.
806        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        // The shallow continuation line and the underline are still fixed.
812        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        // The heading is in the outer quote, outside the item: it is not moved to the item's column.
902        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        // Not rewritten as if the heading sat in the item's own quote.
911        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}