1use std::{collections::BTreeMap, ops::Range};
72
73use pulldown_cmark::{CodeBlockKind, Event, MetadataBlockKind, Options, Parser, Tag};
74
75use crate::document::{DocumentError, DocumentResult, Value};
76
77pub fn load(content: &str) -> DocumentResult<Value> {
83 reject_lone_carriage_return(content)?;
84 let events: Vec<SpannedEvent<'_>> = Parser::new_ext(content, options())
85 .into_offset_iter()
86 .collect();
87 let lines = LineIndex::new(content);
88 let mut cursor = 0;
89 Ok(fold_sections(read_blocks(&events, &lines, &mut cursor)))
90}
91
92type SpannedEvent<'a> = (Event<'a>, Range<usize>);
93
94struct LineIndex<'a> {
100 content: &'a str,
101 starts: Vec<usize>,
102}
103
104impl<'a> LineIndex<'a> {
105 fn new(content: &'a str) -> Self {
117 let mut starts = vec![0];
118 for (offset, byte) in content.bytes().enumerate() {
119 if byte == b'\n' {
120 starts.push(offset + 1);
121 }
122 }
123 Self { content, starts }
124 }
125
126 fn number_at(&self, offset: usize) -> i64 {
127 let line = self.starts.partition_point(|start| *start <= offset);
128 i64::try_from(line).unwrap_or(i64::MAX)
129 }
130
131 fn range(&self, source: &Range<usize>) -> SourceLines {
140 let content_end = self
141 .content
142 .get(..source.end)
143 .map_or(source.end, |head| head.trim_end().len())
144 .max(source.start);
145 let end_offset = content_end.saturating_sub(1).max(source.start);
146 SourceLines {
147 start: self.number_at(source.start),
148 end: self.number_at(end_offset),
149 }
150 }
151}
152
153#[derive(Clone, Copy)]
154struct SourceLines {
155 start: i64,
156 end: i64,
157}
158
159fn reject_lone_carriage_return(content: &str) -> DocumentResult<()> {
173 let bytes = content.as_bytes();
174 for (offset, byte) in bytes.iter().enumerate() {
175 if *byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n') {
176 return Err(DocumentError::SourceRefused {
177 format: "Markdown".to_string(),
178 detail: format!(
179 "a bare carriage return at byte {offset} ends a line for CommonMark but not \
180 for the tools these line numbers are meant to feed; convert the file to LF \
181 or CRLF line endings"
182 ),
183 });
184 }
185 }
186 Ok(())
187}
188
189fn options() -> Options {
204 Options::ENABLE_YAML_STYLE_METADATA_BLOCKS | Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS
205}
206
207struct Section {
209 level: i64,
210 text: String,
211 heading_lines: SourceLines,
212 blocks: Vec<Value>,
213 children: Vec<Section>,
214}
215
216fn fold_sections(blocks: Vec<Value>) -> Value {
224 let mut preamble = Vec::new();
225 let mut roots: Vec<Section> = Vec::new();
226 let mut open: Vec<Section> = Vec::new();
227
228 for block in blocks {
229 let heading_level = (block.get("type").and_then(Value::as_str) == Some("heading"))
230 .then(|| block.get("level").and_then(Value::as_integer))
231 .flatten();
232 match heading_level {
233 Some(level) => {
234 while open.last().is_some_and(|section| section.level >= level) {
235 close_section(&mut open, &mut roots);
236 }
237 open.push(Section {
238 level,
239 text: block
240 .get("text")
241 .and_then(Value::as_str)
242 .unwrap_or_default()
243 .to_string(),
244 heading_lines: SourceLines {
245 start: block
246 .get("source_start_line")
247 .and_then(Value::as_integer)
248 .unwrap_or_default(),
249 end: block
250 .get("source_end_line")
251 .and_then(Value::as_integer)
252 .unwrap_or_default(),
253 },
254 blocks: Vec::new(),
255 children: Vec::new(),
256 });
257 }
258 None => match open.last_mut() {
263 Some(section) => section.blocks.push(block),
264 None => preamble.push(block),
265 },
266 }
267 }
268 while !open.is_empty() {
269 close_section(&mut open, &mut roots);
270 }
271
272 let mut root = BTreeMap::from([("preamble".to_string(), Value::Object(block_views(preamble)))]);
277 insert_sections(&mut root, roots);
278 Value::Object(root)
279}
280
281fn close_section(open: &mut Vec<Section>, roots: &mut Vec<Section>) {
284 let Some(done) = open.pop() else { return };
285 match open.last_mut() {
286 Some(parent) => parent.children.push(done),
287 None => roots.push(done),
288 }
289}
290
291fn insert_sections(target: &mut BTreeMap<String, Value>, sections: Vec<Section>) {
294 for section in sections {
295 let key = format!("h{}", section.level);
296 let group = target
297 .entry(key)
298 .or_insert_with(|| Value::Array(Vec::new()));
299 if let Some(items) = group.as_array_mut() {
300 items.push(section.into_value());
301 }
302 }
303}
304
305fn block_views(blocks: Vec<Value>) -> BTreeMap<String, Value> {
316 let of_kind = |kind: &str| {
317 Value::Array(
318 blocks
319 .iter()
320 .filter(|block| block.get("type").and_then(Value::as_str) == Some(kind))
321 .cloned()
322 .collect::<Vec<Value>>(),
323 )
324 };
325 let paragraph = of_kind("paragraph");
326 let blockquote = of_kind("blockquote");
327 BTreeMap::from([
328 ("paragraph".to_string(), paragraph),
329 ("blockquote".to_string(), blockquote),
330 ("blocks".to_string(), Value::Array(blocks)),
331 ])
332}
333
334impl Section {
335 fn into_value(self) -> Value {
336 let source_end_line = self.source_end_line();
337 let mut fields = block_views(self.blocks);
338 fields.insert("level".to_string(), Value::Integer(self.level));
339 fields.insert("text".to_string(), Value::String(self.text));
340 fields.insert(
341 "source_start_line".to_string(),
342 Value::Integer(self.heading_lines.start),
343 );
344 fields.insert(
345 "source_end_line".to_string(),
346 Value::Integer(source_end_line),
347 );
348 fields.insert(
349 "heading_end_line".to_string(),
350 Value::Integer(self.heading_lines.end),
351 );
352 insert_sections(&mut fields, self.children);
353 Value::Object(fields)
354 }
355
356 fn source_end_line(&self) -> i64 {
357 self.blocks
358 .iter()
359 .filter_map(|block| block.get("source_end_line"))
360 .filter_map(Value::as_integer)
361 .chain(self.children.iter().map(Section::source_end_line))
362 .max()
363 .unwrap_or(self.heading_lines.end)
364 }
365}
366
367fn read_blocks(
371 events: &[SpannedEvent<'_>],
372 lines: &LineIndex<'_>,
373 cursor: &mut usize,
374) -> Vec<Value> {
375 let mut blocks = Vec::new();
376 while let Some((event, source)) = events.get(*cursor) {
377 match event {
378 Event::End(_) => break,
379 Event::Rule => {
380 let source = source.clone();
381 *cursor += 1;
382 blocks.push(block("rule", String::new(), &source, lines, vec![]));
383 }
384 Event::Start(tag) if !is_inline_tag(tag) => {
385 let source = source.clone();
386 *cursor += 1;
387 blocks.extend(read_block(tag, &source, events, lines, cursor));
388 }
389 _ => {
394 let first = *cursor;
395 let text = read_loose_inline(events, cursor);
396 if !text.is_empty() {
397 let source = covered_range(events, first, *cursor);
398 blocks.push(block("paragraph", text, &source, lines, vec![]));
399 }
400 }
401 }
402 }
403 blocks
404}
405
406fn is_inline_tag(tag: &Tag<'_>) -> bool {
408 matches!(
409 tag,
410 Tag::Emphasis
411 | Tag::Strong
412 | Tag::Strikethrough
413 | Tag::Superscript
414 | Tag::Subscript
415 | Tag::Link { .. }
416 | Tag::Image { .. }
417 )
418}
419
420fn read_block(
422 tag: &Tag<'_>,
423 source: &Range<usize>,
424 events: &[SpannedEvent<'_>],
425 lines: &LineIndex<'_>,
426 cursor: &mut usize,
427) -> Option<Value> {
428 match tag {
429 Tag::Paragraph => Some(block(
430 "paragraph",
431 read_inline(events, cursor),
432 source,
433 lines,
434 vec![],
435 )),
436 Tag::Heading { level, .. } => Some(block(
437 "heading",
438 read_inline(events, cursor),
439 source,
440 lines,
441 vec![("level", Value::Integer(*level as i64))],
442 )),
443 Tag::CodeBlock(kind) => {
444 let info = match kind {
447 CodeBlockKind::Fenced(info) => info.trim().to_string(),
448 CodeBlockKind::Indented => String::new(),
449 };
450 Some(block(
451 "code",
452 read_verbatim(events, cursor),
453 source,
454 lines,
455 vec![("language", Value::String(info))],
462 ))
463 }
464 Tag::HtmlBlock => Some(block(
465 "html",
466 read_verbatim(events, cursor),
467 source,
468 lines,
469 vec![],
470 )),
471 Tag::MetadataBlock(kind) => {
478 skip_subtree(events, cursor);
482 Some(block(
483 "frontmatter",
484 String::new(),
485 source,
486 lines,
487 vec![(
488 "format",
489 Value::String(
490 match kind {
491 MetadataBlockKind::PlusesStyle => "toml",
492 MetadataBlockKind::YamlStyle => "yaml",
493 }
494 .to_string(),
495 ),
496 )],
497 ))
498 }
499 Tag::BlockQuote(_) => Some(block(
500 "blockquote",
501 read_container(events, lines, cursor),
502 source,
503 lines,
504 vec![],
505 )),
506 Tag::List(first_number) => Some(block(
507 "list",
508 read_container(events, lines, cursor),
509 source,
510 lines,
511 vec![("ordered", Value::Bool(first_number.is_some()))],
512 )),
513 Tag::Item => Some(block(
518 "item",
519 read_container(events, lines, cursor),
520 source,
521 lines,
522 vec![],
523 )),
524 _ => {
529 skip_subtree(events, cursor);
530 None
531 }
532 }
533}
534
535fn read_container(
543 events: &[SpannedEvent<'_>],
544 lines: &LineIndex<'_>,
545 cursor: &mut usize,
546) -> String {
547 let children = read_blocks(events, lines, cursor);
548 *cursor += 1;
550 children
551 .iter()
552 .filter_map(|child| child.get("text").and_then(Value::as_str))
553 .filter(|text| !text.is_empty())
554 .collect::<Vec<_>>()
555 .join("\n")
556}
557
558fn read_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
567 let text = read_loose_inline(events, cursor);
568 if matches!(events.get(*cursor), Some((Event::End(_), _))) {
569 *cursor += 1;
570 }
571 text
572}
573
574fn read_loose_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
578 let mut text = String::new();
579 let mut depth = 0usize;
580 while let Some((event, _)) = events.get(*cursor) {
581 match event {
582 Event::Start(tag) if depth == 0 && !is_inline_tag(tag) => break,
583 Event::End(_) if depth == 0 => break,
584 Event::Rule if depth == 0 => break,
585 Event::Start(_) => {
586 depth += 1;
587 *cursor += 1;
588 }
589 Event::End(_) => {
590 depth -= 1;
591 *cursor += 1;
592 }
593 Event::Text(chunk) | Event::Code(chunk) => {
594 text.push_str(chunk);
595 *cursor += 1;
596 }
597 Event::SoftBreak | Event::HardBreak => {
598 text.push(' ');
599 *cursor += 1;
600 }
601 _ => *cursor += 1,
602 }
603 }
604 text.trim().to_string()
605}
606
607fn read_verbatim(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
613 let mut text = String::new();
614 while let Some((event, _)) = events.get(*cursor) {
615 *cursor += 1;
616 match event {
617 Event::End(_) => break,
618 Event::Text(chunk) | Event::Html(chunk) => text.push_str(chunk),
619 _ => {}
620 }
621 }
622 let unterminated = text.strip_suffix('\n').unwrap_or(text.as_str());
623 unterminated
624 .strip_suffix('\r')
625 .unwrap_or(unterminated)
626 .to_string()
627}
628
629fn skip_subtree(events: &[SpannedEvent<'_>], cursor: &mut usize) {
631 let mut depth = 0usize;
632 while let Some((event, _)) = events.get(*cursor) {
633 *cursor += 1;
634 match event {
635 Event::Start(_) => depth += 1,
636 Event::End(_) => {
637 if depth == 0 {
638 break;
639 }
640 depth -= 1;
641 }
642 _ => {}
643 }
644 }
645}
646
647fn covered_range(events: &[SpannedEvent<'_>], start: usize, end: usize) -> Range<usize> {
653 let mut covered = events
654 .get(start)
655 .map(|(_, source)| source.clone())
656 .unwrap_or(0..0);
657 for (_, source) in events.get(start..end).unwrap_or_default() {
658 covered.start = covered.start.min(source.start);
659 covered.end = covered.end.max(source.end);
660 }
661 covered
662}
663
664fn block(
667 kind: &str,
668 text: String,
669 source: &Range<usize>,
670 lines: &LineIndex<'_>,
671 extra: Vec<(&str, Value)>,
672) -> Value {
673 let source = lines.range(source);
674 let mut fields = BTreeMap::from([
675 ("type".to_string(), Value::String(kind.to_string())),
676 ("text".to_string(), Value::String(text)),
677 (
678 "source_start_line".to_string(),
679 Value::Integer(source.start),
680 ),
681 ("source_end_line".to_string(), Value::Integer(source.end)),
682 ]);
683 for (name, value) in extra {
684 fields.insert(name.to_string(), value);
685 }
686 Value::Object(fields)
687}
688
689#[cfg(test)]
690mod tests {
691 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
692 use super::*;
693 use crate::document::{Addressing, Format, get_path};
694
695 fn at(source: &str, path: &str) -> DocumentResult<Value> {
698 get_path(
699 &load(source).unwrap(),
700 path,
701 Addressing::INDEX_ONLY.with_array_rule(Format::Markdown.array_rule()),
702 )
703 }
704
705 fn text(source: &str, path: &str) -> String {
706 at(source, path)
707 .unwrap_or_else(|error| panic!("{path}: {error}"))
708 .as_str()
709 .unwrap_or_else(|| panic!("{path} is not a string"))
710 .to_string()
711 }
712
713 fn integer(source: &str, path: &str) -> i64 {
714 at(source, path)
715 .unwrap_or_else(|error| panic!("{path}: {error}"))
716 .as_integer()
717 .unwrap_or_else(|| panic!("{path} is not an integer"))
718 }
719
720 fn shape(source: &str, path: &str) -> Vec<(String, String)> {
722 at(source, path)
723 .unwrap_or_else(|error| panic!("{path}: {error}"))
724 .as_array()
725 .unwrap_or_else(|| panic!("{path} is not an array"))
726 .iter()
727 .map(|block| {
728 let field = |name: &str| {
729 block
730 .get(name)
731 .and_then(Value::as_str)
732 .unwrap_or_default()
733 .to_string()
734 };
735 (field("type"), field("text"))
736 })
737 .collect()
738 }
739
740 fn types(source: &str, path: &str) -> Vec<String> {
741 shape(source, path).into_iter().map(|(k, _)| k).collect()
742 }
743
744 #[test]
751 fn probe_1_setext_heading_is_a_heading() {
752 let source = "Title\n=====\n\nThe lead.\n";
754 assert_eq!(text(source, "h1.0.text"), "Title");
755 assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
756 }
757
758 #[test]
759 fn probe_2_html_comment_before_the_heading_is_its_own_block() {
760 let source = "<!-- generated -->\n\n# Real\n\nThe lead.\n";
763 assert_eq!(
764 shape(source, "preamble.blocks"),
765 [("html".to_string(), "<!-- generated -->".to_string())]
766 );
767 assert_eq!(text(source, "h1.0.text"), "Real");
768 }
769
770 #[test]
771 fn probe_3_badge_line_before_the_heading_is_a_paragraph() {
772 let source = "[](b)\n\n# Real\n\nThe lead.\n";
776 assert_eq!(
777 shape(source, "preamble.blocks"),
778 [("paragraph".to_string(), "CI".to_string())]
779 );
780 assert_eq!(text(source, "h1.0.text"), "Real");
781 assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
782 }
783
784 #[test]
785 fn probe_4_fenced_code_in_the_lead_position_is_code() {
786 let source = "# Real\n\n```bash\nafdata get x\n```\n";
789 assert_eq!(
790 shape(source, "h1.0.blocks"),
791 [("code".to_string(), "afdata get x".to_string())]
792 );
793 assert_eq!(shape(source, "h1.0.paragraph"), []);
794 assert_eq!(
795 at(source, "h1.0.paragraph.0").unwrap_err().code(),
796 "document_path_not_found"
797 );
798 assert_eq!(
799 at(source, "h1.0.blocks.0.language").unwrap(),
800 Value::String("bash".to_string())
801 );
802 }
803
804 #[test]
805 fn probe_5_leading_fence_swallows_the_heading_inside_it() {
806 let source = "```\n# Real\n```\n";
809 assert_eq!(
810 shape(source, "preamble.blocks"),
811 [("code".to_string(), "# Real".to_string())]
812 );
813 assert_eq!(
814 at(source, "h1.0").unwrap_err().code(),
815 "document_path_not_found"
816 );
817 }
818
819 #[test]
820 fn probe_6_atx_heading_interrupts_a_paragraph() {
821 let source = "# Real\n\nThe lead.\n# looks like heading\nmore.\n";
825 assert_eq!(text(source, "h1.0.text"), "Real");
826 assert_eq!(
827 shape(source, "h1.0.paragraph"),
828 [("paragraph".to_string(), "The lead.".to_string())]
829 );
830 assert_eq!(text(source, "h1.1.text"), "looks like heading");
831 assert_eq!(text(source, "h1.1.paragraph.0.text"), "more.");
832 }
833
834 #[test]
835 fn probe_7_four_space_indent_is_code_not_a_heading() {
836 let source = " # Indented\n\nAfter.\n";
839 assert_eq!(
840 types(source, "preamble.blocks"),
841 ["code".to_string(), "paragraph".to_string()]
842 );
843 assert_eq!(
844 at(source, "h1.0").unwrap_err().code(),
845 "document_path_not_found"
846 );
847 }
848
849 #[test]
850 fn probe_8_whole_paragraph_emphasis_is_unwrapped() {
851 assert_eq!(
854 text("# Real\n\n**A bold tagline.**\n", "h1.0.paragraph.0.text"),
855 "A bold tagline."
856 );
857 assert_eq!(
860 text(
861 "# T\n\nA **bold** `span` and a [link](https://example.com).\n",
862 "h1.0.paragraph.0.text"
863 ),
864 "A bold span and a link."
865 );
866 }
867
868 #[test]
871 fn headings_nest_by_level() {
872 let source = "# A\n\na.\n\n## B\n\nb.\n\n### C\n\nc.\n\n## D\n\nd.\n\n# E\n";
873 assert_eq!(text(source, "h1.0.text"), "A");
874 assert_eq!(text(source, "h1.0.paragraph.0.text"), "a.");
875 assert_eq!(text(source, "h1.0.h2.0.text"), "B");
876 assert_eq!(text(source, "h1.0.h2.0.h3.0.text"), "C");
877 assert_eq!(text(source, "h1.0.h2.0.h3.0.paragraph.0.text"), "c.");
878 assert_eq!(text(source, "h1.0.h2.1.text"), "D");
880 assert_eq!(
881 at(source, "h1.0.h2.1.h3.0").unwrap_err().code(),
882 "document_path_not_found"
883 );
884 assert_eq!(text(source, "h1.1.text"), "E");
885 }
886
887 #[test]
888 fn a_skipped_level_keeps_its_own_name() {
889 let source = "# A\n\n### C\n\nc.\n";
892 assert_eq!(text(source, "h1.0.h3.0.text"), "C");
893 assert_eq!(
894 at(source, "h1.0.h2.0").unwrap_err().code(),
895 "document_path_not_found"
896 );
897 }
898
899 #[test]
900 fn a_document_opening_below_h1_has_no_h1() {
901 let source = "## Only\n\ntext.\n";
904 assert_eq!(text(source, "h2.0.text"), "Only");
905 assert_eq!(
906 at(source, "h1.0").unwrap_err().code(),
907 "document_path_not_found"
908 );
909 }
910
911 #[test]
912 fn preamble_is_always_present_and_empty_for_a_clean_file() {
913 assert_eq!(shape("# T\n\nlead\n", "preamble.blocks"), []);
914 assert_eq!(shape("", "preamble.blocks"), []);
915 }
916
917 #[test]
920 fn a_section_is_addressable_by_a_word_of_its_heading() {
921 let source = "# T\n\n## A Quick Look\n\ninside look.\n\n## Supported suffixes\n\ns.\n";
922 assert_eq!(text(source, "h1.0.h2.look.text"), "A Quick Look");
924 assert_eq!(
925 text(source, "h1.0.h2.look.paragraph.0.text"),
926 "inside look."
927 );
928 assert_eq!(text(source, "h1.0.h2.SUFFIX.text"), "Supported suffixes");
929 assert_eq!(
931 at(source, "h1.0.h2.look.text").unwrap(),
932 at(source, "h1.0.h2.0.text").unwrap()
933 );
934 assert_eq!(
937 at(source, "h1.0.h2.inside").unwrap_err().code(),
938 "document_slug_not_found"
939 );
940 }
941
942 #[test]
943 fn an_empty_segment_is_not_an_address() {
944 let one = "# T\n\n## Only One\n\na\n";
949 let two = "# T\n\n## A\n\na\n\n## B\n\nb\n";
950 for source in [one, two] {
951 assert_eq!(
952 at(source, "h1.0.h2..text").unwrap_err().code(),
953 "document_slug_not_found"
954 );
955 }
956 assert_eq!(text(two, "h1.0.h2.A.text"), "A");
958 }
959
960 #[test]
961 fn a_word_matching_several_sections_is_refused() {
962 let source = "# T\n\n## Quick look\n\na.\n\n## Another look\n\nb.\n";
963 let error = at(source, "h1.0.h2.look").unwrap_err();
964 assert_eq!(error.code(), "document_ambiguous_match");
965 let message = error.to_string();
967 assert!(message.contains("indices 0, 1"), "{message}");
968 assert!(!message.contains("Quick look"), "{message}");
969 assert!(!message.contains("Another look"), "{message}");
970 assert_eq!(text(source, "h1.0.h2.Another.text"), "Another look");
972 }
973
974 #[test]
975 fn content_addressing_lowercases_unicode() {
976 let source = "# T\n\n## Überblick\n\ninside.\n";
977 assert_eq!(text(source, "h1.0.h2.ÜBER.text"), "Überblick");
978 }
979
980 #[test]
981 fn the_ask_prompt_blockquote_is_addressable_by_its_opening_words() {
982 let source = "# T\n\nThe lead.\n\n> **Ask your agent:** \"Do the thing.\"\n";
985 assert_eq!(
986 text(source, "h1.0.blocks.Ask your agent.text"),
987 "Ask your agent: \"Do the thing.\""
988 );
989 }
990
991 #[test]
994 fn wrapped_paragraph_joins_onto_one_line() {
995 assert_eq!(
996 text("# T\n\nLead line one\nline two.\n", "h1.0.paragraph.0.text"),
997 "Lead line one line two."
998 );
999 }
1000
1001 #[test]
1002 fn heading_level_is_reported_for_every_depth() {
1003 let source = "# a\n\n## b\n\n###### f\n";
1004 assert_eq!(at(source, "h1.0.level").unwrap(), Value::Integer(1));
1005 assert_eq!(at(source, "h1.0.h2.0.level").unwrap(), Value::Integer(2));
1006 assert_eq!(
1007 at(source, "h1.0.h2.0.h6.0.level").unwrap(),
1008 Value::Integer(6)
1009 );
1010 }
1011
1012 #[test]
1013 fn blockquote_flattens_its_paragraphs() {
1014 assert_eq!(
1016 shape(
1017 "> **Ask your agent:** \"Wrapped across\n> two lines.\"\n",
1018 "preamble.blocks"
1019 ),
1020 [(
1021 "blockquote".to_string(),
1022 "Ask your agent: \"Wrapped across two lines.\"".to_string()
1023 )]
1024 );
1025 assert_eq!(
1028 shape("> first\n>\n> second\n", "preamble.blocks"),
1029 [("blockquote".to_string(), "first\nsecond".to_string())]
1030 );
1031 }
1032
1033 #[test]
1034 fn list_reports_its_items_and_whether_it_is_ordered() {
1035 let bullet = at("- one\n- two\n", "preamble.blocks.0").unwrap();
1036 assert_eq!(bullet.get("text").and_then(Value::as_str), Some("one\ntwo"));
1037 assert_eq!(bullet.get("ordered"), Some(&Value::Bool(false)));
1038
1039 assert_eq!(
1040 at("1. one\n2. two\n", "preamble.blocks.0")
1041 .unwrap()
1042 .get("ordered"),
1043 Some(&Value::Bool(true))
1044 );
1045
1046 assert_eq!(
1050 at("- one\n\n- two\n", "preamble.blocks.0")
1051 .unwrap()
1052 .get("text")
1053 .and_then(Value::as_str),
1054 Some("one\ntwo")
1055 );
1056
1057 assert_eq!(
1059 at("- one\n - inner\n- two\n", "preamble.blocks.0")
1060 .unwrap()
1061 .get("text")
1062 .and_then(Value::as_str),
1063 Some("one\ninner\ntwo")
1064 );
1065 }
1066
1067 #[test]
1068 fn a_leading_metadata_block_is_its_own_kind() {
1069 let toml = "+++\ntitle = \"T\"\n\n[extra]\ntagline = \"x\"\n+++\n\n# Real\n\nThe lead.\n";
1073 assert_eq!(types(toml, "preamble.blocks"), ["frontmatter".to_string()]);
1074 assert_eq!(
1075 at(toml, "preamble.blocks.0.format").unwrap(),
1076 Value::String("toml".to_string())
1077 );
1078 assert_eq!(text(toml, "preamble.blocks.0.text"), "");
1082 assert_eq!(text(toml, "h1.0.text"), "Real");
1084 assert_eq!(text(toml, "h1.0.paragraph.0.text"), "The lead.");
1085
1086 let yaml = "---\ntitle: T\n---\n\n# Real\n";
1087 assert_eq!(types(yaml, "preamble.blocks"), ["frontmatter".to_string()]);
1088 assert_eq!(
1089 at(yaml, "preamble.blocks.0.format").unwrap(),
1090 Value::String("yaml".to_string())
1091 );
1092 assert_eq!(text(yaml, "h1.0.text"), "Real");
1093 }
1094
1095 #[test]
1096 fn dashes_away_from_the_start_keep_their_commonmark_meaning() {
1097 assert_eq!(text("Setext\n---\n\nbody\n", "h2.0.text"), "Setext");
1101 assert_eq!(
1102 types("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1103 [
1104 "paragraph".to_string(),
1105 "rule".to_string(),
1106 "paragraph".to_string()
1107 ]
1108 );
1109 }
1110
1111 #[test]
1112 fn a_source_range_stops_at_the_block_it_names() {
1113 let source = "- a\n - b\n\n\nAfter.\n";
1119 assert_eq!(
1120 at(source, "preamble.blocks.0.type").unwrap(),
1121 Value::String("list".to_string())
1122 );
1123 assert_eq!(
1124 at(source, "preamble.blocks.0.source_start_line").unwrap(),
1125 Value::Integer(1)
1126 );
1127 assert_eq!(
1128 at(source, "preamble.blocks.0.source_end_line").unwrap(),
1129 Value::Integer(2)
1130 );
1131 assert_eq!(
1132 at(source, "preamble.blocks.1.source_start_line").unwrap(),
1133 Value::Integer(5)
1134 );
1135
1136 let mixed = "# H\n\npara\n\n```\ncode\n```\n\n> quote\n\n---\n\ntail\n";
1139 for (address, start, end) in [
1140 ("h1.0.blocks.0", 3, 3),
1141 ("h1.0.blocks.1", 5, 7),
1142 ("h1.0.blocks.2", 9, 9),
1143 ("h1.0.blocks.3", 11, 11),
1144 ("h1.0.blocks.4", 13, 13),
1145 ] {
1146 assert_eq!(
1147 at(mixed, &format!("{address}.source_start_line")).unwrap(),
1148 Value::Integer(start),
1149 "{address} start"
1150 );
1151 assert_eq!(
1152 at(mixed, &format!("{address}.source_end_line")).unwrap(),
1153 Value::Integer(end),
1154 "{address} end"
1155 );
1156 }
1157 }
1158
1159 #[test]
1160 fn gfm_table_rows_are_a_paragraph() {
1161 assert_eq!(
1164 shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.blocks"),
1165 [(
1166 "paragraph".to_string(),
1167 "| a | b | |---|---| | 1 | 2 |".to_string()
1168 )]
1169 );
1170 assert_eq!(
1171 shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.paragraph"),
1172 [(
1173 "paragraph".to_string(),
1174 "| a | b | |---|---| | 1 | 2 |".to_string()
1175 )]
1176 );
1177 }
1178
1179 #[test]
1180 fn badge_syntax_remains_in_the_paragraph_view() {
1181 let source = "# T\n\n[](b)\n\nThe lead.\n";
1182 assert_eq!(
1183 shape(source, "h1.0.paragraph"),
1184 [
1185 ("paragraph".to_string(), "CI".to_string()),
1186 ("paragraph".to_string(), "The lead.".to_string()),
1187 ]
1188 );
1189 }
1190
1191 #[test]
1194 fn atx_heading_and_blocks_report_inclusive_source_lines() {
1195 let source = "# Title\n\nLead line one\nline two.\n\n```rs\nfn main() {}\n```\n";
1196 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1197 assert_eq!(integer(source, "h1.0.source_end_line"), 8);
1198 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1199 assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1200 assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1201 assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1202 assert_eq!(integer(source, "h1.0.blocks.1.source_start_line"), 6);
1203 assert_eq!(integer(source, "h1.0.blocks.1.source_end_line"), 8);
1204 }
1205
1206 #[test]
1207 fn setext_heading_range_includes_its_underline() {
1208 let source = "My Project\n==========\n\nThe synopsis.\n\n## Install\n";
1209 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1210 assert_eq!(integer(source, "h1.0.heading_end_line"), 2);
1211 assert_eq!(integer(source, "h1.0.source_end_line"), 6);
1212 assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1213 assert_eq!(integer(source, "h1.0.h2.0.source_end_line"), 6);
1214 assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 4);
1215 assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1216 }
1217
1218 #[test]
1219 fn line_ranges_are_utf8_safe_and_newline_style_independent() {
1220 let source = "# 中文标题\r\n\r\n这是首段,\r\n也是首段。\r\n\r\n## 安装";
1221 assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1222 assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1223 assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1224 assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1225 assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1226 assert_eq!(integer(source, "h1.0.h2.0.heading_end_line"), 6);
1227 }
1228
1229 #[test]
1230 fn a_bare_carriage_return_is_refused_rather_than_numbered() {
1231 let error = load("# T\r\rLead\rcontinued\r\r## End").unwrap_err();
1237 assert_eq!(error.code(), "document_source_refused");
1241 assert!(error.to_string().contains("carriage return"), "{error}");
1242 assert!(
1246 error.redacted_message().contains("CRLF line endings"),
1247 "{}",
1248 error.redacted_message()
1249 );
1250
1251 let crlf = "# T\r\n\r\nLead\r\ncontinued\r\n\r\n## End\r\n";
1254 assert_eq!(integer(crlf, "h1.0.paragraph.0.source_start_line"), 3);
1255 assert_eq!(integer(crlf, "h1.0.paragraph.0.source_end_line"), 4);
1256 assert_eq!(integer(crlf, "h1.0.h2.0.source_start_line"), 6);
1257
1258 let bare = "# T\n\nLead";
1260 assert_eq!(integer(bare, "h1.0.paragraph.0.source_end_line"), 3);
1261 }
1262
1263 #[test]
1264 fn frontmatter_range_includes_both_delimiters() {
1265 let source = "---\ntitle: T\nnested:\n token_secret: hidden\n---\n\n# T\n";
1266 assert_eq!(integer(source, "preamble.blocks.0.source_start_line"), 1);
1267 assert_eq!(integer(source, "preamble.blocks.0.source_end_line"), 5);
1268 assert_eq!(text(source, "preamble.blocks.0.text"), "");
1269 }
1270
1271 #[test]
1272 fn thematic_break_is_a_block_with_no_text() {
1273 assert_eq!(
1274 shape("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1275 [
1276 ("paragraph".to_string(), "a".to_string()),
1277 ("rule".to_string(), String::new()),
1278 ("paragraph".to_string(), "b".to_string()),
1279 ]
1280 );
1281 assert_eq!(
1284 shape("# T\n\na\n\n---\n\nb\n", "h1.0.paragraph"),
1285 [
1286 ("paragraph".to_string(), "a".to_string()),
1287 ("paragraph".to_string(), "b".to_string()),
1288 ]
1289 );
1290 }
1291
1292 #[test]
1293 fn a_byte_order_mark_makes_the_first_block_a_paragraph() {
1294 assert_eq!(
1299 types("\u{feff}# Title\n\nlead\n", "preamble.blocks"),
1300 ["paragraph".to_string(), "paragraph".to_string()]
1301 );
1302 }
1303
1304 #[test]
1305 fn empty_document_has_no_blocks() {
1306 assert_eq!(shape("", "preamble.blocks"), []);
1307 assert_eq!(shape("\n\n \n", "preamble.blocks"), []);
1308 }
1309
1310 #[test]
1311 fn code_block_keeps_its_lines_and_drops_one_trailing_newline() {
1312 assert_eq!(
1313 shape("```\nline one\nline two\n```\n", "preamble.blocks"),
1314 [("code".to_string(), "line one\nline two".to_string())]
1315 );
1316 }
1317}