rsmarkdown_core/
blocks.rs1use crate::fix::{has_footnote_definition, has_footnote_reference};
6use crate::scan::{is_ws, js_trim};
7
8fn 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 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, Some('<' | '!' | '?') => true, Some('`') => true, _ => false,
27 }
28}
29
30fn 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; let mut token_line_start = 0usize; 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; let is_blank = line.trim().is_empty();
60
61 if in_fence {
62 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 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
153pub 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 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 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 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 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}