1use std::{collections::BTreeMap, ops::Range};
72
73use pulldown_cmark::{CodeBlockKind, Event, MetadataBlockKind, Options, Parser, Tag, TagEnd};
74
75use crate::document::{DocumentError, DocumentResult, Value};
76
77pub fn load(content: &str) -> DocumentResult<Value> {
84 reject_lone_carriage_return(content)?;
85 let events: Vec<SpannedEvent<'_>> = Parser::new_ext(content, options())
86 .into_offset_iter()
87 .collect();
88 reject_deep_nesting(&events)?;
89 let lines = LineIndex::new(content);
90 let mut cursor = 0;
91 Ok(fold_sections(read_blocks(&events, &lines, &mut cursor)))
92}
93
94type SpannedEvent<'a> = (Event<'a>, Range<usize>);
95
96struct LineIndex<'a> {
102 content: &'a str,
103 starts: Vec<usize>,
104}
105
106impl<'a> LineIndex<'a> {
107 fn new(content: &'a str) -> Self {
119 let mut starts = vec![0];
120 for (offset, byte) in content.bytes().enumerate() {
121 if byte == b'\n' {
122 starts.push(offset + 1);
123 }
124 }
125 Self { content, starts }
126 }
127
128 fn number_at(&self, offset: usize) -> i64 {
129 let line = self.starts.partition_point(|start| *start <= offset);
130 i64::try_from(line).unwrap_or(i64::MAX)
131 }
132
133 fn range(&self, source: &Range<usize>) -> SourceLines {
142 let content_end = self
143 .content
144 .get(..source.end)
145 .map_or(source.end, |head| head.trim_end().len())
146 .max(source.start);
147 let end_offset = content_end.saturating_sub(1).max(source.start);
148 SourceLines {
149 start: self.number_at(source.start),
150 end: self.number_at(end_offset),
151 }
152 }
153}
154
155#[derive(Clone, Copy)]
156struct SourceLines {
157 start: i64,
158 end: i64,
159}
160
161fn reject_lone_carriage_return(content: &str) -> DocumentResult<()> {
175 let bytes = content.as_bytes();
176 for (offset, byte) in bytes.iter().enumerate() {
177 if *byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n') {
178 return Err(DocumentError::SourceRefused {
179 format: "Markdown".to_string(),
180 detail: format!(
181 "a bare carriage return at byte {offset} ends a line for CommonMark but not \
182 for the tools these line numbers are meant to feed; convert the file to LF \
183 or CRLF line endings"
184 ),
185 });
186 }
187 }
188 Ok(())
189}
190
191const MAX_BLOCK_DEPTH: usize = 128;
194
195fn reject_deep_nesting(events: &[SpannedEvent<'_>]) -> DocumentResult<()> {
198 let mut depth = 0usize;
199 for (event, _) in events {
200 match event {
201 Event::Start(tag) if !is_inline_tag(tag) => {
202 depth += 1;
203 if depth > MAX_BLOCK_DEPTH {
204 return Err(DocumentError::SourceRefused {
205 format: "Markdown".to_string(),
206 detail: format!("blocks nest deeper than {MAX_BLOCK_DEPTH} levels"),
207 });
208 }
209 }
210 Event::End(tag) if !is_inline_end(*tag) => depth = depth.saturating_sub(1),
211 _ => {}
212 }
213 }
214 Ok(())
215}
216
217fn is_inline_end(tag: TagEnd) -> bool {
218 matches!(
219 tag,
220 TagEnd::Emphasis
221 | TagEnd::Strong
222 | TagEnd::Strikethrough
223 | TagEnd::Superscript
224 | TagEnd::Subscript
225 | TagEnd::Link
226 | TagEnd::Image
227 )
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 heading_lines: SourceLines,
253 blocks: Vec<Value>,
254 children: Vec<Section>,
255}
256
257fn fold_sections(blocks: Vec<Value>) -> Value {
265 let mut preamble = Vec::new();
266 let mut roots: Vec<Section> = Vec::new();
267 let mut open: Vec<Section> = Vec::new();
268
269 for block in blocks {
270 let heading_level = (block.get("type").and_then(Value::as_str) == Some("heading"))
271 .then(|| block.get("level").and_then(Value::as_integer))
272 .flatten();
273 match heading_level {
274 Some(level) => {
275 while open.last().is_some_and(|section| section.level >= level) {
276 close_section(&mut open, &mut roots);
277 }
278 open.push(Section {
279 level,
280 text: block
281 .get("text")
282 .and_then(Value::as_str)
283 .unwrap_or_default()
284 .to_string(),
285 heading_lines: SourceLines {
286 start: block
287 .get("source_start_line")
288 .and_then(Value::as_integer)
289 .unwrap_or_default(),
290 end: block
291 .get("source_end_line")
292 .and_then(Value::as_integer)
293 .unwrap_or_default(),
294 },
295 blocks: Vec::new(),
296 children: Vec::new(),
297 });
298 }
299 None => match open.last_mut() {
304 Some(section) => section.blocks.push(block),
305 None => preamble.push(block),
306 },
307 }
308 }
309 while !open.is_empty() {
310 close_section(&mut open, &mut roots);
311 }
312
313 let mut root = BTreeMap::from([("preamble".to_string(), Value::Object(block_views(preamble)))]);
318 insert_sections(&mut root, roots);
319 Value::Object(root)
320}
321
322fn close_section(open: &mut Vec<Section>, roots: &mut Vec<Section>) {
325 let Some(done) = open.pop() else { return };
326 match open.last_mut() {
327 Some(parent) => parent.children.push(done),
328 None => roots.push(done),
329 }
330}
331
332fn insert_sections(target: &mut BTreeMap<String, Value>, sections: Vec<Section>) {
335 for section in sections {
336 let key = format!("h{}", section.level);
337 let group = target
338 .entry(key)
339 .or_insert_with(|| Value::Array(Vec::new()));
340 if let Some(items) = group.as_array_mut() {
341 items.push(section.into_value());
342 }
343 }
344}
345
346fn block_views(blocks: Vec<Value>) -> BTreeMap<String, Value> {
357 let of_kind = |kind: &str| {
358 Value::Array(
359 blocks
360 .iter()
361 .filter(|block| block.get("type").and_then(Value::as_str) == Some(kind))
362 .cloned()
363 .collect::<Vec<Value>>(),
364 )
365 };
366 let paragraph = of_kind("paragraph");
367 let blockquote = of_kind("blockquote");
368 BTreeMap::from([
369 ("paragraph".to_string(), paragraph),
370 ("blockquote".to_string(), blockquote),
371 ("blocks".to_string(), Value::Array(blocks)),
372 ])
373}
374
375impl Section {
376 fn into_value(self) -> Value {
377 let source_end_line = self.source_end_line();
378 let mut fields = block_views(self.blocks);
379 fields.insert("level".to_string(), Value::Integer(self.level));
380 fields.insert("text".to_string(), Value::String(self.text));
381 fields.insert(
382 "source_start_line".to_string(),
383 Value::Integer(self.heading_lines.start),
384 );
385 fields.insert(
386 "source_end_line".to_string(),
387 Value::Integer(source_end_line),
388 );
389 fields.insert(
390 "heading_end_line".to_string(),
391 Value::Integer(self.heading_lines.end),
392 );
393 insert_sections(&mut fields, self.children);
394 Value::Object(fields)
395 }
396
397 fn source_end_line(&self) -> i64 {
398 self.blocks
399 .iter()
400 .filter_map(|block| block.get("source_end_line"))
401 .filter_map(Value::as_integer)
402 .chain(self.children.iter().map(Section::source_end_line))
403 .max()
404 .unwrap_or(self.heading_lines.end)
405 }
406}
407
408fn read_blocks(
412 events: &[SpannedEvent<'_>],
413 lines: &LineIndex<'_>,
414 cursor: &mut usize,
415) -> Vec<Value> {
416 let mut blocks = Vec::new();
417 while let Some((event, source)) = events.get(*cursor) {
418 match event {
419 Event::End(_) => break,
420 Event::Rule => {
421 let source = source.clone();
422 *cursor += 1;
423 blocks.push(block("rule", String::new(), &source, lines, vec![]));
424 }
425 Event::Start(tag) if !is_inline_tag(tag) => {
426 let source = source.clone();
427 *cursor += 1;
428 blocks.extend(read_block(tag, &source, events, lines, cursor));
429 }
430 _ => {
435 let first = *cursor;
436 let text = read_loose_inline(events, cursor);
437 if !text.is_empty() {
438 let source = covered_range(events, first, *cursor);
439 blocks.push(block("paragraph", text, &source, lines, vec![]));
440 }
441 }
442 }
443 }
444 blocks
445}
446
447fn is_inline_tag(tag: &Tag<'_>) -> bool {
449 matches!(
450 tag,
451 Tag::Emphasis
452 | Tag::Strong
453 | Tag::Strikethrough
454 | Tag::Superscript
455 | Tag::Subscript
456 | Tag::Link { .. }
457 | Tag::Image { .. }
458 )
459}
460
461fn read_block(
463 tag: &Tag<'_>,
464 source: &Range<usize>,
465 events: &[SpannedEvent<'_>],
466 lines: &LineIndex<'_>,
467 cursor: &mut usize,
468) -> Option<Value> {
469 match tag {
470 Tag::Paragraph => Some(block(
471 "paragraph",
472 read_inline(events, cursor),
473 source,
474 lines,
475 vec![],
476 )),
477 Tag::Heading { level, .. } => Some(block(
478 "heading",
479 read_inline(events, cursor),
480 source,
481 lines,
482 vec![("level", Value::Integer(*level as i64))],
483 )),
484 Tag::CodeBlock(kind) => {
485 let info = match kind {
488 CodeBlockKind::Fenced(info) => info.trim().to_string(),
489 CodeBlockKind::Indented => String::new(),
490 };
491 Some(block(
492 "code",
493 read_verbatim(events, cursor),
494 source,
495 lines,
496 vec![("language", Value::String(info))],
503 ))
504 }
505 Tag::HtmlBlock => Some(block(
506 "html",
507 read_verbatim(events, cursor),
508 source,
509 lines,
510 vec![],
511 )),
512 Tag::MetadataBlock(kind) => {
519 skip_subtree(events, cursor);
523 Some(block(
524 "frontmatter",
525 String::new(),
526 source,
527 lines,
528 vec![(
529 "format",
530 Value::String(
531 match kind {
532 MetadataBlockKind::PlusesStyle => "toml",
533 MetadataBlockKind::YamlStyle => "yaml",
534 }
535 .to_string(),
536 ),
537 )],
538 ))
539 }
540 Tag::BlockQuote(_) => Some(block(
541 "blockquote",
542 read_container(events, lines, cursor),
543 source,
544 lines,
545 vec![],
546 )),
547 Tag::List(first_number) => Some(block(
548 "list",
549 read_container(events, lines, cursor),
550 source,
551 lines,
552 vec![("ordered", Value::Bool(first_number.is_some()))],
553 )),
554 Tag::Item => Some(block(
559 "item",
560 read_container(events, lines, cursor),
561 source,
562 lines,
563 vec![],
564 )),
565 _ => {
570 skip_subtree(events, cursor);
571 None
572 }
573 }
574}
575
576fn read_container(
584 events: &[SpannedEvent<'_>],
585 lines: &LineIndex<'_>,
586 cursor: &mut usize,
587) -> String {
588 let children = read_blocks(events, lines, cursor);
589 *cursor += 1;
591 children
592 .iter()
593 .filter_map(|child| child.get("text").and_then(Value::as_str))
594 .filter(|text| !text.is_empty())
595 .collect::<Vec<_>>()
596 .join("\n")
597}
598
599fn read_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
608 let text = read_loose_inline(events, cursor);
609 if matches!(events.get(*cursor), Some((Event::End(_), _))) {
610 *cursor += 1;
611 }
612 text
613}
614
615fn read_loose_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
619 let mut text = String::new();
620 let mut depth = 0usize;
621 while let Some((event, _)) = events.get(*cursor) {
622 match event {
623 Event::Start(tag) if depth == 0 && !is_inline_tag(tag) => break,
624 Event::End(_) if depth == 0 => break,
625 Event::Rule if depth == 0 => break,
626 Event::Start(_) => {
627 depth += 1;
628 *cursor += 1;
629 }
630 Event::End(_) => {
631 depth -= 1;
632 *cursor += 1;
633 }
634 Event::Text(chunk) | Event::Code(chunk) => {
635 text.push_str(chunk);
636 *cursor += 1;
637 }
638 Event::SoftBreak | Event::HardBreak => {
639 text.push(' ');
640 *cursor += 1;
641 }
642 _ => *cursor += 1,
643 }
644 }
645 text.trim().to_string()
646}
647
648fn read_verbatim(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
654 let mut text = String::new();
655 while let Some((event, _)) = events.get(*cursor) {
656 *cursor += 1;
657 match event {
658 Event::End(_) => break,
659 Event::Text(chunk) | Event::Html(chunk) => text.push_str(chunk),
660 _ => {}
661 }
662 }
663 let unterminated = text.strip_suffix('\n').unwrap_or(text.as_str());
664 unterminated
665 .strip_suffix('\r')
666 .unwrap_or(unterminated)
667 .to_string()
668}
669
670fn skip_subtree(events: &[SpannedEvent<'_>], cursor: &mut usize) {
672 let mut depth = 0usize;
673 while let Some((event, _)) = events.get(*cursor) {
674 *cursor += 1;
675 match event {
676 Event::Start(_) => depth += 1,
677 Event::End(_) => {
678 if depth == 0 {
679 break;
680 }
681 depth -= 1;
682 }
683 _ => {}
684 }
685 }
686}
687
688fn covered_range(events: &[SpannedEvent<'_>], start: usize, end: usize) -> Range<usize> {
694 let mut covered = events
695 .get(start)
696 .map(|(_, source)| source.clone())
697 .unwrap_or(0..0);
698 for (_, source) in events.get(start..end).unwrap_or_default() {
699 covered.start = covered.start.min(source.start);
700 covered.end = covered.end.max(source.end);
701 }
702 covered
703}
704
705fn block(
708 kind: &str,
709 text: String,
710 source: &Range<usize>,
711 lines: &LineIndex<'_>,
712 extra: Vec<(&str, Value)>,
713) -> Value {
714 let source = lines.range(source);
715 let mut fields = BTreeMap::from([
716 ("type".to_string(), Value::String(kind.to_string())),
717 ("text".to_string(), Value::String(text)),
718 (
719 "source_start_line".to_string(),
720 Value::Integer(source.start),
721 ),
722 ("source_end_line".to_string(), Value::Integer(source.end)),
723 ]);
724 for (name, value) in extra {
725 fields.insert(name.to_string(), value);
726 }
727 Value::Object(fields)
728}
729
730#[cfg(test)]
731mod tests {
732 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
733 use super::*;
734 use crate::document::{Addressing, Format, get_path};
735
736 fn at(source: &str, path: &str) -> DocumentResult<Value> {
739 get_path(
740 &load(source).unwrap(),
741 path,
742 Addressing::INDEX_ONLY.with_array_rule(Format::Markdown.array_rule()),
743 )
744 }
745
746 fn text(source: &str, path: &str) -> String {
747 at(source, path)
748 .unwrap_or_else(|error| panic!("{path}: {error}"))
749 .as_str()
750 .unwrap_or_else(|| panic!("{path} is not a string"))
751 .to_string()
752 }
753
754 fn integer(source: &str, path: &str) -> i64 {
755 at(source, path)
756 .unwrap_or_else(|error| panic!("{path}: {error}"))
757 .as_integer()
758 .unwrap_or_else(|| panic!("{path} is not an integer"))
759 }
760
761 fn shape(source: &str, path: &str) -> Vec<(String, String)> {
763 at(source, path)
764 .unwrap_or_else(|error| panic!("{path}: {error}"))
765 .as_array()
766 .unwrap_or_else(|| panic!("{path} is not an array"))
767 .iter()
768 .map(|block| {
769 let field = |name: &str| {
770 block
771 .get(name)
772 .and_then(Value::as_str)
773 .unwrap_or_default()
774 .to_string()
775 };
776 (field("type"), field("text"))
777 })
778 .collect()
779 }
780
781 fn types(source: &str, path: &str) -> Vec<String> {
782 shape(source, path).into_iter().map(|(k, _)| k).collect()
783 }
784
785 #[test]
792 fn probe_1_setext_heading_is_a_heading() {
793 let source = "Title\n=====\n\nThe lead.\n";
795 assert_eq!(text(source, "h1.0.text"), "Title");
796 assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
797 }
798
799 #[test]
800 fn probe_2_html_comment_before_the_heading_is_its_own_block() {
801 let source = "<!-- generated -->\n\n# Real\n\nThe lead.\n";
804 assert_eq!(
805 shape(source, "preamble.blocks"),
806 [("html".to_string(), "<!-- generated -->".to_string())]
807 );
808 assert_eq!(text(source, "h1.0.text"), "Real");
809 }
810
811 #[test]
812 fn probe_3_badge_line_before_the_heading_is_a_paragraph() {
813 let source = "[](b)\n\n# Real\n\nThe lead.\n";
817 assert_eq!(
818 shape(source, "preamble.blocks"),
819 [("paragraph".to_string(), "CI".to_string())]
820 );
821 assert_eq!(text(source, "h1.0.text"), "Real");
822 assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
823 }
824
825 #[test]
826 fn probe_4_fenced_code_in_the_lead_position_is_code() {
827 let source = "# Real\n\n```bash\nafdata get x\n```\n";
830 assert_eq!(
831 shape(source, "h1.0.blocks"),
832 [("code".to_string(), "afdata get x".to_string())]
833 );
834 assert_eq!(shape(source, "h1.0.paragraph"), []);
835 assert_eq!(
836 at(source, "h1.0.paragraph.0").unwrap_err().code(),
837 "document_path_not_found"
838 );
839 assert_eq!(
840 at(source, "h1.0.blocks.0.language").unwrap(),
841 Value::String("bash".to_string())
842 );
843 }
844
845 #[test]
846 fn probe_5_leading_fence_swallows_the_heading_inside_it() {
847 let source = "```\n# Real\n```\n";
850 assert_eq!(
851 shape(source, "preamble.blocks"),
852 [("code".to_string(), "# Real".to_string())]
853 );
854 assert_eq!(
855 at(source, "h1.0").unwrap_err().code(),
856 "document_path_not_found"
857 );
858 }
859
860 #[test]
861 fn probe_6_atx_heading_interrupts_a_paragraph() {
862 let source = "# Real\n\nThe lead.\n# looks like heading\nmore.\n";
866 assert_eq!(text(source, "h1.0.text"), "Real");
867 assert_eq!(
868 shape(source, "h1.0.paragraph"),
869 [("paragraph".to_string(), "The lead.".to_string())]
870 );
871 assert_eq!(text(source, "h1.1.text"), "looks like heading");
872 assert_eq!(text(source, "h1.1.paragraph.0.text"), "more.");
873 }
874
875 #[test]
876 fn probe_7_four_space_indent_is_code_not_a_heading() {
877 let source = " # Indented\n\nAfter.\n";
880 assert_eq!(
881 types(source, "preamble.blocks"),
882 ["code".to_string(), "paragraph".to_string()]
883 );
884 assert_eq!(
885 at(source, "h1.0").unwrap_err().code(),
886 "document_path_not_found"
887 );
888 }
889
890 #[test]
891 fn probe_8_whole_paragraph_emphasis_is_unwrapped() {
892 assert_eq!(
895 text("# Real\n\n**A bold tagline.**\n", "h1.0.paragraph.0.text"),
896 "A bold tagline."
897 );
898 assert_eq!(
901 text(
902 "# T\n\nA **bold** `span` and a [link](https://example.com).\n",
903 "h1.0.paragraph.0.text"
904 ),
905 "A bold span and a link."
906 );
907 }
908
909 #[test]
912 fn headings_nest_by_level() {
913 let source = "# A\n\na.\n\n## B\n\nb.\n\n### C\n\nc.\n\n## D\n\nd.\n\n# E\n";
914 assert_eq!(text(source, "h1.0.text"), "A");
915 assert_eq!(text(source, "h1.0.paragraph.0.text"), "a.");
916 assert_eq!(text(source, "h1.0.h2.0.text"), "B");
917 assert_eq!(text(source, "h1.0.h2.0.h3.0.text"), "C");
918 assert_eq!(text(source, "h1.0.h2.0.h3.0.paragraph.0.text"), "c.");
919 assert_eq!(text(source, "h1.0.h2.1.text"), "D");
921 assert_eq!(
922 at(source, "h1.0.h2.1.h3.0").unwrap_err().code(),
923 "document_path_not_found"
924 );
925 assert_eq!(text(source, "h1.1.text"), "E");
926 }
927
928 #[test]
929 fn a_skipped_level_keeps_its_own_name() {
930 let source = "# A\n\n### C\n\nc.\n";
933 assert_eq!(text(source, "h1.0.h3.0.text"), "C");
934 assert_eq!(
935 at(source, "h1.0.h2.0").unwrap_err().code(),
936 "document_path_not_found"
937 );
938 }
939
940 #[test]
941 fn a_document_opening_below_h1_has_no_h1() {
942 let source = "## Only\n\ntext.\n";
945 assert_eq!(text(source, "h2.0.text"), "Only");
946 assert_eq!(
947 at(source, "h1.0").unwrap_err().code(),
948 "document_path_not_found"
949 );
950 }
951
952 #[test]
953 fn preamble_is_always_present_and_empty_for_a_clean_file() {
954 assert_eq!(shape("# T\n\nlead\n", "preamble.blocks"), []);
955 assert_eq!(shape("", "preamble.blocks"), []);
956 }
957
958 #[test]
961 fn a_section_is_addressable_by_a_word_of_its_heading() {
962 let source = "# T\n\n## A Quick Look\n\ninside look.\n\n## Supported suffixes\n\ns.\n";
963 assert_eq!(text(source, "h1.0.h2.look.text"), "A Quick Look");
965 assert_eq!(
966 text(source, "h1.0.h2.look.paragraph.0.text"),
967 "inside look."
968 );
969 assert_eq!(text(source, "h1.0.h2.SUFFIX.text"), "Supported suffixes");
970 assert_eq!(
972 at(source, "h1.0.h2.look.text").unwrap(),
973 at(source, "h1.0.h2.0.text").unwrap()
974 );
975 assert_eq!(
978 at(source, "h1.0.h2.inside").unwrap_err().code(),
979 "document_slug_not_found"
980 );
981 }
982
983 #[test]
984 fn an_empty_segment_is_not_an_address() {
985 let one = "# T\n\n## Only One\n\na\n";
990 let two = "# T\n\n## A\n\na\n\n## B\n\nb\n";
991 for source in [one, two] {
992 assert_eq!(
993 at(source, "h1.0.h2..text").unwrap_err().code(),
994 "document_slug_not_found"
995 );
996 }
997 assert_eq!(text(two, "h1.0.h2.A.text"), "A");
999 }
1000
1001 #[test]
1002 fn a_word_matching_several_sections_is_refused() {
1003 let source = "# T\n\n## Quick look\n\na.\n\n## Another look\n\nb.\n";
1004 let error = at(source, "h1.0.h2.look").unwrap_err();
1005 assert_eq!(error.code(), "document_ambiguous_match");
1006 let message = error.to_string();
1008 assert!(message.contains("indices 0, 1"), "{message}");
1009 assert!(!message.contains("Quick look"), "{message}");
1010 assert!(!message.contains("Another look"), "{message}");
1011 assert_eq!(text(source, "h1.0.h2.Another.text"), "Another look");
1013 }
1014
1015 #[test]
1016 fn content_addressing_lowercases_unicode() {
1017 let source = "# T\n\n## Überblick\n\ninside.\n";
1018 assert_eq!(text(source, "h1.0.h2.ÜBER.text"), "Überblick");
1019 }
1020
1021 #[test]
1022 fn the_ask_prompt_blockquote_is_addressable_by_its_opening_words() {
1023 let source = "# T\n\nThe lead.\n\n> **Ask your agent:** \"Do the thing.\"\n";
1026 assert_eq!(
1027 text(source, "h1.0.blocks.Ask your agent.text"),
1028 "Ask your agent: \"Do the thing.\""
1029 );
1030 }
1031
1032 #[test]
1035 fn wrapped_paragraph_joins_onto_one_line() {
1036 assert_eq!(
1037 text("# T\n\nLead line one\nline two.\n", "h1.0.paragraph.0.text"),
1038 "Lead line one line two."
1039 );
1040 }
1041
1042 #[test]
1043 fn heading_level_is_reported_for_every_depth() {
1044 let source = "# a\n\n## b\n\n###### f\n";
1045 assert_eq!(at(source, "h1.0.level").unwrap(), Value::Integer(1));
1046 assert_eq!(at(source, "h1.0.h2.0.level").unwrap(), Value::Integer(2));
1047 assert_eq!(
1048 at(source, "h1.0.h2.0.h6.0.level").unwrap(),
1049 Value::Integer(6)
1050 );
1051 }
1052
1053 #[test]
1054 fn blockquote_flattens_its_paragraphs() {
1055 assert_eq!(
1057 shape(
1058 "> **Ask your agent:** \"Wrapped across\n> two lines.\"\n",
1059 "preamble.blocks"
1060 ),
1061 [(
1062 "blockquote".to_string(),
1063 "Ask your agent: \"Wrapped across two lines.\"".to_string()
1064 )]
1065 );
1066 assert_eq!(
1069 shape("> first\n>\n> second\n", "preamble.blocks"),
1070 [("blockquote".to_string(), "first\nsecond".to_string())]
1071 );
1072 }
1073
1074 #[test]
1075 fn list_reports_its_items_and_whether_it_is_ordered() {
1076 let bullet = at("- one\n- two\n", "preamble.blocks.0").unwrap();
1077 assert_eq!(bullet.get("text").and_then(Value::as_str), Some("one\ntwo"));
1078 assert_eq!(bullet.get("ordered"), Some(&Value::Bool(false)));
1079
1080 assert_eq!(
1081 at("1. one\n2. two\n", "preamble.blocks.0")
1082 .unwrap()
1083 .get("ordered"),
1084 Some(&Value::Bool(true))
1085 );
1086
1087 assert_eq!(
1091 at("- one\n\n- two\n", "preamble.blocks.0")
1092 .unwrap()
1093 .get("text")
1094 .and_then(Value::as_str),
1095 Some("one\ntwo")
1096 );
1097
1098 assert_eq!(
1100 at("- one\n - inner\n- two\n", "preamble.blocks.0")
1101 .unwrap()
1102 .get("text")
1103 .and_then(Value::as_str),
1104 Some("one\ninner\ntwo")
1105 );
1106 }
1107
1108 #[test]
1109 fn a_leading_metadata_block_is_its_own_kind() {
1110 let toml = "+++\ntitle = \"T\"\n\n[extra]\ntagline = \"x\"\n+++\n\n# Real\n\nThe lead.\n";
1114 assert_eq!(types(toml, "preamble.blocks"), ["frontmatter".to_string()]);
1115 assert_eq!(
1116 at(toml, "preamble.blocks.0.format").unwrap(),
1117 Value::String("toml".to_string())
1118 );
1119 assert_eq!(text(toml, "preamble.blocks.0.text"), "");
1123 assert_eq!(text(toml, "h1.0.text"), "Real");
1125 assert_eq!(text(toml, "h1.0.paragraph.0.text"), "The lead.");
1126
1127 let yaml = "---\ntitle: T\n---\n\n# Real\n";
1128 assert_eq!(types(yaml, "preamble.blocks"), ["frontmatter".to_string()]);
1129 assert_eq!(
1130 at(yaml, "preamble.blocks.0.format").unwrap(),
1131 Value::String("yaml".to_string())
1132 );
1133 assert_eq!(text(yaml, "h1.0.text"), "Real");
1134 }
1135
1136 #[test]
1137 fn dashes_away_from_the_start_keep_their_commonmark_meaning() {
1138 assert_eq!(text("Setext\n---\n\nbody\n", "h2.0.text"), "Setext");
1142 assert_eq!(
1143 types("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1144 [
1145 "paragraph".to_string(),
1146 "rule".to_string(),
1147 "paragraph".to_string()
1148 ]
1149 );
1150 }
1151
1152 #[test]
1153 fn a_source_range_stops_at_the_block_it_names() {
1154 let source = "- a\n - b\n\n\nAfter.\n";
1160 assert_eq!(
1161 at(source, "preamble.blocks.0.type").unwrap(),
1162 Value::String("list".to_string())
1163 );
1164 assert_eq!(
1165 at(source, "preamble.blocks.0.source_start_line").unwrap(),
1166 Value::Integer(1)
1167 );
1168 assert_eq!(
1169 at(source, "preamble.blocks.0.source_end_line").unwrap(),
1170 Value::Integer(2)
1171 );
1172 assert_eq!(
1173 at(source, "preamble.blocks.1.source_start_line").unwrap(),
1174 Value::Integer(5)
1175 );
1176
1177 let mixed = "# H\n\npara\n\n```\ncode\n```\n\n> quote\n\n---\n\ntail\n";
1180 for (address, start, end) in [
1181 ("h1.0.blocks.0", 3, 3),
1182 ("h1.0.blocks.1", 5, 7),
1183 ("h1.0.blocks.2", 9, 9),
1184 ("h1.0.blocks.3", 11, 11),
1185 ("h1.0.blocks.4", 13, 13),
1186 ] {
1187 assert_eq!(
1188 at(mixed, &format!("{address}.source_start_line")).unwrap(),
1189 Value::Integer(start),
1190 "{address} start"
1191 );
1192 assert_eq!(
1193 at(mixed, &format!("{address}.source_end_line")).unwrap(),
1194 Value::Integer(end),
1195 "{address} end"
1196 );
1197 }
1198 }
1199
1200 #[test]
1201 fn gfm_table_rows_are_a_paragraph() {
1202 assert_eq!(
1205 shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.blocks"),
1206 [(
1207 "paragraph".to_string(),
1208 "| a | b | |---|---| | 1 | 2 |".to_string()
1209 )]
1210 );
1211 assert_eq!(
1212 shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.paragraph"),
1213 [(
1214 "paragraph".to_string(),
1215 "| a | b | |---|---| | 1 | 2 |".to_string()
1216 )]
1217 );
1218 }
1219
1220 #[test]
1221 fn badge_syntax_remains_in_the_paragraph_view() {
1222 let source = "# T\n\n[](b)\n\nThe lead.\n";
1223 assert_eq!(
1224 shape(source, "h1.0.paragraph"),
1225 [
1226 ("paragraph".to_string(), "CI".to_string()),
1227 ("paragraph".to_string(), "The lead.".to_string()),
1228 ]
1229 );
1230 }
1231
1232 #[test]
1235 fn atx_heading_and_blocks_report_inclusive_source_lines() {
1236 let source = "# Title\n\nLead line one\nline two.\n\n```rs\nfn main() {}\n```\n";
1237 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1238 assert_eq!(integer(source, "h1.0.source_end_line"), 8);
1239 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1240 assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1241 assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1242 assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1243 assert_eq!(integer(source, "h1.0.blocks.1.source_start_line"), 6);
1244 assert_eq!(integer(source, "h1.0.blocks.1.source_end_line"), 8);
1245 }
1246
1247 #[test]
1248 fn setext_heading_range_includes_its_underline() {
1249 let source = "My Project\n==========\n\nThe synopsis.\n\n## Install\n";
1250 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1251 assert_eq!(integer(source, "h1.0.heading_end_line"), 2);
1252 assert_eq!(integer(source, "h1.0.source_end_line"), 6);
1253 assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1254 assert_eq!(integer(source, "h1.0.h2.0.source_end_line"), 6);
1255 assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 4);
1256 assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1257 }
1258
1259 #[test]
1260 fn line_ranges_are_utf8_safe_and_newline_style_independent() {
1261 let source = "# 中文标题\r\n\r\n这是首段,\r\n也是首段。\r\n\r\n## 安装";
1262 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1263 assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1264 assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1265 assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1266 assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1267 assert_eq!(integer(source, "h1.0.h2.0.heading_end_line"), 6);
1268 }
1269
1270 #[test]
1271 fn nesting_beyond_the_block_depth_is_refused_not_a_stack_overflow() {
1272 let nested = |depth: usize| format!("# T\n{}x\n", "> ".repeat(depth));
1273 assert_eq!(text(&nested(127), "h1.0.blockquote.0.text"), "x");
1275 for depth in [128, 100_000] {
1276 let error = load(&nested(depth)).unwrap_err();
1277 assert_eq!(error.code(), "document_source_refused");
1278 assert!(error.to_string().contains("128 levels"), "{error}");
1279 }
1280 let error = load(&format!("{}x\n", "- ".repeat(100_000))).unwrap_err();
1281 assert_eq!(error.code(), "document_source_refused");
1282 }
1283
1284 #[test]
1285 fn a_bare_carriage_return_is_refused_rather_than_numbered() {
1286 let error = load("# T\r\rLead\rcontinued\r\r## End").unwrap_err();
1292 assert_eq!(error.code(), "document_source_refused");
1296 assert!(error.to_string().contains("carriage return"), "{error}");
1297 assert!(
1301 error.redacted_message().contains("CRLF line endings"),
1302 "{}",
1303 error.redacted_message()
1304 );
1305
1306 let crlf = "# T\r\n\r\nLead\r\ncontinued\r\n\r\n## End\r\n";
1309 assert_eq!(integer(crlf, "h1.0.paragraph.0.source_start_line"), 3);
1310 assert_eq!(integer(crlf, "h1.0.paragraph.0.source_end_line"), 4);
1311 assert_eq!(integer(crlf, "h1.0.h2.0.source_start_line"), 6);
1312
1313 let bare = "# T\n\nLead";
1315 assert_eq!(integer(bare, "h1.0.paragraph.0.source_end_line"), 3);
1316 }
1317
1318 #[test]
1319 fn frontmatter_range_includes_both_delimiters() {
1320 let source = "---\ntitle: T\nnested:\n token_secret: hidden\n---\n\n# T\n";
1321 assert_eq!(integer(source, "preamble.blocks.0.source_start_line"), 1);
1322 assert_eq!(integer(source, "preamble.blocks.0.source_end_line"), 5);
1323 assert_eq!(text(source, "preamble.blocks.0.text"), "");
1324 }
1325
1326 #[test]
1327 fn thematic_break_is_a_block_with_no_text() {
1328 assert_eq!(
1329 shape("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1330 [
1331 ("paragraph".to_string(), "a".to_string()),
1332 ("rule".to_string(), String::new()),
1333 ("paragraph".to_string(), "b".to_string()),
1334 ]
1335 );
1336 assert_eq!(
1339 shape("# T\n\na\n\n---\n\nb\n", "h1.0.paragraph"),
1340 [
1341 ("paragraph".to_string(), "a".to_string()),
1342 ("paragraph".to_string(), "b".to_string()),
1343 ]
1344 );
1345 }
1346
1347 #[test]
1348 fn a_byte_order_mark_makes_the_first_block_a_paragraph() {
1349 assert_eq!(
1354 types("\u{feff}# Title\n\nlead\n", "preamble.blocks"),
1355 ["paragraph".to_string(), "paragraph".to_string()]
1356 );
1357 }
1358
1359 #[test]
1360 fn empty_document_has_no_blocks() {
1361 assert_eq!(shape("", "preamble.blocks"), []);
1362 assert_eq!(shape("\n\n \n", "preamble.blocks"), []);
1363 }
1364
1365 #[test]
1366 fn code_block_keeps_its_lines_and_drops_one_trailing_newline() {
1367 assert_eq!(
1368 shape("```\nline one\nline two\n```\n", "preamble.blocks"),
1369 [("code".to_string(), "line one\nline two".to_string())]
1370 );
1371 }
1372}