Skip to main content

rsmarkdown_core/
blocks.rs

1//! Block segmentation, ported from `markmend/core/src/preprocess/vendored/parse-blocks.ts`
2//! (itself ported from vercel/streamdown). Splits markdown into stable blocks so only
3//! the trailing block needs re-parsing as content streams in.
4
5use crate::fix::{has_footnote_definition, has_footnote_reference};
6use crate::scan::{is_ws, js_trim};
7
8/// Does the (trimmed) line start a standalone block boundary that marked's Lexer
9/// would split into its own token?
10fn starts_block(line: &str) -> bool {
11    let t = line.trim_start_matches(is_ws);
12    let mut chars = t.chars();
13    match chars.next() {
14        Some('#') => {
15            // ATX heading: `#{1,6} ` or bare heading-like
16            let count = t.chars().take_while(|c| *c == '#').count();
17            (1..=6).contains(&count)
18                && t[count..]
19                    .chars()
20                    .next()
21                    .is_some_and(|c| c == ' ' || is_ws(c))
22        }
23        Some('>') => true,             // blockquote
24        Some('<' | '!' | '?') => true, // html / comments
25        Some('`') => true,             // fence handled separately
26        _ => false,
27    }
28}
29
30/// Tokenize markdown into "tokens" the way marked's `Lexer.lex` does at the block
31/// level: blank-line-separated runs, with fenced code blocks kept whole.
32fn lex_tokens(markdown: &str) -> Vec<&str> {
33    let mut tokens: Vec<&str> = Vec::new();
34    let lines: Vec<&str> = markdown.split('\n').collect();
35    let mut token_start: Option<usize> = None; // byte offset of current token
36    let mut token_line_start = 0usize; // index of first line of current token
37    let mut in_fence = false;
38
39    fn flush<'a>(
40        tokens: &mut Vec<&'a str>,
41        token_start: &mut Option<usize>,
42        markdown: &'a str,
43        start_byte: usize,
44        end_byte: usize,
45    ) {
46        if let Some(start) = *token_start {
47            tokens.push(&markdown[start..end_byte]);
48        }
49        *token_start = if start_byte < end_byte {
50            Some(start_byte)
51        } else {
52            None
53        };
54    }
55
56    let mut byte_offset = 0usize;
57    for (line_idx, line) in lines.iter().enumerate() {
58        let line_len = line.len() + 1; // +1 for '\n'
59        let is_blank = line.trim().is_empty();
60
61        if in_fence {
62            // inside a fence: token continues; fence closes on a ``` line
63            if js_trim(line).starts_with("```") {
64                in_fence = false;
65            }
66            byte_offset += line_len;
67            continue;
68        }
69
70        if is_blank {
71            flush(
72                &mut tokens,
73                &mut token_start,
74                markdown,
75                byte_offset,
76                byte_offset,
77            );
78            token_line_start = line_idx + 1;
79            byte_offset += line_len;
80            continue;
81        }
82
83        let starts_fence = js_trim(line).starts_with("```");
84        if starts_fence {
85            flush(
86                &mut tokens,
87                &mut token_start,
88                markdown,
89                byte_offset,
90                byte_offset,
91            );
92            token_line_start = line_idx;
93            token_start = Some(byte_offset);
94            in_fence = true;
95            byte_offset += line_len;
96            continue;
97        }
98
99        // block-boundary lines split the current run
100        let is_boundary = starts_block(line);
101        if is_boundary && token_start.is_some() && line_idx > token_line_start {
102            flush(
103                &mut tokens,
104                &mut token_start,
105                markdown,
106                byte_offset,
107                byte_offset,
108            );
109            token_line_start = line_idx;
110            token_start = Some(byte_offset);
111            byte_offset += line_len;
112            continue;
113        }
114
115        if token_start.is_none() {
116            token_start = Some(byte_offset);
117            token_line_start = line_idx;
118        }
119        byte_offset += line_len;
120    }
121
122    if let Some(start) = token_start {
123        tokens.push(&markdown[start..]);
124    }
125    tokens
126}
127
128fn starts_with_double_dollar(str: &str) -> bool {
129    let t = str.trim_start_matches(is_ws);
130    t.starts_with("$$")
131}
132
133fn ends_with_double_dollar(str: &str) -> bool {
134    let t = str.trim_end_matches(is_ws);
135    t.ends_with("$$")
136}
137
138fn count_double_dollars(str: &str) -> usize {
139    let bytes = str.as_bytes();
140    let mut count = 0;
141    let mut i = 0;
142    while i + 1 < bytes.len() {
143        if bytes[i] == b'$' && bytes[i + 1] == b'$' {
144            count += 1;
145            i += 2;
146        } else {
147            i += 1;
148        }
149    }
150    count
151}
152
153/// Split markdown into logical blocks. Footnotes collapse the whole document into
154/// a single block; unclosed HTML tags and unclosed `$$` math merge across blocks.
155pub fn parse_markdown_into_blocks(markdown: &str) -> Vec<String> {
156    if has_footnote_reference(markdown) || has_footnote_definition(markdown) {
157        return vec![markdown.to_string()];
158    }
159
160    let tokens = lex_tokens(markdown);
161
162    let mut merged: Vec<String> = Vec::new();
163    let mut html_stack: Vec<String> = Vec::new();
164
165    for token in tokens {
166        let current = token;
167        let merged_len = merged.len();
168
169        // inside an unclosed HTML block — merge with previous
170        if !html_stack.is_empty() {
171            merged[merged_len - 1].push('\n');
172            merged[merged_len - 1].push_str(current);
173            if let Some(closing) = memchr::memmem::find(current.as_bytes(), b"</") {
174                if let Some(rest) = current[closing + 2..].split_whitespace().next() {
175                    let tag = rest.trim_end_matches(['>', '/', '\n']).to_string();
176                    if let Some(top) = html_stack.last() {
177                        if *top == tag {
178                            html_stack.pop();
179                        }
180                    }
181                }
182            }
183            continue;
184        }
185
186        // opening HTML block tag without closing in the same token
187        // (mirrors `openingTagPattern = /<(\w+)[\s>]/` on html tokens)
188        if current.trim_start_matches(is_ws).starts_with('<') {
189            let after = current.trim_start_matches(is_ws);
190            let after = &after[1..];
191            let tag: String = after
192                .chars()
193                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
194                .collect();
195            let next_after_tag = after[tag.len()..].chars().next();
196            let ok_boundary = next_after_tag == Some('>') || next_after_tag.is_some_and(is_ws);
197            if !tag.is_empty() && ok_boundary {
198                let has_closing = current.contains(&format!("</{}>", tag));
199                if !has_closing {
200                    html_stack.push(tag);
201                }
202            }
203        }
204
205        let trimmed = current.trim();
206
207        // standalone `$$` closing a previous unclosed math block
208        if trimmed == "$$" && merged_len > 0 {
209            let previous = &merged[merged_len - 1];
210            if starts_with_double_dollar(previous) && count_double_dollars(previous) % 2 == 1 {
211                merged[merged_len - 1] = format!("{}{}", previous, current);
212                continue;
213            }
214        }
215
216        // current block ends with `$$` and continues an unclosed math block
217        if merged_len > 0 && ends_with_double_dollar(current) {
218            let previous = &merged[merged_len - 1];
219            let prev_dollar_count = count_double_dollars(previous);
220            let curr_dollar_count = count_double_dollars(current);
221            if starts_with_double_dollar(previous)
222                && prev_dollar_count % 2 == 1
223                && !starts_with_double_dollar(current)
224                && curr_dollar_count == 1
225            {
226                merged[merged_len - 1] = format!("{}{}", previous, current);
227                continue;
228            }
229        }
230
231        merged.push(current.to_string());
232    }
233
234    merged
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn splits_on_blank_lines() {
243        let blocks = parse_markdown_into_blocks("# Title\n\nParagraph one\n\nParagraph two");
244        assert_eq!(blocks.len(), 3);
245        assert_eq!(blocks[0], "# Title\n");
246        assert_eq!(blocks[1], "Paragraph one\n");
247        assert_eq!(blocks[2], "Paragraph two");
248    }
249
250    #[test]
251    fn code_fence_is_one_block() {
252        let blocks = parse_markdown_into_blocks("before\n\n```js\ncode\nmore\n```\n\nafter");
253        assert_eq!(blocks.len(), 3);
254        assert_eq!(blocks[1], "```js\ncode\nmore\n```\n");
255    }
256
257    #[test]
258    fn unclosed_fence_extends_to_end() {
259        let blocks = parse_markdown_into_blocks("before\n\n```js\ncode\nmore");
260        assert_eq!(blocks.len(), 2);
261        assert_eq!(blocks[1], "```js\ncode\nmore");
262    }
263
264    #[test]
265    fn unclosed_html_merges() {
266        let blocks = parse_markdown_into_blocks("<div>\n\ncontent\n\n</div>\n\nafter");
267        assert_eq!(blocks.len(), 2);
268        assert_eq!(blocks[0], "<div>\n\ncontent\n\n</div>\n");
269    }
270
271    #[test]
272    fn math_merge() {
273        let blocks = parse_markdown_into_blocks("text\n\n$$\nE = mc^2\n$$\n\nafter");
274        assert_eq!(blocks.len(), 3);
275        assert_eq!(blocks[1], "$$\nE = mc^2\n$$\n");
276    }
277
278    #[test]
279    fn footnote_collapses_document() {
280        let blocks = parse_markdown_into_blocks("Text [^1]\n\n[^1]: note");
281        assert_eq!(blocks.len(), 1);
282    }
283
284    #[test]
285    fn list_items_group() {
286        let blocks = parse_markdown_into_blocks("- a\n- b\n- c\n\nafter");
287        assert_eq!(blocks.len(), 2);
288        assert_eq!(blocks[0], "- a\n- b\n- c\n");
289    }
290}