1use std::{collections::BTreeMap, ops::Range};
77
78use pulldown_cmark::{CodeBlockKind, Event, MetadataBlockKind, Options, Parser, Tag};
79
80use crate::document::{DocumentError, DocumentResult, Value};
81
82pub fn load(content: &str) -> DocumentResult<Value> {
89 reject_lone_carriage_return(content)?;
90 let events: Vec<SpannedEvent<'_>> = Parser::new_ext(content, options())
91 .into_offset_iter()
92 .collect();
93 reject_deep_nesting(&events)?;
94 let lines = LineIndex::new(content);
95 let mut cursor = 0;
96 Ok(fold_sections(read_blocks(&events, &lines, &mut cursor)))
97}
98
99type SpannedEvent<'a> = (Event<'a>, Range<usize>);
100
101struct LineIndex<'a> {
107 content: &'a str,
108 starts: Vec<usize>,
109}
110
111impl<'a> LineIndex<'a> {
112 fn new(content: &'a str) -> Self {
124 let mut starts = vec![0];
125 for (offset, byte) in content.bytes().enumerate() {
126 if byte == b'\n' {
127 starts.push(offset + 1);
128 }
129 }
130 Self { content, starts }
131 }
132
133 fn number_at(&self, offset: usize) -> i64 {
134 let line = self.starts.partition_point(|start| *start <= offset);
135 i64::try_from(line).unwrap_or(i64::MAX)
136 }
137
138 fn range(&self, source: &Range<usize>) -> SourceLines {
147 let content_end = self
148 .content
149 .get(..source.end)
150 .map_or(source.end, |head| head.trim_end().len())
151 .max(source.start);
152 let end_offset = content_end.saturating_sub(1).max(source.start);
153 SourceLines {
154 start: self.number_at(source.start),
155 end: self.number_at(end_offset),
156 }
157 }
158}
159
160#[derive(Clone, Copy)]
161struct SourceLines {
162 start: i64,
163 end: i64,
164}
165
166fn reject_lone_carriage_return(content: &str) -> DocumentResult<()> {
180 let bytes = content.as_bytes();
181 for (offset, byte) in bytes.iter().enumerate() {
182 if *byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n') {
183 return Err(DocumentError::SourceRefused {
184 format: "Markdown".to_string(),
185 detail: format!(
186 "a bare carriage return at byte {offset} ends a line for CommonMark but not \
187 for the tools these line numbers are meant to feed; convert the file to LF \
188 or CRLF line endings"
189 ),
190 });
191 }
192 }
193 Ok(())
194}
195
196const MAX_BLOCK_DEPTH: usize = 48;
204
205fn reject_deep_nesting(events: &[SpannedEvent<'_>]) -> DocumentResult<()> {
209 let mut depth = 0usize;
210 for (event, _) in events {
211 match event {
212 Event::Start(_) => {
213 depth += 1;
214 if depth > MAX_BLOCK_DEPTH {
215 return Err(DocumentError::SourceRefused {
216 format: "Markdown".to_string(),
217 detail: format!(
218 "blocks and inline spans nest deeper than {MAX_BLOCK_DEPTH} levels"
219 ),
220 });
221 }
222 }
223 Event::End(_) => depth = depth.saturating_sub(1),
224 _ => {}
225 }
226 }
227 Ok(())
228}
229
230fn options() -> Options {
245 Options::ENABLE_YAML_STYLE_METADATA_BLOCKS | Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS
246}
247
248struct Section {
250 level: i64,
251 text: String,
252 inlines: Value,
253 heading_lines: SourceLines,
254 blocks: Vec<Value>,
255 children: Vec<Section>,
256}
257
258fn fold_sections(blocks: Vec<Value>) -> Value {
266 let mut preamble = Vec::new();
267 let mut roots: Vec<Section> = Vec::new();
268 let mut open: Vec<Section> = Vec::new();
269
270 for block in blocks {
271 let heading_level = (block.get("type").and_then(Value::as_str) == Some("heading"))
272 .then(|| block.get("level").and_then(Value::as_integer))
273 .flatten();
274 match heading_level {
275 Some(level) => {
276 while open.last().is_some_and(|section| section.level >= level) {
277 close_section(&mut open, &mut roots);
278 }
279 open.push(Section {
280 level,
281 text: block
282 .get("text")
283 .and_then(Value::as_str)
284 .unwrap_or_default()
285 .to_string(),
286 inlines: block
287 .get("inlines")
288 .cloned()
289 .unwrap_or_else(|| Value::Array(Vec::new())),
290 heading_lines: SourceLines {
291 start: block
292 .get("source_start_line")
293 .and_then(Value::as_integer)
294 .unwrap_or_default(),
295 end: block
296 .get("source_end_line")
297 .and_then(Value::as_integer)
298 .unwrap_or_default(),
299 },
300 blocks: Vec::new(),
301 children: Vec::new(),
302 });
303 }
304 None => match open.last_mut() {
309 Some(section) => section.blocks.push(block),
310 None => preamble.push(block),
311 },
312 }
313 }
314 while !open.is_empty() {
315 close_section(&mut open, &mut roots);
316 }
317
318 let mut root = BTreeMap::from([("preamble".to_string(), Value::Object(block_views(preamble)))]);
323 insert_sections(&mut root, roots);
324 Value::Object(root)
325}
326
327fn close_section(open: &mut Vec<Section>, roots: &mut Vec<Section>) {
330 let Some(done) = open.pop() else { return };
331 match open.last_mut() {
332 Some(parent) => parent.children.push(done),
333 None => roots.push(done),
334 }
335}
336
337fn insert_sections(target: &mut BTreeMap<String, Value>, sections: Vec<Section>) {
340 for section in sections {
341 let key = format!("h{}", section.level);
342 let group = target
343 .entry(key)
344 .or_insert_with(|| Value::Array(Vec::new()));
345 if let Some(items) = group.as_array_mut() {
346 items.push(section.into_value());
347 }
348 }
349}
350
351fn block_views(blocks: Vec<Value>) -> BTreeMap<String, Value> {
362 let of_kind = |kind: &str| {
363 Value::Array(
364 blocks
365 .iter()
366 .filter(|block| block.get("type").and_then(Value::as_str) == Some(kind))
367 .cloned()
368 .collect::<Vec<Value>>(),
369 )
370 };
371 let paragraph = of_kind("paragraph");
372 let blockquote = of_kind("blockquote");
373 BTreeMap::from([
374 ("paragraph".to_string(), paragraph),
375 ("blockquote".to_string(), blockquote),
376 ("blocks".to_string(), Value::Array(blocks)),
377 ])
378}
379
380impl Section {
381 fn into_value(self) -> Value {
382 let source_end_line = self.source_end_line();
383 let mut fields = block_views(self.blocks);
384 fields.insert("level".to_string(), Value::Integer(self.level));
385 fields.insert("text".to_string(), Value::String(self.text));
386 fields.insert("inlines".to_string(), self.inlines);
387 fields.insert(
388 "source_start_line".to_string(),
389 Value::Integer(self.heading_lines.start),
390 );
391 fields.insert(
392 "source_end_line".to_string(),
393 Value::Integer(source_end_line),
394 );
395 fields.insert(
396 "heading_end_line".to_string(),
397 Value::Integer(self.heading_lines.end),
398 );
399 insert_sections(&mut fields, self.children);
400 Value::Object(fields)
401 }
402
403 fn source_end_line(&self) -> i64 {
404 self.blocks
405 .iter()
406 .filter_map(|block| block.get("source_end_line"))
407 .filter_map(Value::as_integer)
408 .chain(self.children.iter().map(Section::source_end_line))
409 .max()
410 .unwrap_or(self.heading_lines.end)
411 }
412}
413
414fn read_blocks(
418 events: &[SpannedEvent<'_>],
419 lines: &LineIndex<'_>,
420 cursor: &mut usize,
421) -> Vec<Value> {
422 let mut blocks = Vec::new();
423 while let Some((event, source)) = events.get(*cursor) {
424 match event {
425 Event::End(_) => break,
426 Event::Rule => {
427 let source = source.clone();
428 *cursor += 1;
429 blocks.push(block("rule", String::new(), &source, lines, vec![]));
430 }
431 Event::Start(tag) if !is_inline_tag(tag) => {
432 let source = source.clone();
433 *cursor += 1;
434 blocks.extend(read_block(tag, &source, events, lines, cursor));
435 }
436 _ => {
441 let first = *cursor;
442 let (text, inlines) = read_loose_inline(events, cursor);
443 if !text.is_empty() {
444 let source = covered_range(events, first, *cursor);
445 blocks.push(block(
446 "paragraph",
447 text,
448 &source,
449 lines,
450 vec![("inlines", inlines)],
451 ));
452 }
453 }
454 }
455 }
456 blocks
457}
458
459fn is_inline_tag(tag: &Tag<'_>) -> bool {
461 matches!(
462 tag,
463 Tag::Emphasis
464 | Tag::Strong
465 | Tag::Strikethrough
466 | Tag::Superscript
467 | Tag::Subscript
468 | Tag::Link { .. }
469 | Tag::Image { .. }
470 )
471}
472
473fn read_block(
475 tag: &Tag<'_>,
476 source: &Range<usize>,
477 events: &[SpannedEvent<'_>],
478 lines: &LineIndex<'_>,
479 cursor: &mut usize,
480) -> Option<Value> {
481 match tag {
482 Tag::Paragraph => {
483 let (text, inlines) = read_inline(events, cursor);
484 Some(block(
485 "paragraph",
486 text,
487 source,
488 lines,
489 vec![("inlines", inlines)],
490 ))
491 }
492 Tag::Heading { level, .. } => {
493 let (text, inlines) = read_inline(events, cursor);
494 Some(block(
495 "heading",
496 text,
497 source,
498 lines,
499 vec![
500 ("level", Value::Integer(*level as i64)),
501 ("inlines", inlines),
502 ],
503 ))
504 }
505 Tag::CodeBlock(kind) => {
506 let info = match kind {
509 CodeBlockKind::Fenced(info) => info.trim().to_string(),
510 CodeBlockKind::Indented => String::new(),
511 };
512 Some(block(
513 "code",
514 read_verbatim(events, cursor),
515 source,
516 lines,
517 vec![("language", Value::String(info))],
524 ))
525 }
526 Tag::HtmlBlock => Some(block(
527 "html",
528 read_verbatim(events, cursor),
529 source,
530 lines,
531 vec![],
532 )),
533 Tag::MetadataBlock(kind) => {
540 skip_subtree(events, cursor);
544 Some(block(
545 "frontmatter",
546 String::new(),
547 source,
548 lines,
549 vec![(
550 "format",
551 Value::String(
552 match kind {
553 MetadataBlockKind::PlusesStyle => "toml",
554 MetadataBlockKind::YamlStyle => "yaml",
555 }
556 .to_string(),
557 ),
558 )],
559 ))
560 }
561 Tag::BlockQuote(_) => {
562 let (text, children) = read_container(events, lines, cursor);
563 Some(block(
564 "blockquote",
565 text,
566 source,
567 lines,
568 vec![("blocks", children)],
569 ))
570 }
571 Tag::List(first_number) => {
572 let (text, children) = read_container(events, lines, cursor);
573 Some(block(
574 "list",
575 text,
576 source,
577 lines,
578 vec![
579 ("ordered", Value::Bool(first_number.is_some())),
580 ("blocks", children),
581 ],
582 ))
583 }
584 Tag::Item => {
587 let (text, children) = read_container(events, lines, cursor);
588 Some(block(
589 "item",
590 text,
591 source,
592 lines,
593 vec![("blocks", children)],
594 ))
595 }
596 _ => {
601 skip_subtree(events, cursor);
602 None
603 }
604 }
605}
606
607fn read_container(
616 events: &[SpannedEvent<'_>],
617 lines: &LineIndex<'_>,
618 cursor: &mut usize,
619) -> (String, Value) {
620 let children = read_blocks(events, lines, cursor);
621 *cursor += 1;
623 let text = children
624 .iter()
625 .filter_map(|child| child.get("text").and_then(Value::as_str))
626 .filter(|text| !text.is_empty())
627 .collect::<Vec<_>>()
628 .join("\n");
629 (text, Value::Array(children))
630}
631
632fn read_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> (String, Value) {
642 let read = read_loose_inline(events, cursor);
643 if matches!(events.get(*cursor), Some((Event::End(_), _))) {
644 *cursor += 1;
645 }
646 read
647}
648
649fn read_loose_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> (String, Value) {
653 let mut reader = InlineReader::default();
654 let mut depth = 0usize;
655 while let Some((event, _)) = events.get(*cursor) {
656 match event {
657 Event::Start(tag) if depth == 0 && !is_inline_tag(tag) => break,
658 Event::End(_) if depth == 0 => break,
659 Event::Rule if depth == 0 => break,
660 Event::Start(tag) => {
661 depth += 1;
662 reader.open(tag);
663 }
664 Event::End(_) => {
665 depth -= 1;
666 reader.close();
667 }
668 Event::Text(chunk) => reader.text(chunk),
669 Event::Code(chunk) => reader.leaf("code", chunk, chunk),
670 Event::InlineHtml(chunk) | Event::Html(chunk) => reader.leaf("html", chunk, ""),
671 Event::SoftBreak => reader.text(" "),
672 Event::HardBreak => reader.leaf("line_break", "", " "),
673 _ => {}
674 }
675 *cursor += 1;
676 }
677 reader.finish()
678}
679
680struct Inline {
682 kind: &'static str,
683 text: String,
685 destination_url: Option<String>,
686 children: Option<Vec<Inline>>,
689}
690
691impl Inline {
692 fn into_value(self) -> Value {
693 let text = if self.children.is_some() || self.destination_url.is_some() {
696 self.text.trim().to_string()
697 } else {
698 self.text
699 };
700 let mut fields = BTreeMap::from([
701 ("type".to_string(), Value::String(self.kind.to_string())),
702 ("text".to_string(), Value::String(text)),
703 ]);
704 if let Some(url) = self.destination_url {
705 fields.insert("destination_url".to_string(), Value::String(url));
706 }
707 if let Some(children) = self.children {
708 fields.insert("inlines".to_string(), inline_values(children));
709 }
710 Value::Object(fields)
711 }
712}
713
714fn inline_values(inlines: Vec<Inline>) -> Value {
715 Value::Array(inlines.into_iter().map(Inline::into_value).collect())
716}
717
718#[derive(Default)]
727struct InlineReader {
728 text: String,
729 roots: Vec<Inline>,
730 open: Vec<Inline>,
731 opened: Vec<bool>,
733}
734
735impl InlineReader {
736 fn siblings(&mut self) -> &mut Vec<Inline> {
739 match self.open.last_mut() {
740 Some(span) => span.children.get_or_insert_with(Vec::new),
741 None => &mut self.roots,
742 }
743 }
744
745 fn flatten(&mut self, chunk: &str) {
746 self.text.push_str(chunk);
747 for span in &mut self.open {
748 span.text.push_str(chunk);
749 }
750 }
751
752 fn in_image(&self) -> bool {
753 self.open.iter().any(|span| span.kind == "image")
754 }
755
756 fn text(&mut self, chunk: &str) {
757 self.flatten(chunk);
758 if self.in_image() {
759 return;
760 }
761 let siblings = self.siblings();
762 match siblings.last_mut() {
763 Some(last) if last.kind == "text" => last.text.push_str(chunk),
764 _ => siblings.push(Inline {
765 kind: "text",
766 text: chunk.to_string(),
767 destination_url: None,
768 children: None,
769 }),
770 }
771 }
772
773 fn leaf(&mut self, kind: &'static str, node_text: &str, flat: &str) {
774 self.flatten(flat);
775 if self.in_image() {
776 return;
777 }
778 self.siblings().push(Inline {
779 kind,
780 text: node_text.to_string(),
781 destination_url: None,
782 children: None,
783 });
784 }
785
786 fn open(&mut self, tag: &Tag<'_>) {
787 let span = match tag {
788 Tag::Emphasis => Some(("emphasis", None, true)),
789 Tag::Strong => Some(("strong", None, true)),
790 Tag::Link { dest_url, .. } => Some(("link", Some(dest_url.to_string()), true)),
791 Tag::Image { dest_url, .. } => Some(("image", Some(dest_url.to_string()), false)),
792 _ => None,
793 };
794 let span = span.filter(|_| !self.in_image());
796 self.opened.push(span.is_some());
797 if let Some((kind, destination_url, structured)) = span {
798 self.open.push(Inline {
799 kind,
800 text: String::new(),
801 destination_url,
802 children: structured.then(Vec::new),
803 });
804 }
805 }
806
807 fn close(&mut self) {
808 if self.opened.pop() == Some(true)
809 && let Some(done) = self.open.pop()
810 {
811 self.siblings().push(done);
812 }
813 }
814
815 fn finish(mut self) -> (String, Value) {
816 while !self.open.is_empty() {
817 self.opened.push(true);
818 self.close();
819 }
820 (self.text.trim().to_string(), inline_values(self.roots))
821 }
822}
823
824fn read_verbatim(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
830 let mut text = String::new();
831 while let Some((event, _)) = events.get(*cursor) {
832 *cursor += 1;
833 match event {
834 Event::End(_) => break,
835 Event::Text(chunk) | Event::Html(chunk) => text.push_str(chunk),
836 _ => {}
837 }
838 }
839 let unterminated = text.strip_suffix('\n').unwrap_or(text.as_str());
840 unterminated
841 .strip_suffix('\r')
842 .unwrap_or(unterminated)
843 .to_string()
844}
845
846fn skip_subtree(events: &[SpannedEvent<'_>], cursor: &mut usize) {
848 let mut depth = 0usize;
849 while let Some((event, _)) = events.get(*cursor) {
850 *cursor += 1;
851 match event {
852 Event::Start(_) => depth += 1,
853 Event::End(_) => {
854 if depth == 0 {
855 break;
856 }
857 depth -= 1;
858 }
859 _ => {}
860 }
861 }
862}
863
864fn covered_range(events: &[SpannedEvent<'_>], start: usize, end: usize) -> Range<usize> {
870 let mut covered = events
871 .get(start)
872 .map(|(_, source)| source.clone())
873 .unwrap_or(0..0);
874 for (_, source) in events.get(start..end).unwrap_or_default() {
875 covered.start = covered.start.min(source.start);
876 covered.end = covered.end.max(source.end);
877 }
878 covered
879}
880
881fn block(
884 kind: &str,
885 text: String,
886 source: &Range<usize>,
887 lines: &LineIndex<'_>,
888 extra: Vec<(&str, Value)>,
889) -> Value {
890 let source = lines.range(source);
891 let mut fields = BTreeMap::from([
892 ("type".to_string(), Value::String(kind.to_string())),
893 ("text".to_string(), Value::String(text)),
894 (
895 "source_start_line".to_string(),
896 Value::Integer(source.start),
897 ),
898 ("source_end_line".to_string(), Value::Integer(source.end)),
899 ]);
900 for (name, value) in extra {
901 fields.insert(name.to_string(), value);
902 }
903 Value::Object(fields)
904}
905
906#[cfg(test)]
907mod tests {
908 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
909 use super::*;
910 use crate::document::{Addressing, Format, get_path};
911
912 fn at(source: &str, path: &str) -> DocumentResult<Value> {
915 get_path(
916 &load(source).unwrap(),
917 path,
918 Addressing::INDEX_ONLY.with_array_rule(Format::Markdown.array_rule()),
919 )
920 }
921
922 fn text(source: &str, path: &str) -> String {
923 at(source, path)
924 .unwrap_or_else(|error| panic!("{path}: {error}"))
925 .as_str()
926 .unwrap_or_else(|| panic!("{path} is not a string"))
927 .to_string()
928 }
929
930 fn integer(source: &str, path: &str) -> i64 {
931 at(source, path)
932 .unwrap_or_else(|error| panic!("{path}: {error}"))
933 .as_integer()
934 .unwrap_or_else(|| panic!("{path} is not an integer"))
935 }
936
937 fn shape(source: &str, path: &str) -> Vec<(String, String)> {
939 at(source, path)
940 .unwrap_or_else(|error| panic!("{path}: {error}"))
941 .as_array()
942 .unwrap_or_else(|| panic!("{path} is not an array"))
943 .iter()
944 .map(|block| {
945 let field = |name: &str| {
946 block
947 .get(name)
948 .and_then(Value::as_str)
949 .unwrap_or_default()
950 .to_string()
951 };
952 (field("type"), field("text"))
953 })
954 .collect()
955 }
956
957 fn types(source: &str, path: &str) -> Vec<String> {
958 shape(source, path).into_iter().map(|(k, _)| k).collect()
959 }
960
961 #[test]
968 fn probe_1_setext_heading_is_a_heading() {
969 let source = "Title\n=====\n\nThe lead.\n";
971 assert_eq!(text(source, "h1.0.text"), "Title");
972 assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
973 }
974
975 #[test]
976 fn probe_2_html_comment_before_the_heading_is_its_own_block() {
977 let source = "<!-- generated -->\n\n# Real\n\nThe lead.\n";
980 assert_eq!(
981 shape(source, "preamble.blocks"),
982 [("html".to_string(), "<!-- generated -->".to_string())]
983 );
984 assert_eq!(text(source, "h1.0.text"), "Real");
985 }
986
987 #[test]
988 fn probe_3_badge_line_before_the_heading_is_a_paragraph() {
989 let source = "[](b)\n\n# Real\n\nThe lead.\n";
993 assert_eq!(
994 shape(source, "preamble.blocks"),
995 [("paragraph".to_string(), "CI".to_string())]
996 );
997 assert_eq!(text(source, "h1.0.text"), "Real");
998 assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
999 }
1000
1001 #[test]
1002 fn probe_4_fenced_code_in_the_lead_position_is_code() {
1003 let source = "# Real\n\n```bash\nafdata get x\n```\n";
1006 assert_eq!(
1007 shape(source, "h1.0.blocks"),
1008 [("code".to_string(), "afdata get x".to_string())]
1009 );
1010 assert_eq!(shape(source, "h1.0.paragraph"), []);
1011 assert_eq!(
1012 at(source, "h1.0.paragraph.0").unwrap_err().code(),
1013 "document_path_not_found"
1014 );
1015 assert_eq!(
1016 at(source, "h1.0.blocks.0.language").unwrap(),
1017 Value::String("bash".to_string())
1018 );
1019 }
1020
1021 #[test]
1022 fn probe_5_leading_fence_swallows_the_heading_inside_it() {
1023 let source = "```\n# Real\n```\n";
1026 assert_eq!(
1027 shape(source, "preamble.blocks"),
1028 [("code".to_string(), "# Real".to_string())]
1029 );
1030 assert_eq!(
1031 at(source, "h1.0").unwrap_err().code(),
1032 "document_path_not_found"
1033 );
1034 }
1035
1036 #[test]
1037 fn probe_6_atx_heading_interrupts_a_paragraph() {
1038 let source = "# Real\n\nThe lead.\n# looks like heading\nmore.\n";
1042 assert_eq!(text(source, "h1.0.text"), "Real");
1043 assert_eq!(
1044 shape(source, "h1.0.paragraph"),
1045 [("paragraph".to_string(), "The lead.".to_string())]
1046 );
1047 assert_eq!(text(source, "h1.1.text"), "looks like heading");
1048 assert_eq!(text(source, "h1.1.paragraph.0.text"), "more.");
1049 }
1050
1051 #[test]
1052 fn probe_7_four_space_indent_is_code_not_a_heading() {
1053 let source = " # Indented\n\nAfter.\n";
1056 assert_eq!(
1057 types(source, "preamble.blocks"),
1058 ["code".to_string(), "paragraph".to_string()]
1059 );
1060 assert_eq!(
1061 at(source, "h1.0").unwrap_err().code(),
1062 "document_path_not_found"
1063 );
1064 }
1065
1066 #[test]
1067 fn probe_8_whole_paragraph_emphasis_is_unwrapped() {
1068 assert_eq!(
1071 text("# Real\n\n**A bold tagline.**\n", "h1.0.paragraph.0.text"),
1072 "A bold tagline."
1073 );
1074 assert_eq!(
1077 text(
1078 "# T\n\nA **bold** `span` and a [link](https://example.com).\n",
1079 "h1.0.paragraph.0.text"
1080 ),
1081 "A bold span and a link."
1082 );
1083 }
1084
1085 #[test]
1088 fn headings_nest_by_level() {
1089 let source = "# A\n\na.\n\n## B\n\nb.\n\n### C\n\nc.\n\n## D\n\nd.\n\n# E\n";
1090 assert_eq!(text(source, "h1.0.text"), "A");
1091 assert_eq!(text(source, "h1.0.paragraph.0.text"), "a.");
1092 assert_eq!(text(source, "h1.0.h2.0.text"), "B");
1093 assert_eq!(text(source, "h1.0.h2.0.h3.0.text"), "C");
1094 assert_eq!(text(source, "h1.0.h2.0.h3.0.paragraph.0.text"), "c.");
1095 assert_eq!(text(source, "h1.0.h2.1.text"), "D");
1097 assert_eq!(
1098 at(source, "h1.0.h2.1.h3.0").unwrap_err().code(),
1099 "document_path_not_found"
1100 );
1101 assert_eq!(text(source, "h1.1.text"), "E");
1102 }
1103
1104 #[test]
1105 fn a_skipped_level_keeps_its_own_name() {
1106 let source = "# A\n\n### C\n\nc.\n";
1109 assert_eq!(text(source, "h1.0.h3.0.text"), "C");
1110 assert_eq!(
1111 at(source, "h1.0.h2.0").unwrap_err().code(),
1112 "document_path_not_found"
1113 );
1114 }
1115
1116 #[test]
1117 fn a_document_opening_below_h1_has_no_h1() {
1118 let source = "## Only\n\ntext.\n";
1121 assert_eq!(text(source, "h2.0.text"), "Only");
1122 assert_eq!(
1123 at(source, "h1.0").unwrap_err().code(),
1124 "document_path_not_found"
1125 );
1126 }
1127
1128 #[test]
1129 fn preamble_is_always_present_and_empty_for_a_clean_file() {
1130 assert_eq!(shape("# T\n\nlead\n", "preamble.blocks"), []);
1131 assert_eq!(shape("", "preamble.blocks"), []);
1132 }
1133
1134 #[test]
1137 fn a_section_is_addressable_by_a_word_of_its_heading() {
1138 let source = "# T\n\n## A Quick Look\n\ninside look.\n\n## Supported suffixes\n\ns.\n";
1139 assert_eq!(text(source, "h1.0.h2.look.text"), "A Quick Look");
1141 assert_eq!(
1142 text(source, "h1.0.h2.look.paragraph.0.text"),
1143 "inside look."
1144 );
1145 assert_eq!(text(source, "h1.0.h2.SUFFIX.text"), "Supported suffixes");
1146 assert_eq!(
1148 at(source, "h1.0.h2.look.text").unwrap(),
1149 at(source, "h1.0.h2.0.text").unwrap()
1150 );
1151 assert_eq!(
1154 at(source, "h1.0.h2.inside").unwrap_err().code(),
1155 "document_slug_not_found"
1156 );
1157 }
1158
1159 #[test]
1160 fn an_empty_segment_is_not_an_address() {
1161 let one = "# T\n\n## Only One\n\na\n";
1166 let two = "# T\n\n## A\n\na\n\n## B\n\nb\n";
1167 for source in [one, two] {
1168 assert_eq!(
1169 at(source, "h1.0.h2..text").unwrap_err().code(),
1170 "document_slug_not_found"
1171 );
1172 }
1173 assert_eq!(text(two, "h1.0.h2.A.text"), "A");
1175 }
1176
1177 #[test]
1178 fn a_word_matching_several_sections_is_refused() {
1179 let source = "# T\n\n## Quick look\n\na.\n\n## Another look\n\nb.\n";
1180 let error = at(source, "h1.0.h2.look").unwrap_err();
1181 assert_eq!(error.code(), "document_ambiguous_match");
1182 let message = error.to_string();
1184 assert!(message.contains("indices 0, 1"), "{message}");
1185 assert!(!message.contains("Quick look"), "{message}");
1186 assert!(!message.contains("Another look"), "{message}");
1187 assert_eq!(text(source, "h1.0.h2.Another.text"), "Another look");
1189 }
1190
1191 #[test]
1192 fn content_addressing_lowercases_unicode() {
1193 let source = "# T\n\n## Überblick\n\ninside.\n";
1194 assert_eq!(text(source, "h1.0.h2.ÜBER.text"), "Überblick");
1195 }
1196
1197 #[test]
1198 fn the_ask_prompt_blockquote_is_addressable_by_its_opening_words() {
1199 let source = "# T\n\nThe lead.\n\n> **Ask your agent:** \"Do the thing.\"\n";
1202 assert_eq!(
1203 text(source, "h1.0.blocks.Ask your agent.text"),
1204 "Ask your agent: \"Do the thing.\""
1205 );
1206 }
1207
1208 #[test]
1211 fn wrapped_paragraph_joins_onto_one_line() {
1212 assert_eq!(
1213 text("# T\n\nLead line one\nline two.\n", "h1.0.paragraph.0.text"),
1214 "Lead line one line two."
1215 );
1216 }
1217
1218 #[test]
1219 fn heading_level_is_reported_for_every_depth() {
1220 let source = "# a\n\n## b\n\n###### f\n";
1221 assert_eq!(at(source, "h1.0.level").unwrap(), Value::Integer(1));
1222 assert_eq!(at(source, "h1.0.h2.0.level").unwrap(), Value::Integer(2));
1223 assert_eq!(
1224 at(source, "h1.0.h2.0.h6.0.level").unwrap(),
1225 Value::Integer(6)
1226 );
1227 }
1228
1229 #[test]
1230 fn blockquote_flattens_its_paragraphs() {
1231 assert_eq!(
1233 shape(
1234 "> **Ask your agent:** \"Wrapped across\n> two lines.\"\n",
1235 "preamble.blocks"
1236 ),
1237 [(
1238 "blockquote".to_string(),
1239 "Ask your agent: \"Wrapped across two lines.\"".to_string()
1240 )]
1241 );
1242 assert_eq!(
1245 shape("> first\n>\n> second\n", "preamble.blocks"),
1246 [("blockquote".to_string(), "first\nsecond".to_string())]
1247 );
1248 }
1249
1250 #[test]
1251 fn list_reports_its_items_and_whether_it_is_ordered() {
1252 let bullet = at("- one\n- two\n", "preamble.blocks.0").unwrap();
1253 assert_eq!(bullet.get("text").and_then(Value::as_str), Some("one\ntwo"));
1254 assert_eq!(bullet.get("ordered"), Some(&Value::Bool(false)));
1255
1256 assert_eq!(
1257 at("1. one\n2. two\n", "preamble.blocks.0")
1258 .unwrap()
1259 .get("ordered"),
1260 Some(&Value::Bool(true))
1261 );
1262
1263 assert_eq!(
1267 at("- one\n\n- two\n", "preamble.blocks.0")
1268 .unwrap()
1269 .get("text")
1270 .and_then(Value::as_str),
1271 Some("one\ntwo")
1272 );
1273
1274 assert_eq!(
1276 at("- one\n - inner\n- two\n", "preamble.blocks.0")
1277 .unwrap()
1278 .get("text")
1279 .and_then(Value::as_str),
1280 Some("one\ninner\ntwo")
1281 );
1282 }
1283
1284 #[test]
1285 fn a_leading_metadata_block_is_its_own_kind() {
1286 let toml = "+++\ntitle = \"T\"\n\n[extra]\ntagline = \"x\"\n+++\n\n# Real\n\nThe lead.\n";
1290 assert_eq!(types(toml, "preamble.blocks"), ["frontmatter".to_string()]);
1291 assert_eq!(
1292 at(toml, "preamble.blocks.0.format").unwrap(),
1293 Value::String("toml".to_string())
1294 );
1295 assert_eq!(text(toml, "preamble.blocks.0.text"), "");
1299 assert_eq!(text(toml, "h1.0.text"), "Real");
1301 assert_eq!(text(toml, "h1.0.paragraph.0.text"), "The lead.");
1302
1303 let yaml = "---\ntitle: T\n---\n\n# Real\n";
1304 assert_eq!(types(yaml, "preamble.blocks"), ["frontmatter".to_string()]);
1305 assert_eq!(
1306 at(yaml, "preamble.blocks.0.format").unwrap(),
1307 Value::String("yaml".to_string())
1308 );
1309 assert_eq!(text(yaml, "h1.0.text"), "Real");
1310 }
1311
1312 #[test]
1313 fn dashes_away_from_the_start_keep_their_commonmark_meaning() {
1314 assert_eq!(text("Setext\n---\n\nbody\n", "h2.0.text"), "Setext");
1318 assert_eq!(
1319 types("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1320 [
1321 "paragraph".to_string(),
1322 "rule".to_string(),
1323 "paragraph".to_string()
1324 ]
1325 );
1326 }
1327
1328 #[test]
1329 fn a_source_range_stops_at_the_block_it_names() {
1330 let source = "- a\n - b\n\n\nAfter.\n";
1336 assert_eq!(
1337 at(source, "preamble.blocks.0.type").unwrap(),
1338 Value::String("list".to_string())
1339 );
1340 assert_eq!(
1341 at(source, "preamble.blocks.0.source_start_line").unwrap(),
1342 Value::Integer(1)
1343 );
1344 assert_eq!(
1345 at(source, "preamble.blocks.0.source_end_line").unwrap(),
1346 Value::Integer(2)
1347 );
1348 assert_eq!(
1349 at(source, "preamble.blocks.1.source_start_line").unwrap(),
1350 Value::Integer(5)
1351 );
1352
1353 let mixed = "# H\n\npara\n\n```\ncode\n```\n\n> quote\n\n---\n\ntail\n";
1356 for (address, start, end) in [
1357 ("h1.0.blocks.0", 3, 3),
1358 ("h1.0.blocks.1", 5, 7),
1359 ("h1.0.blocks.2", 9, 9),
1360 ("h1.0.blocks.3", 11, 11),
1361 ("h1.0.blocks.4", 13, 13),
1362 ] {
1363 assert_eq!(
1364 at(mixed, &format!("{address}.source_start_line")).unwrap(),
1365 Value::Integer(start),
1366 "{address} start"
1367 );
1368 assert_eq!(
1369 at(mixed, &format!("{address}.source_end_line")).unwrap(),
1370 Value::Integer(end),
1371 "{address} end"
1372 );
1373 }
1374 }
1375
1376 #[test]
1377 fn gfm_table_rows_are_a_paragraph() {
1378 assert_eq!(
1381 shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.blocks"),
1382 [(
1383 "paragraph".to_string(),
1384 "| a | b | |---|---| | 1 | 2 |".to_string()
1385 )]
1386 );
1387 assert_eq!(
1388 shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.paragraph"),
1389 [(
1390 "paragraph".to_string(),
1391 "| a | b | |---|---| | 1 | 2 |".to_string()
1392 )]
1393 );
1394 }
1395
1396 #[test]
1397 fn badge_syntax_remains_in_the_paragraph_view() {
1398 let source = "# T\n\n[](b)\n\nThe lead.\n";
1399 assert_eq!(
1400 shape(source, "h1.0.paragraph"),
1401 [
1402 ("paragraph".to_string(), "CI".to_string()),
1403 ("paragraph".to_string(), "The lead.".to_string()),
1404 ]
1405 );
1406 }
1407
1408 #[test]
1411 fn atx_heading_and_blocks_report_inclusive_source_lines() {
1412 let source = "# Title\n\nLead line one\nline two.\n\n```rs\nfn main() {}\n```\n";
1413 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1414 assert_eq!(integer(source, "h1.0.source_end_line"), 8);
1415 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1416 assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1417 assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1418 assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1419 assert_eq!(integer(source, "h1.0.blocks.1.source_start_line"), 6);
1420 assert_eq!(integer(source, "h1.0.blocks.1.source_end_line"), 8);
1421 }
1422
1423 #[test]
1424 fn setext_heading_range_includes_its_underline() {
1425 let source = "My Project\n==========\n\nThe synopsis.\n\n## Install\n";
1426 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1427 assert_eq!(integer(source, "h1.0.heading_end_line"), 2);
1428 assert_eq!(integer(source, "h1.0.source_end_line"), 6);
1429 assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1430 assert_eq!(integer(source, "h1.0.h2.0.source_end_line"), 6);
1431 assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 4);
1432 assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1433 }
1434
1435 #[test]
1436 fn line_ranges_are_utf8_safe_and_newline_style_independent() {
1437 let source = "# 中文标题\r\n\r\n这是首段,\r\n也是首段。\r\n\r\n## 安装";
1438 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1439 assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1440 assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1441 assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1442 assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1443 assert_eq!(integer(source, "h1.0.h2.0.heading_end_line"), 6);
1444 }
1445
1446 #[test]
1447 fn nesting_beyond_the_block_depth_is_refused_not_a_stack_overflow() {
1448 let nested = |depth: usize| format!("# T\n{}x\n", "> ".repeat(depth));
1449 assert_eq!(text(&nested(47), "h1.0.blockquote.0.text"), "x");
1451 for depth in [48, 100_000] {
1452 let error = load(&nested(depth)).unwrap_err();
1453 assert_eq!(error.code(), "document_source_refused");
1454 assert!(error.to_string().contains("48 levels"), "{error}");
1455 }
1456 let error = load(&format!("{}x\n", "- ".repeat(100_000))).unwrap_err();
1457 assert_eq!(error.code(), "document_source_refused");
1458 }
1459
1460 #[test]
1461 fn a_bare_carriage_return_is_refused_rather_than_numbered() {
1462 let error = load("# T\r\rLead\rcontinued\r\r## End").unwrap_err();
1468 assert_eq!(error.code(), "document_source_refused");
1472 assert!(error.to_string().contains("carriage return"), "{error}");
1473 assert!(
1477 error.redacted_message().contains("CRLF line endings"),
1478 "{}",
1479 error.redacted_message()
1480 );
1481
1482 let crlf = "# T\r\n\r\nLead\r\ncontinued\r\n\r\n## End\r\n";
1485 assert_eq!(integer(crlf, "h1.0.paragraph.0.source_start_line"), 3);
1486 assert_eq!(integer(crlf, "h1.0.paragraph.0.source_end_line"), 4);
1487 assert_eq!(integer(crlf, "h1.0.h2.0.source_start_line"), 6);
1488
1489 let bare = "# T\n\nLead";
1491 assert_eq!(integer(bare, "h1.0.paragraph.0.source_end_line"), 3);
1492 }
1493
1494 #[test]
1495 fn frontmatter_range_includes_both_delimiters() {
1496 let source = "---\ntitle: T\nnested:\n token_secret: hidden\n---\n\n# T\n";
1497 assert_eq!(integer(source, "preamble.blocks.0.source_start_line"), 1);
1498 assert_eq!(integer(source, "preamble.blocks.0.source_end_line"), 5);
1499 assert_eq!(text(source, "preamble.blocks.0.text"), "");
1500 }
1501
1502 #[test]
1503 fn thematic_break_is_a_block_with_no_text() {
1504 assert_eq!(
1505 shape("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1506 [
1507 ("paragraph".to_string(), "a".to_string()),
1508 ("rule".to_string(), String::new()),
1509 ("paragraph".to_string(), "b".to_string()),
1510 ]
1511 );
1512 assert_eq!(
1515 shape("# T\n\na\n\n---\n\nb\n", "h1.0.paragraph"),
1516 [
1517 ("paragraph".to_string(), "a".to_string()),
1518 ("paragraph".to_string(), "b".to_string()),
1519 ]
1520 );
1521 }
1522
1523 #[test]
1524 fn a_byte_order_mark_makes_the_first_block_a_paragraph() {
1525 assert_eq!(
1530 types("\u{feff}# Title\n\nlead\n", "preamble.blocks"),
1531 ["paragraph".to_string(), "paragraph".to_string()]
1532 );
1533 }
1534
1535 #[test]
1536 fn empty_document_has_no_blocks() {
1537 assert_eq!(shape("", "preamble.blocks"), []);
1538 assert_eq!(shape("\n\n \n", "preamble.blocks"), []);
1539 }
1540
1541 #[test]
1542 fn code_block_keeps_its_lines_and_drops_one_trailing_newline() {
1543 assert_eq!(
1544 shape("```\nline one\nline two\n```\n", "preamble.blocks"),
1545 [("code".to_string(), "line one\nline two".to_string())]
1546 );
1547 }
1548
1549 #[test]
1552 fn inlines_keep_what_text_flattens() {
1553 let source =
1554 "See [the **docs**](docs.md),  and `code` <b>b</b>\\\nend\n";
1555 assert_eq!(
1556 text(source, "preamble.paragraph.0.text"),
1557 "See the docs, alt x and code b end"
1558 );
1559 let kinds = |path: &str| -> Vec<(String, String)> {
1560 at(source, path)
1561 .unwrap()
1562 .as_array()
1563 .unwrap()
1564 .iter()
1565 .map(|node| {
1566 (
1567 node.get("type")
1568 .and_then(Value::as_str)
1569 .unwrap()
1570 .to_string(),
1571 node.get("text")
1572 .and_then(Value::as_str)
1573 .unwrap()
1574 .to_string(),
1575 )
1576 })
1577 .collect()
1578 };
1579 let pairs = |items: &[(&str, &str)]| -> Vec<(String, String)> {
1580 items
1581 .iter()
1582 .map(|(kind, text)| (kind.to_string(), text.to_string()))
1583 .collect()
1584 };
1585 assert_eq!(
1586 kinds("preamble.paragraph.0.inlines"),
1587 pairs(&[
1588 ("text", "See "),
1589 ("link", "the docs"),
1590 ("text", ", "),
1591 ("image", "alt x"),
1592 ("text", " and "),
1593 ("code", "code"),
1594 ("text", " "),
1595 ("html", "<b>"),
1596 ("text", "b"),
1597 ("html", "</b>"),
1598 ("line_break", ""),
1599 ("text", "end"),
1600 ])
1601 );
1602 assert_eq!(
1603 kinds("preamble.paragraph.0.inlines.docs.inlines"),
1604 pairs(&[("text", "the "), ("strong", "docs")])
1605 );
1606 assert_eq!(
1607 text(source, "preamble.paragraph.0.inlines.docs.destination_url"),
1608 "docs.md"
1609 );
1610 assert_eq!(
1611 text(source, "preamble.paragraph.0.inlines.3.destination_url"),
1612 "img.png"
1613 );
1614 assert!(
1616 at(source, "preamble.paragraph.0.inlines.3.inlines").is_err(),
1617 "an image reports flattened alt text only"
1618 );
1619 }
1620
1621 #[test]
1622 fn a_heading_and_its_section_report_inlines() {
1623 let source = "# Run `serve` now\n";
1624 assert_eq!(text(source, "h1.0.text"), "Run serve now");
1625 assert_eq!(text(source, "h1.0.inlines.1.type"), "code");
1626 assert_eq!(text(source, "h1.0.inlines.1.text"), "serve");
1627 }
1628
1629 #[test]
1632 fn a_soft_break_joins_adjacent_text() {
1633 assert_eq!(
1634 text("one\ntwo\n", "preamble.paragraph.0.inlines.0.text"),
1635 "one two"
1636 );
1637 assert!(at("one\ntwo\n", "preamble.paragraph.0.inlines.1").is_err());
1638 }
1639
1640 #[test]
1643 fn containers_report_their_child_blocks() {
1644 let source = "- item\n\n ```bash\n nested\n ```\n\n> ```json\n> {}\n> ```\n";
1645 assert_eq!(text(source, "preamble.blocks.0.blocks.0.type"), "item");
1646 assert_eq!(
1647 text(source, "preamble.blocks.0.blocks.0.blocks.1.type"),
1648 "code"
1649 );
1650 assert_eq!(
1651 text(source, "preamble.blocks.0.blocks.0.blocks.1.language"),
1652 "bash"
1653 );
1654 assert_eq!(
1655 text(source, "preamble.blocks.0.blocks.0.blocks.1.text"),
1656 "nested"
1657 );
1658 assert_eq!(
1659 integer(
1660 source,
1661 "preamble.blocks.0.blocks.0.blocks.1.source_start_line"
1662 ),
1663 3
1664 );
1665 assert_eq!(text(source, "preamble.blocks.1.blocks.0.type"), "code");
1666 assert_eq!(text(source, "preamble.blocks.1.blocks.0.language"), "json");
1667 assert_eq!(text(source, "preamble.blocks.1.blocks.0.text"), "{}");
1668 assert_eq!(text(source, "preamble.blocks.0.text"), "item\nnested");
1670 }
1671
1672 #[test]
1676 fn the_deepest_accepted_file_reports_a_tree_json_can_read_back() {
1677 fn depth(value: &Value) -> usize {
1678 match value {
1679 Value::Array(items) => 1 + items.iter().map(depth).max().unwrap_or(0),
1680 Value::Object(fields) => 1 + fields.values().map(depth).max().unwrap_or(0),
1681 _ => 0,
1682 }
1683 }
1684 let headings = "# a\n## b\n### c\n#### d\n##### e\n###### f\n";
1685 let deepest = [
1687 format!("{headings}{}x\n", "> ".repeat(MAX_BLOCK_DEPTH - 1)),
1688 format!("{headings}{}x\n", "- ".repeat((MAX_BLOCK_DEPTH - 1) / 2)),
1689 format!("{headings}{}*x*\n", "> ".repeat(MAX_BLOCK_DEPTH - 3)),
1690 ];
1691 for source in deepest {
1692 let tree = load(&source).unwrap();
1693 assert!(depth(&tree) <= 112, "{} levels", depth(&tree));
1694 let json = serde_json::to_string(&tree).unwrap();
1695 let enveloped = format!("{{\"kind\":\"result\",\"result\":{{\"value\":{json}}}}}");
1696 serde_json::from_str::<serde_json::Value>(&enveloped).unwrap();
1697 }
1698 }
1699
1700 #[test]
1701 fn inline_nesting_counts_toward_the_depth_limit() {
1702 let nested = |depth: usize| format!("{}x{}\n", "*".repeat(depth), "*".repeat(depth));
1703 assert!(load(&nested(8)).is_ok());
1704 let error = load(&nested(400)).unwrap_err();
1705 assert_eq!(error.code(), "document_source_refused");
1706 }
1707}