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 may be open in the run of quoted lines above
181/// it at the line's own quote depth or deeper: the nearest earlier line deciding it
182/// opens an item, or is text written flush with the quote after a blank line, or a
183/// flush heading, either of which ends any list before it. Flush text without a blank
184/// line above may be lazy continuation of an item, so it decides nothing, and an
185/// unprefixed line under quoted text is such a continuation of the quote.
186///
187/// An item at a shallower depth than the line cannot change where the line's quote
188/// holds its content, so it does not count; a line that cannot continue a paragraph
189/// ends the quotes deeper than its own, and the items in them.
190fn quote_list_flags(ctx: &crate::lint_context::LintContext) -> Vec<bool> {
191    let mut flags = Vec::with_capacity(ctx.lines.len());
192    // The quote depths holding an item that may still be open, ascending and distinct.
193    let mut item_depths: Vec<usize> = Vec::new();
194    // The shallowest quote depth a line of the open list was seen at: text in a
195    // quote nested deeper says nothing about the list around that quote.
196    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    // The quote depth of an HTML block a tag alone on a quoted line may have opened.
203    let mut tag_block: Option<usize> = None;
204    // The end marker of a comment, instruction or declaration block opened in a quote;
205    // it lasts through blank lines until the marker or an unquoted line.
206    let mut raw_block: Option<(&'static str, usize)> = None;
207    for line in &ctx.lines {
208        let text = line.content(ctx.content);
209        // Four columns of indentation before the first marker make it text, not a quote,
210        // unless a list holding the quote supplies that indentation.
211        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            // Unprefixed text straight under quoted text continues its paragraph,
219            // so the quote and its open item go on; anything else ends the quote.
220            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            // An unquoted line ends the quote's HTML block, which has no lazy lines.
227            tag_block = None;
228            raw_block = None;
229            flags.push(false);
230            continue;
231        };
232        // The quote markers of a line written flush with the margin are not held by a list,
233        // so a later line indented four columns is text again.
234        in_list_quote = quote_start(text).is_some_and(|at| at > 0);
235        // List-shaped text inside a code block decides nothing, but an item whose
236        // content is indented code (`- ` and five spaces) is still an item.
237        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        // Where a quoted block that runs to its end marker begins or ends is read by the
243        // parser and by other readers alike only up to the marker, so the lines between
244        // are never taken for headings or items.
245        let mut was_raw = false;
246        if let Some((end, open)) = raw_block {
247            if depth < open {
248                // The line leaves the quote that held the block.
249                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        // The block lasts until a blank line at the quote depth it opened at; deeper
262        // quote markers are text of the block, and a shallower line leaves its quote.
263        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            // A tag alone on a line cannot interrupt a paragraph, but it can start a block
268            // after a paragraph that is held by an item or a deeper quote, which the parser
269            // reads as a lazy line; the parser reads the other kinds of block itself.
270            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        // The line itself ends the quotes deeper than its own, and the items in them.
279        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        // An opener line sits inside the item it opens at its own quote depth (a setext
285        // heading's text line is such a line), so it is flagged once that item is
286        // recorded. A marker opens an item there when nothing is above it to continue,
287        // when it starts a quote, or when it can interrupt the paragraph above it.
288        let opens_item = !blank && opens_list_item(content.trim_start());
289        // A line written flush after a line of a quote inside an item leaves that quote
290        // and may leave the item. What the lines above held is then unknown, so an item
291        // marker is taken to open an item whether or not it could interrupt a paragraph.
292        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
337/// Whether the optional space after the last quote marker of `line` is a tab that is wider
338/// than that one column, so that the text after `content` is indented by what is left.
339fn 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
344/// Whether `content` is a tag alone on its line, indented less than four columns: the start
345/// of an HTML block that no paragraph above needs to allow, which other parsers read
346/// where a paragraph's lazy continuation is not possible.
347/// The marker that ends an HTML block of start conditions 2 to 5 opened by `content`.
348fn 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
366/// Whether `content` is a line of text that a following line continues as a paragraph,
367/// rather than a marker, a fence, a heading, a tag or a line of an indented block.
368fn 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    // Start conditions 1 and 6 interrupt a paragraph, so the parser reads them itself; a
410    // closing type-1 tag and the names outside the spec are start condition 7.
411    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
417/// Whether `tag` (the text after the `<`) is one whole open or closing tag with nothing but
418/// whitespace after it, which is what start condition 7 asks of the line.
419fn 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
444/// Whether a quote marker of `line` is written far enough past the marker before it to be
445/// inside an item that an earlier line opened (`>   > x`).
446fn 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
467/// Calls `visit` with each quote depth `line` may open or sit inside an item at: the depth
468/// of every list marker opening it (`> - > - x` visits 1, then 2), and the depth around a
469/// quote marker written far enough past the marker before it to be inside an item that
470/// an earlier line opened (`>   > - x` visits 1 for the outer quote's item, then 2).
471/// Returns the quote depth the line's text sits at.
472fn 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            // The optional space after the marker before it, then the two columns of the
480            // narrowest item marker.
481            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
497/// Whether `content`, the text of a quoted line after its quote markers, starts a block
498/// that cannot be a lazy continuation of a paragraph in a deeper quote: an ATX heading
499/// or a list item with text, indented less than four columns.
500fn ends_deeper_quotes(line: &str, content: &str) -> bool {
501    let trimmed = content.trim_start_matches([' ', '\t']);
502    // The columns of indentation after the quote marker and the one optional column
503    // it takes, counting what is left of a tab.
504    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
517/// The width of `text` in columns, with tabs advancing to multiples of four.
518fn 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
525/// Whether `text` opens with an ATX heading marker: one to six `#`, then a space,
526/// a tab or the end of the line.
527fn 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/// What the indentation check needs to know about a heading inside a quote.
534#[derive(Clone, Copy)]
535struct QuotedHeading<'a> {
536    /// Per line, the column its innermost open list item holds its content at
537    /// (`container_edges`), zero outside a list.
538    edges: &'a [(usize, usize)],
539    /// Per line, whether a list item may be open in the quote above it: an
540    /// unindented line under an item can be its lazy continuation, which
541    /// `edges` reads as ending the item.
542    list_flags: &'a [bool],
543    /// How many quote markers the heading's own line has.
544    depth: usize,
545}
546
547/// The quote depth of `line` and its text inside the quote, where the quote may
548/// open after list markers (`- > text`), or `None` for a line outside any quote.
549fn 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
555/// How many quote markers `line` opens with, up to `max`, and the byte offset
556/// where its content starts after them and the one optional space or tab that
557/// follows the last. The markers may be split by list markers (`> - > text`).
558/// `None` when the line has no marker.
559fn 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        // Step over list markers to a quote that opens inside the item.
581        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
599/// The byte offset in `line` where its quote markers begin: 0 for a line that
600/// opens with one, after the list markers for a quote inside a list item
601/// (`- > text`), or `None` for a line outside any quote.
602fn 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
618/// Whether `text` starts with a list marker followed by a space, a tab or the
619/// end of the line.
620fn 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        // Early return for empty content
644        if ctx.lines.is_empty() {
645            return Ok(vec![]);
646        }
647
648        let mut warnings = Vec::new();
649
650        // Computed once, and only for a document with a heading in a quote.
651        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        // Process all headings using cached heading information
659        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            // The quote depth is read off the line that closes the heading: the
664            // underline of a setext heading, whose text lines may start with
665            // markers that are only text.
666            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            // Skip lines inside PyMdown blocks, admonitions, and content tabs:
680            // those containers are indentation-scoped, so a heading indented
681            // there is intentionally nested, not an accidental indent. This
682            // deliberately excludes markdown="1" HTML divs (tag-scoped, content
683            // needs no indentation, and detected in every flavor).
684            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            // Skip hashtag-like patterns (e.g., #tag, #123, #29039) for ATX level 1
690            // These are likely issue refs or social hashtags, not intended headings
691            if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
692                // Get first "word" of heading text (up to space, comma, or closing paren)
693                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                    // Skip if first word starts with lowercase or number
701                    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            // A setext heading's text is the whole paragraph its underline
718            // ends, and CommonMark strips the leading whitespace of every one
719            // of those lines, so each of them carries indentation of its own
720            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                // The underline is indented independently of the text
738                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        // Protect the indentation edits themselves: visible template headings
756        // can have Markdown indentation outside the template delimiters.
757        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    /// Get the category of this rule for selective processing
785    fn category(&self) -> RuleCategory {
786        RuleCategory::Heading
787    }
788
789    /// Check if this rule should be skipped
790    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
791        // Fast path: check if document likely has headings
792        if !ctx.likely_has_headings() {
793            return true;
794        }
795        // Verify headings actually exist
796        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        // Test with properly aligned headings
820        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        // Test with indented headings
826        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); // Should flag all three indented headings
830        assert_eq!(result[0].line, 1);
831        assert_eq!(result[1].line, 2);
832        assert_eq!(result[2].line, 3);
833
834        // Test with setext headings
835        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); // Should flag the indented heading and underline
839        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        // Issue refs should NOT be flagged (starts with number)
848        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        // Hashtags should NOT be flagged (starts with lowercase)
857        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        // A `#` run with no space after it is paragraph text in CommonMark,
866        // whatever follows it, so there is no heading to move left
867        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        // The same line with its space is an indented heading
878        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        // Properly aligned headings should pass
884        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        // A heading intentionally indented to stay nested
896        // inside a MkDocs admonition body must not be flagged, since the
897        // indentation is required for it to belong to the admonition.
898        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        // The same false positive occurs inside a MkDocs content tab body.
912        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        // Control: a heading indented outside any admonition/tab (accidental
926        // indentation, not container nesting) must still be flagged under
927        // MkDocs flavor, and its fix must still de-indent it.
928        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        // Control: standard flavor has no MkDocs container concept, so an
945        // indented heading is still an accidental indent and must still be
946        // flagged and de-indented by the fix. Uses 3 spaces: at 4+ spaces
947        // CommonMark parses the line as an indented code block rather than a
948        // heading at all, which is unrelated to this rule's guard.
949        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        // A markdown="1" HTML div is tag-scoped, not indentation-scoped:
966        // content needs no indentation to belong to it, so an indented
967        // heading inside one is an accidental indent in every flavor.
968        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/// A heading written inside a list item is indented by the item's own content
986/// column. Only the indentation past that column is the heading's, and removing
987/// the rest moves the heading out of the item.
988#[cfg(test)]
989mod list_item_tests {
990    use super::*;
991    use crate::config::MarkdownFlavor;
992    use crate::lint_context::LintContext;
993
994    /// The warnings for `content` and the document after the rule's fix.
995    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    /// Content the rule leaves alone: no warning and no change.
1004    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    /// Content the rule flags once per entry of `lines`, fixed to `expected`,
1011    /// and that stays fixed when fixed again.
1012    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        // Text between the item and the heading does not end the item.
1055        assert_fixed("- x\n  text\n    ## a\n", &[3], "- x\n  text\n  ## a\n");
1056        // A lazy continuation line holds the heading in the item.
1057        assert_clean("- x\nlazy\n  ## a\n");
1058        assert_clean("- x\n\n  a\nlazy\n  ---\n");
1059        // An item that opened empty is closed by the blank line, and the heading
1060        // belongs to the item around it, whose margin it keeps.
1061        assert_fixed("-\n  -\n\n    ## a\n", &[4], "-\n  -\n\n  ## a\n");
1062        // A paragraph that re-enters only the outer item closes the inner one.
1063        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        // Two tabs reach column 8, six past the item's edge: indented code.
1078        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        // Written under the content column of its item, a heading is outside it.
1086        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        // The fence line is text of the heading; unindented it would open a fence.
1095        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        // The shallow continuation line and the underline are still fixed.
1101        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    /// The lines `content` warns on, and the document after fixing it.
1186    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        // The heading is in the outer quote, outside the item: it is measured from the
1196        // outer quote's content edge, not moved to the item's column.
1197        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        // At the outer quote's edge: nothing to report.
1226        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        // The heading's quote is inside the item; its own content edge is the reference.
1232        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        // At the inner quote's edge: nothing to report.
1247        assert!(fixed_quoted("> - item\n>\n>   > ## H\n").0.is_empty());
1248        // An item held by the heading's own quote still owns its content column.
1249        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        // An item in the line's own quote, or in one inside it that the line may continue.
1260        assert_eq!(flags("> - a\n>   b\n"), [true, true]);
1261        assert_eq!(flags("> > - a\n> b\n"), [true, true]);
1262        // An item in an enclosing quote is not in the line's quote.
1263        assert_eq!(flags("> - a\n>   > b\n"), [true, false]);
1264        // A forgotten outer item stays possible around an indented inner quote.
1265        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        // A blank line at a shallower depth.
1271        assert_eq!(flags("> > - a\n>\n>    b\n"), [true, false, false]);
1272        // The blank line ends the inner quote; a quote opened after it holds no item.
1273        assert_eq!(flags("> > - a\n>\n> > b\n"), [true, false, false]);
1274        // A line after a blank line at the deeper depth.
1275        assert_eq!(flags("> > - a\n> >\n>    b\n"), [true, true, false]);
1276        // An ATX heading, a bullet item with text and `1.` item interrupt a paragraph.
1277        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        // Plain text, a `2.` item and an empty marker may be lazy continuation.
1281        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        // An item that opens with indented code is still an item.
1285        assert_eq!(flags("> > - a\n> -     code\n>    ## b\n"), [true, true, true]);
1286        // Four columns of indentation make it continuation text, not a block.
1287        assert_eq!(flags("> > - a\n>     ## b\n"), [true, true]);
1288        assert_eq!(flags("> > - a\n>     - b\n"), [true, true]);
1289        // Items on one line close together: `> - > - x` keeps the outer one.
1290        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}