Skip to main content

rumdl_lib/utils/
blockquote.rs

1//! Blockquote-related utilities for rumdl.
2//!
3//! Provides functions for working with blockquote-prefixed lines, including
4//! calculating effective indentation within blockquote context.
5
6/// Parsed blockquote prefix components.
7///
8/// This parser supports both compact (`>> text`) and spaced (`> > text`) nesting.
9/// It preserves at most one optional space/tab after the final marker in `prefix`;
10/// additional whitespace is left in `content`.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct ParsedBlockquotePrefix<'a> {
13    /// Leading indentation before the first marker.
14    pub indent: &'a str,
15    /// Prefix up to (and including) the optional single space/tab after final `>`.
16    pub prefix: &'a str,
17    /// Content after `prefix` (may begin with additional spaces/tabs).
18    pub content: &'a str,
19    /// Number of blockquote markers at the start of the line.
20    pub nesting_level: usize,
21    /// All whitespace after the final `>` (including the optional first space/tab).
22    pub spaces_after_marker: &'a str,
23}
24
25#[inline]
26fn is_space_or_tab(byte: u8) -> bool {
27    byte == b' ' || byte == b'\t'
28}
29
30/// Parse a blockquote prefix from a line.
31///
32/// Returns `None` when the line does not start (after leading indentation) with
33/// a blockquote marker.
34#[inline]
35pub fn parse_blockquote_prefix(line: &str) -> Option<ParsedBlockquotePrefix<'_>> {
36    let bytes = line.as_bytes();
37    let mut pos = 0;
38
39    // Leading indentation before the first marker.
40    while pos < bytes.len() && is_space_or_tab(bytes[pos]) {
41        pos += 1;
42    }
43    let indent_end = pos;
44
45    if pos >= bytes.len() || bytes[pos] != b'>' {
46        return None;
47    }
48
49    let mut nesting_level = 0;
50    let mut prefix_end = pos;
51    let mut spaces_after_marker_start = pos;
52    let mut spaces_after_marker_end = pos;
53
54    loop {
55        if pos >= bytes.len() || bytes[pos] != b'>' {
56            break;
57        }
58
59        nesting_level += 1;
60        pos += 1; // past '>'
61        let marker_end = pos;
62
63        // Include at most one optional space/tab in the preserved prefix.
64        if pos < bytes.len() && is_space_or_tab(bytes[pos]) {
65            pos += 1;
66        }
67        let content_start_candidate = pos;
68
69        // Consume any additional whitespace.
70        while pos < bytes.len() && is_space_or_tab(bytes[pos]) {
71            pos += 1;
72        }
73
74        // If another marker follows, all consumed whitespace belongs to nesting.
75        if pos < bytes.len() && bytes[pos] == b'>' {
76            continue;
77        }
78
79        // Final marker.
80        prefix_end = content_start_candidate;
81        spaces_after_marker_start = marker_end;
82        spaces_after_marker_end = pos;
83        break;
84    }
85
86    Some(ParsedBlockquotePrefix {
87        indent: &line[..indent_end],
88        prefix: &line[..prefix_end],
89        content: &line[prefix_end..],
90        nesting_level,
91        spaces_after_marker: &line[spaces_after_marker_start..spaces_after_marker_end],
92    })
93}
94
95/// Calculate the effective indentation of a line within a blockquote context.
96///
97/// For lines inside blockquotes, the "raw" leading whitespace (before `>`) is always 0,
98/// but the semantically meaningful indent is the whitespace *after* the blockquote markers.
99///
100/// # Arguments
101///
102/// * `line_content` - The full line content including any blockquote markers
103/// * `expected_bq_level` - The blockquote nesting level to match (0 for no blockquote)
104/// * `fallback_indent` - The indent to return if blockquote levels don't match or if
105///   `expected_bq_level` is 0
106///
107/// # Returns
108///
109/// The effective indentation:
110/// - If `expected_bq_level` is 0: returns `fallback_indent`
111/// - If line's blockquote level matches `expected_bq_level`: returns indent after stripping markers
112/// - If blockquote levels don't match: returns `fallback_indent`
113///
114/// # Examples
115///
116/// ```
117/// use rumdl_lib::utils::blockquote::effective_indent_in_blockquote;
118///
119/// // Regular line (no blockquote context)
120/// assert_eq!(effective_indent_in_blockquote("   text", 0, 3), 3);
121///
122/// // Blockquote with 2 spaces after marker (first space consumed as marker syntax)
123/// assert_eq!(effective_indent_in_blockquote(">  text", 1, 0), 1);
124///
125/// // Nested blockquote with 3 spaces after last marker (first space consumed as marker syntax)
126/// assert_eq!(effective_indent_in_blockquote("> >   text", 2, 0), 2);
127///
128/// // Mismatched blockquote level - returns fallback
129/// assert_eq!(effective_indent_in_blockquote("> text", 2, 5), 5);
130/// ```
131pub fn effective_indent_in_blockquote(line_content: &str, expected_bq_level: usize, fallback_indent: usize) -> usize {
132    if expected_bq_level == 0 {
133        return fallback_indent;
134    }
135
136    // Count blockquote markers at the start of the line
137    // Markers can be separated by whitespace: "> > text" or ">> text"
138    let line_bq_level = line_content
139        .chars()
140        .take_while(|c| *c == '>' || c.is_whitespace())
141        .filter(|&c| c == '>')
142        .count();
143
144    if line_bq_level != expected_bq_level {
145        return fallback_indent;
146    }
147
148    // Strip blockquote markers and compute indent within the blockquote context
149    let mut pos = 0;
150    let mut found_markers = 0;
151    for c in line_content.chars() {
152        pos += c.len_utf8();
153        if c == '>' {
154            found_markers += 1;
155            if found_markers == line_bq_level {
156                // Skip optional space after final >
157                if line_content.get(pos..pos + 1) == Some(" ") {
158                    pos += 1;
159                }
160                break;
161            }
162        }
163    }
164
165    let after_bq = &line_content[pos..];
166    after_bq.len() - after_bq.trim_start().len()
167}
168
169/// Count the number of blockquote markers (`>`) at the start of a line.
170///
171/// Handles both compact (`>>text`) and spaced (`> > text`) blockquote syntax.
172fn count_blockquote_level(line_content: &str) -> usize {
173    line_content
174        .chars()
175        .take_while(|c| *c == '>' || c.is_whitespace())
176        .filter(|&c| c == '>')
177        .count()
178}
179
180/// Extract the content after blockquote markers.
181///
182/// Returns the portion of the line after all blockquote markers and the
183/// optional space following the last marker.
184///
185/// # Examples
186///
187/// ```
188/// use rumdl_lib::utils::blockquote::content_after_blockquote;
189///
190/// assert_eq!(content_after_blockquote("> text", 1), "text");
191/// assert_eq!(content_after_blockquote(">  indented", 1), " indented");
192/// assert_eq!(content_after_blockquote("> > nested", 2), "nested");
193/// assert_eq!(content_after_blockquote("no quote", 0), "no quote");
194/// ```
195pub fn content_after_blockquote(line_content: &str, expected_bq_level: usize) -> &str {
196    if expected_bq_level == 0 {
197        return line_content;
198    }
199
200    // First, verify the line has the expected blockquote level
201    let actual_level = count_blockquote_level(line_content);
202    if actual_level != expected_bq_level {
203        return line_content;
204    }
205
206    let mut pos = 0;
207    let mut found_markers = 0;
208    for c in line_content.chars() {
209        pos += c.len_utf8();
210        if c == '>' {
211            found_markers += 1;
212            if found_markers == expected_bq_level {
213                // Skip optional space after final >
214                if line_content.get(pos..pos + 1) == Some(" ") {
215                    pos += 1;
216                }
217                break;
218            }
219        }
220    }
221
222    &line_content[pos..]
223}
224
225/// Strip all blockquote markers from a line, returning the content after them.
226///
227/// Unlike [`content_after_blockquote`], this does not require knowing the
228/// expected nesting level — it strips all `>` markers and their trailing spaces.
229/// Returns the original line unchanged if it has no blockquote markers.
230///
231/// # Examples
232///
233/// ```
234/// use rumdl_lib::utils::blockquote::strip_blockquote_prefix;
235///
236/// assert_eq!(strip_blockquote_prefix("> text"), "text");
237/// assert_eq!(strip_blockquote_prefix("> > nested"), "nested");
238/// assert_eq!(strip_blockquote_prefix(">>compact"), "compact");
239/// assert_eq!(strip_blockquote_prefix("  > indented"), "indented");
240/// assert_eq!(strip_blockquote_prefix("no quote"), "no quote");
241/// ```
242pub fn strip_blockquote_prefix(line: &str) -> &str {
243    match parse_blockquote_prefix(line) {
244        Some(parsed) => parsed.content,
245        None => line,
246    }
247}
248
249/// The columns of whitespace `line` starts with, a tab running to the next multiple of four.
250fn indent_columns(line: &str) -> usize {
251    let mut column = 0;
252    for c in line.chars() {
253        match c {
254            ' ' => column += 1,
255            '\t' => column += 4 - column % 4,
256            _ => break,
257        }
258    }
259    column
260}
261
262/// The length of the list marker `text` opens with (`-`, `+`, `*`, or up to nine digits
263/// then `.` or `)`), when a space, a tab or the end of the text follows it.
264fn list_marker_len(text: &str) -> Option<usize> {
265    let token = text.split([' ', '\t']).next().unwrap_or("");
266    let digits = token.trim_end_matches(['.', ')']);
267    let is_marker = matches!(token, "-" | "+" | "*")
268        || (token.len() == digits.len() + 1
269            && (1..=9).contains(&digits.len())
270            && digits.bytes().all(|b| b.is_ascii_digit()));
271    is_marker.then_some(token.len())
272}
273
274/// The visual column of byte `offset` in `line`, a tab running to the next multiple of four.
275fn column_at(line: &str, offset: usize) -> usize {
276    line[..offset].chars().fold(0, |column, c| match c {
277        '\t' => column + 4 - column % 4,
278        _ => column + 1,
279    })
280}
281
282/// For a line that opens list items from `from` on (the byte offset after any quote
283/// markers), the byte offset where the text of a quote opened inside them begins
284/// (`- > text`, `1. - > text`, after the marker and its optional space), or `None` when
285/// the items' content is not a quote. A list marker followed by five or more columns of
286/// whitespace holds indented code, and a quote marker written four columns past the
287/// marker before it is text.
288pub fn quote_text_start_in_items(line: &str, from: usize) -> Option<usize> {
289    let mut offset = from;
290    let mut opened_item = false;
291    // The visual column of `offset`, carried forward so tabs are measured at their real
292    // tab stops without recounting the line for every nested marker.
293    let mut column = column_at(line, from);
294    loop {
295        let rest = &line[offset..];
296        let text = rest.trim_start_matches([' ', '\t']);
297        let text_column = rest[..rest.len() - text.len()].chars().fold(column, |column, c| {
298            if c == '\t' { column + 4 - column % 4 } else { column + 1 }
299        });
300        // The first marker may be indented up to three columns; the content of an item
301        // starts within four columns after its marker, or is code.
302        if text_column - column >= if opened_item { 5 } else { 4 } {
303            return None;
304        }
305        if let Some(marker_len) = list_marker_len(text).filter(|&len| text.len() > len) {
306            offset += rest.len() - text.len() + marker_len;
307            column = text_column + marker_len;
308            opened_item = true;
309            continue;
310        }
311        if !opened_item {
312            return None;
313        }
314        let parsed = parse_blockquote_prefix(rest)?;
315        if !quote_markers_are_real(rest) {
316            return None;
317        }
318        return Some(line.len() - parsed.content.len());
319    }
320}
321
322/// The content columns of the list items an item line opens when `line` carries no
323/// chain from the container pass (an item opened in a quote): the item's own, then
324/// each item written on the same line inside it (`> - - x`).
325fn written_item_columns(line: &str, content_column: usize, bare: bool) -> Vec<usize> {
326    let mut columns = vec![content_column + usize::from(bare)];
327    if bare {
328        return columns;
329    }
330    // One forward pass: the visual column is carried along instead of recounted from the
331    // start of the line for every offset.
332    let mut position = 0;
333    let mut column = 0;
334    let mut column_at_forward = |target: usize| -> usize {
335        for c in line[position..target].chars() {
336            column += if c == '\t' { 4 - column % 4 } else { 1 };
337        }
338        position = target;
339        column
340    };
341    let Some(mut offset) = line
342        .char_indices()
343        .map(|(index, _)| index)
344        .chain(std::iter::once(line.len()))
345        .find(|&index| column_at_forward(index) >= content_column)
346    else {
347        return columns;
348    };
349    while let Some(marker_len) =
350        list_marker_len(&line[offset..]).filter(|_| !crate::utils::thematic_break::is_thematic_break(&line[offset..]))
351    {
352        let after_marker = offset + marker_len;
353        let spaces = line[after_marker..].len() - line[after_marker..].trim_start_matches([' ', '\t']).len();
354        let marker_end = column_at_forward(after_marker);
355        let rest_blank = line[after_marker + spaces..].trim().is_empty();
356        let spaces_columns = column_at_forward(after_marker + spaces) - marker_end;
357        if rest_blank || spaces_columns >= 5 {
358            columns.push(marker_end + 1);
359            break;
360        }
361        columns.push(marker_end + spaces_columns);
362        offset = after_marker + spaces;
363    }
364    columns
365}
366
367/// Whether the quote markers of `line` (zero-based `line_num`) are text continuing the
368/// paragraph above rather than markers: the line sits four columns or more past the edge
369/// of the container it is written in (`edges`, from `container_edges`), straight under a
370/// line of text.
371pub fn quote_markers_are_paragraph_text(
372    ctx: &crate::lint_context::LintContext,
373    edges: &[usize],
374    line_num: usize,
375    line: &str,
376) -> bool {
377    line_num > 0 && !ctx.lines[line_num - 1].is_blank && indent_columns(line) >= edges[line_num] + 4
378}
379
380/// The column each line's container starts its content at, indexed by zero-based line:
381/// zero outside a list, and the content column of the innermost list item still open at
382/// the line inside one. An item stays open through blank lines and ends at the first line
383/// written left of its content column, or at a sibling or outer item marker. For a line
384/// that opens an item, the edge is that of the container the item is written in.
385///
386/// An item opened in a quote is measured against the text after the quote markers on
387/// quoted lines, and ends at the first unquoted line.
388pub fn container_edges(ctx: &crate::lint_context::LintContext) -> Vec<usize> {
389    container_edges_with_margins(ctx)
390        .into_iter()
391        .map(|(edge, _)| edge)
392        .collect()
393}
394
395/// `container_edges`, with for each line the width of the quote markers (and the optional
396/// space after them) in front of the line that opened the innermost open item. The edge
397/// less this margin is the item's content column measured from the text after the
398/// markers, which is how a quoted line is compared when its markers are written with a
399/// different width (`>> - x` then `> > y`).
400///
401/// The margin is zero, meaning the edge is not to be trusted past the quote markers, for
402/// an item opened outside a quote or in front of a tab, for a line inside a quote nested
403/// deeper than the item's (its markers already consume the item's indentation), and after
404/// a quoted line that ended inner items while continuing a paragraph (a possible lazy
405/// continuation).
406pub fn container_edges_with_margins(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
407    // Open items, outermost first: content column, whether the item opened in a quote
408    // the width of the quote markers in front of it and how many quotes deep it opened.
409    let mut open: Vec<(usize, bool, usize, usize)> = Vec::new();
410    let mut edges = Vec::with_capacity(ctx.lines.len());
411    // Set when a quoted line ended inner items while continuing a paragraph: it may be a lazy
412    // continuation of one of them, so the surviving edge no longer says where later lines belong.
413    let mut lazy_taint = false;
414    for (index, info) in ctx.lines.iter().enumerate() {
415        let depth = info.blockquote.as_ref().map_or(0, |quote| quote.nesting_level);
416        if !info.is_blank {
417            let line = info.content(ctx.content);
418            let quoted = info.blockquote.is_some();
419            let margin = match parse_blockquote_prefix(line) {
420                // A tab on the opener line makes its columns depend on the tab stop, so the
421                // margin is not known and the item is not measured past its quote.
422                Some(parsed) if quoted && !line.contains('\t') => column_at(line, parsed.prefix.len()),
423                _ => 0,
424            };
425            // The column a line is measured at against an open item: past the quote
426            // markers when both the item and the line are quoted.
427            let measured = |opened_quoted: bool,
428                            opened_margin: usize,
429                            opened_depth: usize,
430                            content: usize,
431                            column: usize,
432                            raw_column: usize|
433             -> bool {
434                if opened_quoted && quoted {
435                    // Leaving the quote an item was opened in ends the item.
436                    depth < opened_depth || content.saturating_sub(opened_margin) > column.saturating_sub(margin)
437                } else {
438                    content > raw_column
439                }
440            };
441            match info.list_item.as_deref() {
442                Some(item) => {
443                    while open.last().is_some_and(|&(content, oq, om, od)| {
444                        measured(oq, om, od, content, item.marker_column, item.marker_column)
445                    }) {
446                        open.pop();
447                    }
448                    edges.push(open.last().map_or((0, 0), |&(content, _, om, od)| {
449                        (content, if depth > od || lazy_taint { 0 } else { om })
450                    }));
451                    // An item with nothing after its marker takes its content from the
452                    // lines below, which have to clear the marker and the space after it.
453                    let bare = line.trim_end().chars().count() <= item.content_column;
454                    let columns = if item.commonmark_items.is_empty() {
455                        written_item_columns(line, item.content_column, bare)
456                    } else {
457                        item.commonmark_items.iter().map(|item| item.content_column).collect()
458                    };
459                    // An item inside an unquoted item is compared by raw indentation: its quote
460                    // markers sit in the outer item's content, so margins do not line up.
461                    let relative = quoted && open.iter().all(|&(_, oq, _, _)| oq);
462                    open.extend(columns.into_iter().map(|content| (content, relative, margin, depth)));
463                    lazy_taint = false;
464                    continue;
465                }
466                None => {
467                    let column = match parse_blockquote_prefix(line) {
468                        Some(parsed) if quoted => column_at(line, parsed.prefix.len()) + indent_columns(parsed.content),
469                        _ => indent_columns(line),
470                    };
471                    let before = open.len();
472                    while open.last().is_some_and(|&(content, oq, om, od)| {
473                        measured(oq, om, od, content, column, indent_columns(line))
474                    }) {
475                        open.pop();
476                    }
477                    if index > 0 && !ctx.lines[index - 1].is_blank && open.len() < before {
478                        lazy_taint = true;
479                    }
480                    if open.is_empty() {
481                        lazy_taint = false;
482                    }
483                }
484            }
485        }
486        edges.push(open.last().map_or((0, 0), |&(content, _, om, od)| {
487            (content, if depth > od || lazy_taint { 0 } else { om })
488        }));
489    }
490    edges
491}
492
493/// Whether the unquoted `line` at zero-based `line_num` is indented four columns or more
494/// past the edge of its container (`container_edges`), which makes it indented code rather
495/// than a heading. `10. x` followed by a heading at column 6 is still a heading.
496pub fn is_indented_past_edge(edges: &[usize], line_num: usize, line: &str) -> bool {
497    indent_columns(line) >= edges[line_num] + 4
498}
499
500/// Whether every quote marker opening `line` opens a quote: a marker written
501/// four columns or more past the optional space after the one before it is text
502/// of the quote around it. Tabs advance to the next multiple of four.
503pub fn quote_markers_are_real(line: &str) -> bool {
504    let mut column = 0;
505    // Column just past the previous marker, once a marker has been seen.
506    let mut after_marker: Option<usize> = None;
507    for c in line.chars() {
508        match c {
509            ' ' => column += 1,
510            '\t' => column += 4 - column % 4,
511            '>' => {
512                if after_marker.is_some_and(|end| column >= end + 1 + 4) {
513                    return false;
514                }
515                column += 1;
516                after_marker = Some(column);
517            }
518            _ => break,
519        }
520    }
521    true
522}
523
524/// The length of the quote markers opening `line`, up to `depth` of them, with
525/// the one optional space or tab after the last. Markers past `depth` are text
526/// of the heading (`>     > x` in a setext paragraph), not quotes. `None` for a
527/// line without any marker.
528pub fn quote_margin_len(line: &str, depth: usize) -> Option<usize> {
529    let mut pos = 0;
530    let mut markers = 0;
531    while markers < depth {
532        let rest = &line[pos..];
533        let next = rest.trim_start_matches([' ', '\t']);
534        if !next.starts_with('>') {
535            break;
536        }
537        pos += rest.len() - next.len() + 1;
538        markers += 1;
539    }
540    if markers == 0 {
541        return None;
542    }
543    Some(pos + usize::from(line[pos..].starts_with([' ', '\t'])))
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    // ==========================================================================
551    // parse_blockquote_prefix tests
552    // ==========================================================================
553
554    #[test]
555    fn test_parse_blockquote_prefix_compact_nested() {
556        let parsed = parse_blockquote_prefix(">> text").expect("should parse compact nested blockquote");
557        assert_eq!(parsed.indent, "");
558        assert_eq!(parsed.prefix, ">> ");
559        assert_eq!(parsed.content, "text");
560        assert_eq!(parsed.nesting_level, 2);
561        assert_eq!(parsed.spaces_after_marker, " ");
562    }
563
564    #[test]
565    fn test_parse_blockquote_prefix_spaced_nested() {
566        let parsed = parse_blockquote_prefix("> >  text").expect("should parse spaced nested blockquote");
567        assert_eq!(parsed.indent, "");
568        assert_eq!(parsed.prefix, "> > ");
569        assert_eq!(parsed.content, " text");
570        assert_eq!(parsed.nesting_level, 2);
571        assert_eq!(parsed.spaces_after_marker, "  ");
572    }
573
574    #[test]
575    fn test_parse_blockquote_prefix_with_indent() {
576        let parsed = parse_blockquote_prefix("   > quote").expect("should parse indented blockquote");
577        assert_eq!(parsed.indent, "   ");
578        assert_eq!(parsed.prefix, "   > ");
579        assert_eq!(parsed.content, "quote");
580        assert_eq!(parsed.nesting_level, 1);
581        assert_eq!(parsed.spaces_after_marker, " ");
582    }
583
584    #[test]
585    fn test_parse_blockquote_prefix_non_blockquote() {
586        assert!(parse_blockquote_prefix("plain text").is_none());
587        assert!(parse_blockquote_prefix("  plain text").is_none());
588    }
589
590    // ==========================================================================
591    // effective_indent_in_blockquote tests
592    // ==========================================================================
593
594    #[test]
595    fn test_effective_indent_no_blockquote_context() {
596        // When expected_bq_level is 0, always return fallback
597        assert_eq!(effective_indent_in_blockquote("text", 0, 0), 0);
598        assert_eq!(effective_indent_in_blockquote("   text", 0, 3), 3);
599        assert_eq!(effective_indent_in_blockquote("> text", 0, 5), 5);
600    }
601
602    #[test]
603    fn test_effective_indent_single_level_blockquote() {
604        // Single > with various indents after
605        assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 0);
606        assert_eq!(effective_indent_in_blockquote(">  text", 1, 99), 1);
607        assert_eq!(effective_indent_in_blockquote(">   text", 1, 99), 2);
608        assert_eq!(effective_indent_in_blockquote(">    text", 1, 99), 3);
609    }
610
611    #[test]
612    fn test_effective_indent_no_space_after_marker() {
613        // >text (no space after >) - should have 0 effective indent
614        assert_eq!(effective_indent_in_blockquote(">text", 1, 99), 0);
615        assert_eq!(effective_indent_in_blockquote(">>text", 2, 99), 0);
616    }
617
618    #[test]
619    fn test_effective_indent_nested_blockquote_compact() {
620        // Compact nested: >>text, >> text, >>  text
621        assert_eq!(effective_indent_in_blockquote(">> text", 2, 99), 0);
622        assert_eq!(effective_indent_in_blockquote(">>  text", 2, 99), 1);
623        assert_eq!(effective_indent_in_blockquote(">>   text", 2, 99), 2);
624    }
625
626    #[test]
627    fn test_effective_indent_nested_blockquote_spaced() {
628        // Spaced nested: > > text, > >  text
629        assert_eq!(effective_indent_in_blockquote("> > text", 2, 99), 0);
630        assert_eq!(effective_indent_in_blockquote("> >  text", 2, 99), 1);
631        assert_eq!(effective_indent_in_blockquote("> >   text", 2, 99), 2);
632    }
633
634    #[test]
635    fn test_effective_indent_mismatched_level() {
636        // Line has different blockquote level than expected - return fallback
637        assert_eq!(effective_indent_in_blockquote("> text", 2, 42), 42);
638        assert_eq!(effective_indent_in_blockquote(">> text", 1, 42), 42);
639        assert_eq!(effective_indent_in_blockquote("text", 1, 42), 42);
640    }
641
642    #[test]
643    fn test_effective_indent_empty_blockquote() {
644        // Empty blockquote lines
645        assert_eq!(effective_indent_in_blockquote(">", 1, 99), 0);
646        assert_eq!(effective_indent_in_blockquote("> ", 1, 99), 0);
647        assert_eq!(effective_indent_in_blockquote(">  ", 1, 99), 1);
648    }
649
650    #[test]
651    fn test_effective_indent_issue_268_case() {
652        // The exact pattern from issue #268:
653        // ">   text" where we expect 2 spaces of indent (list continuation)
654        assert_eq!(effective_indent_in_blockquote(">   Opening the app", 1, 0), 2);
655        assert_eq!(
656            effective_indent_in_blockquote(">   [**See preview here!**](https://example.com)", 1, 0),
657            2
658        );
659    }
660
661    #[test]
662    fn test_effective_indent_triple_nested() {
663        // Triple nested blockquotes
664        assert_eq!(effective_indent_in_blockquote("> > > text", 3, 99), 0);
665        assert_eq!(effective_indent_in_blockquote("> > >  text", 3, 99), 1);
666        assert_eq!(effective_indent_in_blockquote(">>> text", 3, 99), 0);
667        assert_eq!(effective_indent_in_blockquote(">>>  text", 3, 99), 1);
668    }
669
670    // ==========================================================================
671    // count_blockquote_level tests
672    // ==========================================================================
673
674    #[test]
675    fn test_count_blockquote_level_none() {
676        assert_eq!(count_blockquote_level("regular text"), 0);
677        assert_eq!(count_blockquote_level("   indented text"), 0);
678        assert_eq!(count_blockquote_level(""), 0);
679    }
680
681    #[test]
682    fn test_count_blockquote_level_single() {
683        assert_eq!(count_blockquote_level("> text"), 1);
684        assert_eq!(count_blockquote_level(">text"), 1);
685        assert_eq!(count_blockquote_level(">"), 1);
686    }
687
688    #[test]
689    fn test_count_blockquote_level_nested() {
690        assert_eq!(count_blockquote_level(">> text"), 2);
691        assert_eq!(count_blockquote_level("> > text"), 2);
692        assert_eq!(count_blockquote_level(">>> text"), 3);
693        assert_eq!(count_blockquote_level("> > > text"), 3);
694    }
695
696    // ==========================================================================
697    // content_after_blockquote tests
698    // ==========================================================================
699
700    #[test]
701    fn test_content_after_blockquote_no_quote() {
702        assert_eq!(content_after_blockquote("text", 0), "text");
703        assert_eq!(content_after_blockquote("   indented", 0), "   indented");
704    }
705
706    #[test]
707    fn test_content_after_blockquote_single() {
708        assert_eq!(content_after_blockquote("> text", 1), "text");
709        assert_eq!(content_after_blockquote(">text", 1), "text");
710        assert_eq!(content_after_blockquote(">  indented", 1), " indented");
711    }
712
713    #[test]
714    fn test_content_after_blockquote_nested() {
715        assert_eq!(content_after_blockquote(">> text", 2), "text");
716        assert_eq!(content_after_blockquote("> > text", 2), "text");
717        assert_eq!(content_after_blockquote("> >  indented", 2), " indented");
718    }
719
720    #[test]
721    fn test_content_after_blockquote_mismatched_level() {
722        // If level doesn't match, return original
723        assert_eq!(content_after_blockquote("> text", 2), "> text");
724        assert_eq!(content_after_blockquote(">> text", 1), ">> text");
725    }
726}