1use std::collections::HashSet;
6use std::ops::Range;
7use std::str::FromStr;
8
9use markdown::ParseOptions;
10use markdown::mdast::Node;
11use terraphim_types::{Document, DocumentType};
12use thiserror::Error;
13use ulid::Ulid;
14
15pub mod chunk;
16pub mod heading;
17pub mod limits;
18
19pub use chunk::{ContentChunk, chunk_by_headings};
20pub use heading::{
21 HeadingNode, HeadingTree, MatchStrategy, SectionConfig, SectionPattern, SectionType,
22 build_heading_tree, classify_sections,
23};
24pub use limits::{AstStats, LimitKind, ParseLimits, ParseLimitsError, measure_ast};
25
26pub const TERRAPHIM_BLOCK_ID_PREFIX: &str = "terraphim:block-id:";
27
28pub fn extract_first_heading(content: &str) -> Option<String> {
33 let ast = markdown::to_mdast(content, &ParseOptions::gfm()).ok()?;
34 find_first_h1(&ast)
35}
36
37fn find_first_h1(node: &Node) -> Option<String> {
39 match node {
40 Node::Heading(h) if h.depth == 1 => {
41 let text = collect_text_content(&h.children);
42 if text.is_empty() { None } else { Some(text) }
43 }
44 _ => {
45 if let Some(children) = children(node) {
46 for child in children {
47 if let Some(heading) = find_first_h1(child) {
48 return Some(heading);
49 }
50 }
51 }
52 None
53 }
54 }
55}
56
57pub(crate) fn collect_text_content(nodes: &[Node]) -> String {
59 let mut text = String::new();
60 for node in nodes {
61 match node {
62 Node::Text(t) => text.push_str(&t.value),
63 Node::InlineCode(c) => text.push_str(&c.value),
64 other => {
65 if let Some(children) = children(other) {
66 text.push_str(&collect_text_content(children));
67 }
68 }
69 }
70 }
71 text
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum BlockKind {
76 Paragraph,
77 ListItem,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Block {
82 pub id: Ulid,
83 pub kind: BlockKind,
84
85 pub span: Range<usize>,
90
91 pub id_span: Range<usize>,
96}
97
98#[derive(Debug, Clone)]
99pub struct NormalizedMarkdown {
100 pub markdown: String,
101 pub blocks: Vec<Block>,
102 pub ast: Option<markdown::mdast::Node>,
103}
104
105#[derive(Debug, Error)]
106pub enum MarkdownParserError {
107 #[error("failed to parse markdown: {0}")]
108 Markdown(String),
109
110 #[error("missing or invalid terraphim block id for {0:?} at byte offset {1}")]
111 MissingOrInvalidBlockId(BlockKind, usize),
112
113 #[error("markdown limit exceeded: {0} limit of {1} (actual: {2})")]
114 LimitExceeded(LimitKind, usize, usize),
115}
116
117impl From<markdown::message::Message> for MarkdownParserError {
118 fn from(value: markdown::message::Message) -> Self {
119 Self::Markdown(format!("{value:?}"))
120 }
121}
122
123#[derive(Debug, Clone)]
124struct Edit {
125 range: Range<usize>,
126 replacement: String,
127}
128
129impl Edit {
130 fn insert(at: usize, text: String) -> Self {
131 Self {
132 range: at..at,
133 replacement: text,
134 }
135 }
136}
137
138pub fn ensure_terraphim_block_ids(markdown: &str) -> Result<String, MarkdownParserError> {
144 let ast = markdown::to_mdast(markdown, &ParseOptions::gfm())?;
145 let mut edits: Vec<Edit> = Vec::new();
146 ensure_block_ids_in_children(&ast, markdown, &mut edits, ParentKind::Other);
147
148 if edits.is_empty() {
149 return Ok(markdown.to_string());
150 }
151
152 edits.sort_by_key(|e| std::cmp::Reverse(e.range.start));
154 let mut out = markdown.to_string();
155 for edit in edits {
156 out.replace_range(edit.range, &edit.replacement);
157 }
158 Ok(out)
159}
160
161pub fn normalize_markdown(markdown: &str) -> Result<NormalizedMarkdown, MarkdownParserError> {
168 let mut current = ensure_terraphim_block_ids(markdown)?;
169 for _ in 0..4 {
170 let next = ensure_terraphim_block_ids(¤t)?;
171 if next == current {
172 break;
173 }
174 current = next;
175 }
176 let blocks = extract_blocks(¤t)?;
177 let ast = markdown::to_mdast(¤t, &ParseOptions::gfm()).ok();
178 Ok(NormalizedMarkdown {
179 markdown: current,
180 blocks,
181 ast,
182 })
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct ParseDiagnostic {
188 pub message: String,
190 pub offset: Option<usize>,
192 pub limit: Option<LimitKind>,
194}
195
196#[derive(Debug, Clone)]
199pub struct ParseReport {
200 pub normalized: NormalizedMarkdown,
203 pub diagnostics: Vec<ParseDiagnostic>,
205 pub complete: bool,
207}
208
209fn enforce_input_limits(markdown: &str, limits: &ParseLimits) -> Result<(), MarkdownParserError> {
211 limits.validate_input(markdown).map_err(|e| match e {
212 ParseLimitsError::InputTooLarge { limit, actual } => {
213 MarkdownParserError::LimitExceeded(LimitKind::InputBytes, limit, actual)
214 }
215 ParseLimitsError::LineTooLong { limit, actual } => {
216 MarkdownParserError::LimitExceeded(LimitKind::LineLength, limit, actual)
217 }
218 })
219}
220
221fn parse_ast_within_limits(
223 markdown: &str,
224 limits: &ParseLimits,
225) -> Result<markdown::mdast::Node, MarkdownParserError> {
226 let ast = markdown::to_mdast(markdown, &ParseOptions::gfm())?;
227 let stats = measure_ast(&ast).ok_or(MarkdownParserError::LimitExceeded(
228 LimitKind::NodeCount,
229 limits.max_nodes,
230 usize::MAX,
231 ))?;
232 if stats.depth > limits.max_depth {
233 return Err(MarkdownParserError::LimitExceeded(
234 LimitKind::NestingDepth,
235 limits.max_depth,
236 stats.depth,
237 ));
238 }
239 if stats.nodes > limits.max_nodes {
240 return Err(MarkdownParserError::LimitExceeded(
241 LimitKind::NodeCount,
242 limits.max_nodes,
243 stats.nodes,
244 ));
245 }
246 Ok(ast)
247}
248
249pub fn parse_strict(
255 markdown: &str,
256 limits: &ParseLimits,
257) -> Result<NormalizedMarkdown, MarkdownParserError> {
258 enforce_input_limits(markdown, limits)?;
259 parse_ast_within_limits(markdown, limits)?;
260 let normalized = normalize_markdown(markdown)?;
261 if normalized.markdown.len() > limits.max_output_bytes {
262 return Err(MarkdownParserError::LimitExceeded(
263 LimitKind::OutputBytes,
264 limits.max_output_bytes,
265 normalized.markdown.len(),
266 ));
267 }
268 Ok(normalized)
269}
270
271pub fn parse_recovering(
278 markdown: &str,
279 limits: &ParseLimits,
280) -> Result<ParseReport, MarkdownParserError> {
281 let mut diagnostics: Vec<ParseDiagnostic> = Vec::new();
282
283 let normalized = match parse_strict(markdown, limits) {
284 Ok(n) => n,
285 Err(e) => {
286 let (limit, offset) = match &e {
287 MarkdownParserError::LimitExceeded(kind, _, _) => (Some(*kind), None),
288 MarkdownParserError::MissingOrInvalidBlockId(_, offset) => (None, Some(*offset)),
289 MarkdownParserError::Markdown(_) => (None, None),
290 };
291 diagnostics.push(ParseDiagnostic {
292 message: e.to_string(),
293 offset,
294 limit,
295 });
296 NormalizedMarkdown {
297 markdown: String::new(),
298 blocks: Vec::new(),
299 ast: None,
300 }
301 }
302 };
303
304 let complete = diagnostics.is_empty();
305 Ok(ParseReport {
306 normalized,
307 diagnostics,
308 complete,
309 })
310}
311
312pub fn blocks_to_documents(source_id: &str, normalized: &NormalizedMarkdown) -> Vec<Document> {
314 normalized
315 .blocks
316 .iter()
317 .map(|block| {
318 let block_id = block.id.to_string();
319 let id = format!("{source_id}#{block_id}");
320 let body = strip_terraphim_block_id_comments(&normalized.markdown[block.span.clone()])
321 .trim()
322 .to_string();
323 let title = first_nonempty_line(&body).unwrap_or_else(|| "Untitled".to_string());
324 Document {
325 id,
326 url: source_id.to_string(),
327 title,
328 body,
329 description: None,
330 summarization: None,
331 stub: None,
332 tags: None,
333 rank: None,
334 source_haystack: None,
335 doc_type: DocumentType::KgEntry,
336 synonyms: None,
337 route: None,
338 priority: None,
339 quality_score: None,
340 }
341 })
342 .collect()
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346enum ParentKind {
347 ListItem,
348 Other,
349}
350
351fn ensure_block_ids_in_children(
352 node: &Node,
353 source: &str,
354 edits: &mut Vec<Edit>,
355 parent: ParentKind,
356) {
357 match node {
358 Node::Root(root) => {
359 ensure_block_ids_in_list(&root.children, source, edits, ParentKind::Other)
360 }
361 Node::Blockquote(bq) => ensure_block_ids_in_list(&bq.children, source, edits, parent),
362 Node::List(list) => ensure_block_ids_in_list(&list.children, source, edits, parent),
363 Node::ListItem(li) => {
364 if let Some(pos) = node.position() {
365 ensure_list_item_inline_id(source, pos.start.offset, edits);
366 }
367 ensure_block_ids_in_list(&li.children, source, edits, ParentKind::ListItem);
368 }
369 _ => {
370 if let Some(children) = children(node) {
371 ensure_block_ids_in_list(children, source, edits, parent);
372 }
373 }
374 }
375}
376
377fn ensure_block_ids_in_list(
378 children: &[Node],
379 source: &str,
380 edits: &mut Vec<Edit>,
381 parent: ParentKind,
382) {
383 let mut first_direct_paragraph_in_list_item = false;
384
385 for (idx, child) in children.iter().enumerate() {
386 match child {
387 Node::ListItem(_) => ensure_block_ids_in_children(child, source, edits, parent),
388 Node::Paragraph(_) => {
389 if parent == ParentKind::ListItem && !first_direct_paragraph_in_list_item {
392 first_direct_paragraph_in_list_item = true;
393 } else if let Some(pos) = child.position() {
394 let has_prev_block_id = idx
395 .checked_sub(1)
396 .and_then(|prev| parse_block_id_from_html_node(&children[prev]))
397 .is_some();
398 if !has_prev_block_id {
399 edits.push(insert_paragraph_id_comment(source, pos.start.offset));
400 }
401 }
402 }
403 _ => ensure_block_ids_in_children(child, source, edits, parent),
404 }
405 }
406}
407
408fn insert_paragraph_id_comment(source: &str, paragraph_start: usize) -> Edit {
409 let (line_start, prefix) = line_prefix_at(source, paragraph_start);
410 let id = Ulid::new();
411 Edit::insert(
412 line_start,
413 format!("{prefix}<!-- terraphim:block-id:{id} -->\n"),
414 )
415}
416
417fn ensure_list_item_inline_id(source: &str, list_item_start: usize, edits: &mut Vec<Edit>) {
418 let (line_start, line_end) = line_bounds_at(source, list_item_start);
419 let line = &source[line_start..line_end];
420
421 if let Some((comment_start, comment_end, parsed)) = find_inline_block_id_comment(line) {
422 if parsed.is_some() {
423 return;
424 }
425
426 let replacement = format!("<!-- terraphim:block-id:{} -->", Ulid::new());
428 edits.push(Edit {
429 range: (line_start + comment_start)..(line_start + comment_end),
430 replacement,
431 });
432 return;
433 }
434
435 if let Some(insert_at) = list_item_inline_insert_point(source, list_item_start) {
437 let trailing_space = match source.as_bytes().get(insert_at) {
438 None | Some(b'\n') | Some(b'\r') => "",
439 _ => " ",
440 };
441 edits.push(Edit::insert(
442 insert_at,
443 format!(
444 "<!-- terraphim:block-id:{} -->{trailing_space}",
445 Ulid::new()
446 ),
447 ));
448 }
449}
450
451fn list_item_inline_insert_point(source: &str, list_item_start: usize) -> Option<usize> {
452 let bytes = source.as_bytes();
453 let mut i = list_item_start;
454
455 loop {
458 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
459 i += 1;
460 }
461 if bytes.get(i..i + 2) == Some(b"> ") {
462 i += 2;
463 continue;
464 }
465 break;
466 }
467
468 if matches!(bytes.get(i), Some(b'-' | b'*' | b'+')) {
470 i += 1;
471 if matches!(bytes.get(i), Some(b' ' | b'\t')) {
472 i += 1;
473 } else {
474 return None;
475 }
476 } else if matches!(bytes.get(i), Some(b'0'..=b'9')) {
477 while matches!(bytes.get(i), Some(b'0'..=b'9')) {
479 i += 1;
480 }
481 if matches!(bytes.get(i), Some(b'.' | b')')) {
482 i += 1;
483 } else {
484 return None;
485 }
486 if matches!(bytes.get(i), Some(b' ' | b'\t')) {
487 i += 1;
488 } else {
489 return None;
490 }
491 } else {
492 return None;
493 }
494
495 if bytes.get(i) == Some(&b'[')
497 && matches!(bytes.get(i + 1), Some(b' ' | b'x' | b'X'))
498 && bytes.get(i + 2) == Some(&b']')
499 && matches!(bytes.get(i + 3), Some(b' ' | b'\t'))
500 {
501 i += 4;
502 }
503
504 Some(i)
505}
506
507fn extract_blocks(markdown: &str) -> Result<Vec<Block>, MarkdownParserError> {
508 let ast = markdown::to_mdast(markdown, &ParseOptions::gfm())?;
509 let mut blocks = Vec::new();
510 extract_blocks_from_children(&ast, markdown, &mut blocks, ParentKind::Other)?;
511
512 let mut seen = HashSet::new();
514 for b in &blocks {
515 let id = b.id.to_string();
516 if !seen.insert(id) {
517 return Err(MarkdownParserError::MissingOrInvalidBlockId(
519 b.kind,
520 b.span.start,
521 ));
522 }
523 }
524
525 Ok(blocks)
526}
527
528fn extract_blocks_from_children(
529 node: &Node,
530 source: &str,
531 blocks: &mut Vec<Block>,
532 parent: ParentKind,
533) -> Result<(), MarkdownParserError> {
534 match node {
535 Node::Root(root) => {
536 extract_blocks_from_list(&root.children, source, blocks, ParentKind::Other)?;
537 }
538 Node::Blockquote(bq) => {
539 extract_blocks_from_list(&bq.children, source, blocks, parent)?;
540 }
541 Node::List(list) => {
542 extract_blocks_from_list(&list.children, source, blocks, parent)?;
543 }
544 Node::ListItem(li) => {
545 let Some(pos) = node.position() else {
546 return Ok(());
547 };
548
549 let Some((id, id_span)) = extract_list_item_id(source, pos.start.offset) else {
550 return Err(MarkdownParserError::MissingOrInvalidBlockId(
551 BlockKind::ListItem,
552 pos.start.offset,
553 ));
554 };
555 let start = line_bounds_at(source, pos.start.offset).0;
556 let end = pos.end.offset;
557 blocks.push(Block {
558 id,
559 kind: BlockKind::ListItem,
560 span: start..end,
561 id_span,
562 });
563 extract_blocks_from_list(&li.children, source, blocks, ParentKind::ListItem)?;
564 }
565 _ => {
566 if let Some(children) = children(node) {
567 extract_blocks_from_list(children, source, blocks, parent)?;
568 }
569 }
570 }
571 Ok(())
572}
573
574fn extract_blocks_from_list(
575 children: &[Node],
576 source: &str,
577 blocks: &mut Vec<Block>,
578 parent: ParentKind,
579) -> Result<(), MarkdownParserError> {
580 let mut first_direct_paragraph_in_list_item = false;
581
582 for (idx, child) in children.iter().enumerate() {
583 match child {
584 Node::ListItem(_) => extract_blocks_from_children(child, source, blocks, parent)?,
585 Node::Paragraph(_) => {
586 if parent == ParentKind::ListItem && !first_direct_paragraph_in_list_item {
587 first_direct_paragraph_in_list_item = true;
588 continue;
589 }
590
591 let Some(pos) = child.position() else {
592 continue;
593 };
594
595 let Some((id, anchor_span)) = idx
596 .checked_sub(1)
597 .and_then(|prev| {
598 parse_block_id_from_html_node_with_span(source, &children[prev])
599 })
600 .and_then(|(id, span)| id.map(|id| (id, span)))
601 else {
602 return Err(MarkdownParserError::MissingOrInvalidBlockId(
603 BlockKind::Paragraph,
604 pos.start.offset,
605 ));
606 };
607
608 blocks.push(Block {
609 id,
610 kind: BlockKind::Paragraph,
611 span: anchor_span.start..pos.end.offset,
612 id_span: anchor_span,
613 })
614 }
615 _ => extract_blocks_from_children(child, source, blocks, parent)?,
616 }
617 }
618
619 Ok(())
620}
621
622fn extract_list_item_id(source: &str, list_item_start: usize) -> Option<(Ulid, Range<usize>)> {
623 let (line_start, line_end) = line_bounds_at(source, list_item_start);
624 let line = &source[line_start..line_end];
625 let (comment_start, comment_end, parsed) = find_inline_block_id_comment(line)?;
626 let id = parsed?;
627 Some((id, (line_start + comment_start)..(line_start + comment_end)))
628}
629
630fn parse_block_id_from_html_node(node: &Node) -> Option<Ulid> {
631 match node {
632 Node::Html(val) => parse_block_id_comment(&val.value),
633 _ => None,
634 }
635}
636
637fn parse_block_id_from_html_node_with_span(
638 source: &str,
639 node: &Node,
640) -> Option<(Option<Ulid>, Range<usize>)> {
641 let Node::Html(val) = node else { return None };
642 let id = parse_block_id_comment(&val.value);
643
644 let Some(pos) = node.position() else {
645 return Some((id, 0..0));
646 };
647
648 let (line_start, line_end) = line_bounds_at(source, pos.start.offset);
649 Some((id, line_start..line_end))
650}
651
652fn parse_block_id_comment(raw_html: &str) -> Option<Ulid> {
653 let html = raw_html.trim();
654 let inner = html
655 .strip_prefix("<!--")
656 .and_then(|s| s.strip_suffix("-->"))?;
657 let inner = inner.trim();
658 let id_str = inner.strip_prefix(TERRAPHIM_BLOCK_ID_PREFIX)?;
659 Ulid::from_str(id_str.trim()).ok()
660}
661
662fn find_inline_block_id_comment(line: &str) -> Option<(usize, usize, Option<Ulid>)> {
663 let start = line.find("<!--")?;
664 let marker = line[start..].find(TERRAPHIM_BLOCK_ID_PREFIX)? + start;
665 let end = line[marker..].find("-->")? + marker + 3;
666
667 let comment_start = start;
668 let comment_end = end;
669 let comment = &line[comment_start..comment_end];
670 Some((comment_start, comment_end, parse_block_id_comment(comment)))
671}
672
673fn line_bounds_at(source: &str, offset: usize) -> (usize, usize) {
674 let line_start = source[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
675 let line_end = source[offset..]
676 .find('\n')
677 .map(|i| offset + i)
678 .unwrap_or_else(|| source.len());
679 (line_start, line_end)
680}
681
682fn line_prefix_at(source: &str, offset: usize) -> (usize, String) {
683 let (line_start, _line_end) = line_bounds_at(source, offset);
684 let prefix = &source[line_start..offset];
685 (line_start, prefix.to_string())
686}
687
688fn children(node: &Node) -> Option<&Vec<Node>> {
689 match node {
690 Node::Root(root) => Some(&root.children),
691 Node::Blockquote(bq) => Some(&bq.children),
692 Node::List(list) => Some(&list.children),
693 Node::ListItem(li) => Some(&li.children),
694 Node::Paragraph(p) => Some(&p.children),
695 Node::Heading(h) => Some(&h.children),
696 _ => None,
697 }
698}
699
700fn strip_terraphim_block_id_comments(text: &str) -> String {
701 let mut out = String::with_capacity(text.len());
702 for line in text.lines() {
703 let mut remaining = line;
704 let mut cleaned = String::new();
705 loop {
706 let Some((start, end, _)) = find_inline_block_id_comment(remaining) else {
707 cleaned.push_str(remaining);
708 break;
709 };
710 cleaned.push_str(&remaining[..start]);
711 remaining = &remaining[end..];
712 }
713
714 if cleaned.trim().is_empty() {
715 continue;
716 }
717
718 out.push_str(cleaned.trim_end());
719 out.push('\n')
720 }
721 out
722}
723
724fn first_nonempty_line(text: &str) -> Option<String> {
725 text.lines()
726 .map(|l| l.trim())
727 .find(|l| !l.is_empty())
728 .map(|l| l.chars().take(80).collect::<String>())
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 fn count_block_ids(s: &str) -> usize {
736 s.lines()
737 .filter(|l| l.contains("<!-- terraphim:block-id:"))
738 .count()
739 }
740
741 #[test]
742 fn inserts_paragraph_ids() {
743 let input = "Hello world\n\nSecond paragraph\n";
744 let out = ensure_terraphim_block_ids(input).unwrap();
745 assert_eq!(count_block_ids(&out), 2);
747 assert!(out.contains("Hello world"));
748 assert!(out.contains("Second paragraph"));
749 }
750
751 #[test]
752 fn inserts_list_item_inline_ids() {
753 let input = "- first\n- second\n";
754 let out = ensure_terraphim_block_ids(input).unwrap();
755 assert_eq!(count_block_ids(&out), 2);
756 assert!(out.contains("- <!-- terraphim:block-id:"));
757 }
758
759 #[test]
760 fn normalize_returns_blocks() {
761 let input = "- item\n\nPara\n";
762 let normalized = normalize_markdown(input).unwrap();
763 assert!(normalized.blocks.len() >= 2);
764 }
765
766 #[test]
767 fn extract_first_heading_h1() {
768 let input = "# Bun Package Manager\n\nsynonyms:: npm, yarn\n";
769 assert_eq!(
770 extract_first_heading(input),
771 Some("Bun Package Manager".to_string())
772 );
773 }
774
775 #[test]
776 fn extract_first_heading_skips_h2() {
777 let input = "## Not This\n\n# This One\n";
778 assert_eq!(extract_first_heading(input), Some("This One".to_string()));
779 }
780
781 #[test]
782 fn extract_first_heading_none_when_absent() {
783 let input = "Just some text\n\n## Only H2\n";
784 assert_eq!(extract_first_heading(input), None);
785 }
786
787 #[test]
788 fn extract_first_heading_with_inline_code() {
789 let input = "# The `bun` Runtime\n";
790 assert_eq!(
791 extract_first_heading(input),
792 Some("The bun Runtime".to_string())
793 );
794 }
795
796 #[test]
797 fn iterative_normalization_stabilizes() {
798 let input = "- item one\n- item two\n\nA paragraph after the list.\n\nAnother paragraph.\n";
805
806 let pass1 = ensure_terraphim_block_ids(input).unwrap();
808
809 let normalized = normalize_markdown(input).unwrap();
811
812 let again = ensure_terraphim_block_ids(&normalized.markdown).unwrap();
815 assert_eq!(
816 again, normalized.markdown,
817 "normalization should be stable after normalize_markdown"
818 );
819
820 let id_count = normalized
822 .markdown
823 .lines()
824 .filter(|l| l.contains("<!-- terraphim:block-id:"))
825 .count();
826 assert!(
827 id_count >= 4,
828 "expected at least 4 block IDs, got {id_count}; pass1 had {} IDs",
829 count_block_ids(&pass1),
830 );
831 }
832}