1use std::ops::Range;
4
5use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
6
7#[derive(Debug, Clone, Default)]
9pub struct MarkdownDoc {
10 pub frontmatter: Option<Frontmatter>,
12 pub headings: Vec<Heading>,
14 pub sections: Vec<Section>,
16 pub code_blocks: Vec<CodeBlock>,
18 pub list_items: Vec<ListItem>,
20 pub paragraphs: Vec<Paragraph>,
22 pub links: Vec<Link>,
24}
25
26#[derive(Debug, Clone)]
28pub struct Frontmatter {
29 pub raw: String,
31 pub byte_range: Range<usize>,
33}
34
35#[derive(Debug, Clone)]
37pub struct Heading {
38 pub level: u8,
40 pub text: String,
42 pub byte_range: Range<usize>,
44 pub line: usize,
46}
47
48#[derive(Debug, Clone)]
50pub struct Section {
51 pub heading_index: Option<usize>,
53 pub byte_range: Range<usize>,
55}
56
57#[derive(Debug, Clone)]
59pub struct CodeBlock {
60 pub lang: Option<String>,
62 pub text: String,
64 pub byte_range: Range<usize>,
66 pub line: usize,
68}
69
70#[derive(Debug, Clone)]
72pub struct ListItem {
73 pub text: String,
75 pub byte_range: Range<usize>,
77 pub line: usize,
79}
80
81#[derive(Debug, Clone)]
83pub struct Paragraph {
84 pub text: String,
86 pub byte_range: Range<usize>,
88 pub line: usize,
90}
91
92#[derive(Debug, Clone)]
94pub struct Link {
95 pub url: String,
97 pub text: String,
99 pub byte_range: Range<usize>,
101 pub line: usize,
103}
104
105pub fn parse(input: &str) -> MarkdownDoc {
107 let (frontmatter, body_start) = split_frontmatter(input);
108 let body = &input[body_start..];
109 let line_starts = compute_line_starts(input);
110
111 let mut doc = MarkdownDoc {
112 frontmatter,
113 headings: Vec::new(),
114 sections: Vec::new(),
115 code_blocks: Vec::new(),
116 list_items: Vec::new(),
117 paragraphs: Vec::new(),
118 links: Vec::new(),
119 };
120
121 let mut heading_stack: Vec<PendingHeading> = Vec::new();
122 let mut code_stack: Vec<PendingCodeBlock> = Vec::new();
123 let mut item_stack: Vec<PendingListItem> = Vec::new();
124 let mut paragraph_stack: Vec<PendingParagraph> = Vec::new();
125 let mut link_stack: Vec<PendingLink> = Vec::new();
126
127 for (event, raw_range) in Parser::new_ext(body, Options::empty()).into_offset_iter() {
128 let range = (raw_range.start + body_start)..(raw_range.end + body_start);
129 match event {
130 Event::Start(Tag::Heading { level, .. }) => {
131 heading_stack.push(PendingHeading {
132 level: heading_level_u8(level),
133 text: String::new(),
134 start: range.start,
135 });
136 }
137 Event::End(TagEnd::Heading(_)) => {
138 if let Some(pending) = heading_stack.pop() {
139 let line = offset_to_line(&line_starts, pending.start);
140 doc.headings.push(Heading {
141 level: pending.level,
142 text: pending.text.trim().to_string(),
143 byte_range: pending.start..range.end,
144 line,
145 });
146 }
147 }
148 Event::Start(Tag::CodeBlock(kind)) => {
149 let lang = match kind {
150 CodeBlockKind::Fenced(l) => {
151 let s = l.to_string();
152 if s.is_empty() {
153 None
154 } else {
155 Some(s)
156 }
157 }
158 CodeBlockKind::Indented => None,
159 };
160 code_stack.push(PendingCodeBlock {
161 lang,
162 text: String::new(),
163 start: range.start,
164 });
165 }
166 Event::End(TagEnd::CodeBlock) => {
167 if let Some(pending) = code_stack.pop() {
168 let line = offset_to_line(&line_starts, pending.start);
169 doc.code_blocks.push(CodeBlock {
170 lang: pending.lang,
171 text: pending.text,
172 byte_range: pending.start..range.end,
173 line,
174 });
175 }
176 }
177 Event::Start(Tag::Item) => {
178 item_stack.push(PendingListItem {
179 text: String::new(),
180 start: range.start,
181 });
182 }
183 Event::End(TagEnd::Item) => {
184 if let Some(pending) = item_stack.pop() {
185 let line = offset_to_line(&line_starts, pending.start);
186 doc.list_items.push(ListItem {
187 text: pending.text.trim().to_string(),
188 byte_range: pending.start..range.end,
189 line,
190 });
191 }
192 }
193 Event::Start(Tag::Paragraph) => {
194 if item_stack.is_empty() {
196 paragraph_stack.push(PendingParagraph {
197 text: String::new(),
198 start: range.start,
199 });
200 }
201 }
202 Event::End(TagEnd::Paragraph) => {
203 if let Some(pending) = paragraph_stack.pop() {
204 let line = offset_to_line(&line_starts, pending.start);
205 doc.paragraphs.push(Paragraph {
206 text: pending.text.trim().to_string(),
207 byte_range: pending.start..range.end,
208 line,
209 });
210 }
211 }
212 Event::Start(Tag::Link { dest_url, .. }) => {
213 link_stack.push(PendingLink {
214 url: dest_url.to_string(),
215 text: String::new(),
216 start: range.start,
217 });
218 }
219 Event::End(TagEnd::Link) => {
220 if let Some(pending) = link_stack.pop() {
221 let line = offset_to_line(&line_starts, pending.start);
222 doc.links.push(Link {
223 url: pending.url,
224 text: pending.text.trim().to_string(),
225 byte_range: pending.start..range.end,
226 line,
227 });
228 }
229 }
230 Event::Text(t) => {
231 if let Some(c) = code_stack.last_mut() {
232 c.text.push_str(&t);
233 } else {
234 if let Some(h) = heading_stack.last_mut() {
235 h.text.push_str(&t);
236 }
237 if let Some(li) = item_stack.last_mut() {
238 li.text.push_str(&t);
239 }
240 if let Some(p) = paragraph_stack.last_mut() {
241 p.text.push_str(&t);
242 }
243 if let Some(lk) = link_stack.last_mut() {
244 lk.text.push_str(&t);
245 }
246 }
247 }
248 Event::Code(t) => {
249 if let Some(h) = heading_stack.last_mut() {
250 h.text.push_str(&t);
251 }
252 if let Some(li) = item_stack.last_mut() {
253 li.text.push_str(&t);
254 }
255 if let Some(p) = paragraph_stack.last_mut() {
256 p.text.push_str(&t);
257 }
258 if let Some(lk) = link_stack.last_mut() {
259 lk.text.push_str(&t);
260 }
261 }
262 _ => {}
263 }
264 }
265
266 doc.sections = build_sections(&doc.headings, body_start, input.len());
267 doc
268}
269
270struct PendingHeading {
271 level: u8,
272 text: String,
273 start: usize,
274}
275
276struct PendingCodeBlock {
277 lang: Option<String>,
278 text: String,
279 start: usize,
280}
281
282struct PendingListItem {
283 text: String,
284 start: usize,
285}
286
287struct PendingParagraph {
288 text: String,
289 start: usize,
290}
291
292struct PendingLink {
293 url: String,
294 text: String,
295 start: usize,
296}
297
298fn heading_level_u8(level: HeadingLevel) -> u8 {
299 match level {
300 HeadingLevel::H1 => 1,
301 HeadingLevel::H2 => 2,
302 HeadingLevel::H3 => 3,
303 HeadingLevel::H4 => 4,
304 HeadingLevel::H5 => 5,
305 HeadingLevel::H6 => 6,
306 }
307}
308
309fn build_sections(headings: &[Heading], body_start: usize, file_end: usize) -> Vec<Section> {
310 let mut sections = Vec::new();
311 let first_start = headings
312 .first()
313 .map(|h| h.byte_range.start)
314 .unwrap_or(file_end);
315 if body_start < first_start {
316 sections.push(Section {
317 heading_index: None,
318 byte_range: body_start..first_start,
319 });
320 }
321 for (i, h) in headings.iter().enumerate() {
322 let end = headings
323 .get(i + 1)
324 .map(|nh| nh.byte_range.start)
325 .unwrap_or(file_end);
326 sections.push(Section {
327 heading_index: Some(i),
328 byte_range: h.byte_range.end..end,
329 });
330 }
331 sections
332}
333
334fn compute_line_starts(input: &str) -> Vec<usize> {
335 let mut v = Vec::with_capacity(64);
336 v.push(0);
337 for (i, b) in input.bytes().enumerate() {
338 if b == b'\n' {
339 v.push(i + 1);
340 }
341 }
342 v
343}
344
345fn offset_to_line(starts: &[usize], offset: usize) -> usize {
346 match starts.binary_search(&offset) {
347 Ok(i) => i + 1,
348 Err(i) => i.max(1),
349 }
350}
351
352fn split_frontmatter(input: &str) -> (Option<Frontmatter>, usize) {
353 let bom_len = if input.starts_with('\u{FEFF}') {
354 '\u{FEFF}'.len_utf8()
355 } else {
356 0
357 };
358 let after_bom = &input[bom_len..];
359
360 let open_len = if after_bom.starts_with("---\r\n") {
361 5
362 } else if after_bom.starts_with("---\n") {
363 4
364 } else {
365 return (None, 0);
366 };
367
368 let body_after_open = &after_bom[open_len..];
369 let mut cursor = 0usize;
370 while cursor <= body_after_open.len() {
371 let rest = &body_after_open[cursor..];
372 let (line, consumed) = match rest.find('\n') {
373 Some(nl) => (&rest[..nl], nl + 1),
374 None => (rest, rest.len()),
375 };
376 let content = line.strip_suffix('\r').unwrap_or(line);
377 if content == "---" {
378 let close_end = cursor + consumed;
379 let raw_slice = &body_after_open[..cursor];
380 let raw_trimmed = raw_slice.strip_suffix('\n').unwrap_or(raw_slice);
381 let raw_trimmed = raw_trimmed.strip_suffix('\r').unwrap_or(raw_trimmed);
382 let full_end = bom_len + open_len + close_end;
383 return (
384 Some(Frontmatter {
385 raw: raw_trimmed.to_string(),
386 byte_range: bom_len..full_end,
387 }),
388 full_end,
389 );
390 }
391 if consumed == 0 {
392 break;
393 }
394 cursor += consumed;
395 }
396 (None, 0)
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn parses_frontmatter_when_present() {
405 let src = "---\ntitle: Hi\nkey: val\n---\n# Heading\n";
406 let doc = parse(src);
407 let fm = doc.frontmatter.expect("frontmatter");
408 assert_eq!(fm.raw, "title: Hi\nkey: val");
409 assert_eq!(fm.byte_range.start, 0);
410 assert_eq!(
411 &src[fm.byte_range.clone()],
412 "---\ntitle: Hi\nkey: val\n---\n"
413 );
414 assert_eq!(doc.headings.len(), 1);
415 assert_eq!(doc.headings[0].text, "Heading");
416 }
417
418 #[test]
419 fn no_frontmatter_when_missing_closing_fence() {
420 let src = "---\ntitle: unterminated\n# Heading\n";
421 let doc = parse(src);
422 assert!(doc.frontmatter.is_none());
423 }
424
425 #[test]
426 fn no_frontmatter_when_not_delimited() {
427 let src = "# Heading\n\nBody text.\n";
428 let doc = parse(src);
429 assert!(doc.frontmatter.is_none());
430 assert_eq!(doc.headings.len(), 1);
431 }
432
433 #[test]
434 fn extracts_headings_with_line_numbers() {
435 let src = "# One\n\n## Two\n\nBody\n\n### Three\n";
436 let doc = parse(src);
437 assert_eq!(doc.headings.len(), 3);
438 assert_eq!(doc.headings[0].level, 1);
439 assert_eq!(doc.headings[0].text, "One");
440 assert_eq!(doc.headings[0].line, 1);
441 assert_eq!(doc.headings[1].level, 2);
442 assert_eq!(doc.headings[1].line, 3);
443 assert_eq!(doc.headings[2].level, 3);
444 assert_eq!(doc.headings[2].line, 7);
445 }
446
447 #[test]
448 fn sections_span_between_headings() {
449 let src = "prelude\n\n# One\nbody-one\n\n# Two\nbody-two\n";
450 let doc = parse(src);
451 assert_eq!(doc.sections.len(), 3);
452 assert!(doc.sections[0].heading_index.is_none());
453 assert_eq!(doc.sections[1].heading_index, Some(0));
454 assert_eq!(doc.sections[2].heading_index, Some(1));
455 let s1 = &src[doc.sections[1].byte_range.clone()];
456 assert!(s1.contains("body-one"));
457 assert!(!s1.contains("body-two"));
458 }
459
460 #[test]
461 fn code_blocks_capture_language() {
462 let src = "text\n\n```rust\nfn main() {}\n```\n\n```\nplain\n```\n";
463 let doc = parse(src);
464 assert_eq!(doc.code_blocks.len(), 2);
465 assert_eq!(doc.code_blocks[0].lang.as_deref(), Some("rust"));
466 assert!(doc.code_blocks[0].text.contains("fn main"));
467 assert_eq!(doc.code_blocks[1].lang, None);
468 }
469
470 #[test]
471 fn handles_crlf_line_endings() {
472 let src = "---\r\ntitle: crlf\r\n---\r\n# Heading\r\n\r\n- item one\r\n- item two\r\n";
473 let doc = parse(src);
474 let fm = doc.frontmatter.expect("frontmatter");
475 assert_eq!(fm.raw, "title: crlf");
476 assert_eq!(doc.headings.len(), 1);
477 assert_eq!(doc.headings[0].text, "Heading");
478 assert_eq!(doc.list_items.len(), 2);
479 assert_eq!(doc.list_items[0].text, "item one");
480 assert_eq!(doc.list_items[1].text, "item two");
481 }
482}