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/// Calculate the effective indentation of a line within a blockquote context.
7///
8/// For lines inside blockquotes, the "raw" leading whitespace (before `>`) is always 0,
9/// but the semantically meaningful indent is the whitespace *after* the blockquote markers.
10///
11/// # Arguments
12///
13/// * `line_content` - The full line content including any blockquote markers
14/// * `expected_bq_level` - The blockquote nesting level to match (0 for no blockquote)
15/// * `fallback_indent` - The indent to return if blockquote levels don't match or if
16///   `expected_bq_level` is 0
17///
18/// # Returns
19///
20/// The effective indentation:
21/// - If `expected_bq_level` is 0: returns `fallback_indent`
22/// - If line's blockquote level matches `expected_bq_level`: returns indent after stripping markers
23/// - If blockquote levels don't match: returns `fallback_indent`
24///
25/// # Examples
26///
27/// ```
28/// use rumdl_lib::utils::blockquote::effective_indent_in_blockquote;
29///
30/// // Regular line (no blockquote context)
31/// assert_eq!(effective_indent_in_blockquote("   text", 0, 3), 3);
32///
33/// // Blockquote line with 2 spaces after marker
34/// assert_eq!(effective_indent_in_blockquote(">  text", 1, 0), 2);
35///
36/// // Nested blockquote with 3 spaces after markers
37/// assert_eq!(effective_indent_in_blockquote("> >   text", 2, 0), 3);
38///
39/// // Mismatched blockquote level - returns fallback
40/// assert_eq!(effective_indent_in_blockquote("> text", 2, 5), 5);
41/// ```
42pub fn effective_indent_in_blockquote(line_content: &str, expected_bq_level: usize, fallback_indent: usize) -> usize {
43    if expected_bq_level == 0 {
44        return fallback_indent;
45    }
46
47    // Count blockquote markers at the start of the line
48    // Markers can be separated by whitespace: "> > text" or ">> text"
49    let line_bq_level = line_content
50        .chars()
51        .take_while(|c| *c == '>' || c.is_whitespace())
52        .filter(|&c| c == '>')
53        .count();
54
55    if line_bq_level != expected_bq_level {
56        return fallback_indent;
57    }
58
59    // Strip blockquote markers and compute indent within the blockquote context
60    let mut pos = 0;
61    let mut found_markers = 0;
62    for c in line_content.chars() {
63        pos += c.len_utf8();
64        if c == '>' {
65            found_markers += 1;
66            if found_markers == line_bq_level {
67                // Skip optional space after final >
68                if line_content.get(pos..pos + 1) == Some(" ") {
69                    pos += 1;
70                }
71                break;
72            }
73        }
74    }
75
76    let after_bq = &line_content[pos..];
77    after_bq.len() - after_bq.trim_start().len()
78}
79
80/// Count the number of blockquote markers (`>`) at the start of a line.
81///
82/// Handles both compact (`>>text`) and spaced (`> > text`) blockquote syntax.
83///
84/// # Examples
85///
86/// ```
87/// use rumdl_lib::utils::blockquote::count_blockquote_level;
88///
89/// assert_eq!(count_blockquote_level("regular text"), 0);
90/// assert_eq!(count_blockquote_level("> quoted"), 1);
91/// assert_eq!(count_blockquote_level(">> nested"), 2);
92/// assert_eq!(count_blockquote_level("> > spaced nested"), 2);
93/// ```
94pub fn count_blockquote_level(line_content: &str) -> usize {
95    line_content
96        .chars()
97        .take_while(|c| *c == '>' || c.is_whitespace())
98        .filter(|&c| c == '>')
99        .count()
100}
101
102/// Extract the content after blockquote markers.
103///
104/// Returns the portion of the line after all blockquote markers and the
105/// optional space following the last marker.
106///
107/// # Examples
108///
109/// ```
110/// use rumdl_lib::utils::blockquote::content_after_blockquote;
111///
112/// assert_eq!(content_after_blockquote("> text", 1), "text");
113/// assert_eq!(content_after_blockquote(">  indented", 1), " indented");
114/// assert_eq!(content_after_blockquote("> > nested", 2), "nested");
115/// assert_eq!(content_after_blockquote("no quote", 0), "no quote");
116/// ```
117pub fn content_after_blockquote(line_content: &str, expected_bq_level: usize) -> &str {
118    if expected_bq_level == 0 {
119        return line_content;
120    }
121
122    // First, verify the line has the expected blockquote level
123    let actual_level = count_blockquote_level(line_content);
124    if actual_level != expected_bq_level {
125        return line_content;
126    }
127
128    let mut pos = 0;
129    let mut found_markers = 0;
130    for c in line_content.chars() {
131        pos += c.len_utf8();
132        if c == '>' {
133            found_markers += 1;
134            if found_markers == expected_bq_level {
135                // Skip optional space after final >
136                if line_content.get(pos..pos + 1) == Some(" ") {
137                    pos += 1;
138                }
139                break;
140            }
141        }
142    }
143
144    &line_content[pos..]
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    // ==========================================================================
152    // effective_indent_in_blockquote tests
153    // ==========================================================================
154
155    #[test]
156    fn test_effective_indent_no_blockquote_context() {
157        // When expected_bq_level is 0, always return fallback
158        assert_eq!(effective_indent_in_blockquote("text", 0, 0), 0);
159        assert_eq!(effective_indent_in_blockquote("   text", 0, 3), 3);
160        assert_eq!(effective_indent_in_blockquote("> text", 0, 5), 5);
161    }
162
163    #[test]
164    fn test_effective_indent_single_level_blockquote() {
165        // Single > with various indents after
166        assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 0);
167        assert_eq!(effective_indent_in_blockquote(">  text", 1, 99), 1);
168        assert_eq!(effective_indent_in_blockquote(">   text", 1, 99), 2);
169        assert_eq!(effective_indent_in_blockquote(">    text", 1, 99), 3);
170    }
171
172    #[test]
173    fn test_effective_indent_no_space_after_marker() {
174        // >text (no space after >) - should have 0 effective indent
175        assert_eq!(effective_indent_in_blockquote(">text", 1, 99), 0);
176        assert_eq!(effective_indent_in_blockquote(">>text", 2, 99), 0);
177    }
178
179    #[test]
180    fn test_effective_indent_nested_blockquote_compact() {
181        // Compact nested: >>text, >> text, >>  text
182        assert_eq!(effective_indent_in_blockquote(">> text", 2, 99), 0);
183        assert_eq!(effective_indent_in_blockquote(">>  text", 2, 99), 1);
184        assert_eq!(effective_indent_in_blockquote(">>   text", 2, 99), 2);
185    }
186
187    #[test]
188    fn test_effective_indent_nested_blockquote_spaced() {
189        // Spaced nested: > > text, > >  text
190        assert_eq!(effective_indent_in_blockquote("> > text", 2, 99), 0);
191        assert_eq!(effective_indent_in_blockquote("> >  text", 2, 99), 1);
192        assert_eq!(effective_indent_in_blockquote("> >   text", 2, 99), 2);
193    }
194
195    #[test]
196    fn test_effective_indent_mismatched_level() {
197        // Line has different blockquote level than expected - return fallback
198        assert_eq!(effective_indent_in_blockquote("> text", 2, 42), 42);
199        assert_eq!(effective_indent_in_blockquote(">> text", 1, 42), 42);
200        assert_eq!(effective_indent_in_blockquote("text", 1, 42), 42);
201    }
202
203    #[test]
204    fn test_effective_indent_empty_blockquote() {
205        // Empty blockquote lines
206        assert_eq!(effective_indent_in_blockquote(">", 1, 99), 0);
207        assert_eq!(effective_indent_in_blockquote("> ", 1, 99), 0);
208        assert_eq!(effective_indent_in_blockquote(">  ", 1, 99), 1);
209    }
210
211    #[test]
212    fn test_effective_indent_issue_268_case() {
213        // The exact pattern from issue #268:
214        // ">   text" where we expect 2 spaces of indent (list continuation)
215        assert_eq!(effective_indent_in_blockquote(">   Opening the app", 1, 0), 2);
216        assert_eq!(
217            effective_indent_in_blockquote(">   [**See preview here!**](https://example.com)", 1, 0),
218            2
219        );
220    }
221
222    #[test]
223    fn test_effective_indent_triple_nested() {
224        // Triple nested blockquotes
225        assert_eq!(effective_indent_in_blockquote("> > > text", 3, 99), 0);
226        assert_eq!(effective_indent_in_blockquote("> > >  text", 3, 99), 1);
227        assert_eq!(effective_indent_in_blockquote(">>> text", 3, 99), 0);
228        assert_eq!(effective_indent_in_blockquote(">>>  text", 3, 99), 1);
229    }
230
231    // ==========================================================================
232    // count_blockquote_level tests
233    // ==========================================================================
234
235    #[test]
236    fn test_count_blockquote_level_none() {
237        assert_eq!(count_blockquote_level("regular text"), 0);
238        assert_eq!(count_blockquote_level("   indented text"), 0);
239        assert_eq!(count_blockquote_level(""), 0);
240    }
241
242    #[test]
243    fn test_count_blockquote_level_single() {
244        assert_eq!(count_blockquote_level("> text"), 1);
245        assert_eq!(count_blockquote_level(">text"), 1);
246        assert_eq!(count_blockquote_level(">"), 1);
247    }
248
249    #[test]
250    fn test_count_blockquote_level_nested() {
251        assert_eq!(count_blockquote_level(">> text"), 2);
252        assert_eq!(count_blockquote_level("> > text"), 2);
253        assert_eq!(count_blockquote_level(">>> text"), 3);
254        assert_eq!(count_blockquote_level("> > > text"), 3);
255    }
256
257    // ==========================================================================
258    // content_after_blockquote tests
259    // ==========================================================================
260
261    #[test]
262    fn test_content_after_blockquote_no_quote() {
263        assert_eq!(content_after_blockquote("text", 0), "text");
264        assert_eq!(content_after_blockquote("   indented", 0), "   indented");
265    }
266
267    #[test]
268    fn test_content_after_blockquote_single() {
269        assert_eq!(content_after_blockquote("> text", 1), "text");
270        assert_eq!(content_after_blockquote(">text", 1), "text");
271        assert_eq!(content_after_blockquote(">  indented", 1), " indented");
272    }
273
274    #[test]
275    fn test_content_after_blockquote_nested() {
276        assert_eq!(content_after_blockquote(">> text", 2), "text");
277        assert_eq!(content_after_blockquote("> > text", 2), "text");
278        assert_eq!(content_after_blockquote("> >  indented", 2), " indented");
279    }
280
281    #[test]
282    fn test_content_after_blockquote_mismatched_level() {
283        // If level doesn't match, return original
284        assert_eq!(content_after_blockquote("> text", 2), "> text");
285        assert_eq!(content_after_blockquote(">> text", 1), ">> text");
286    }
287}