1use std::sync::Arc;
2
3use self_cell::self_cell;
4
5use crate::{
6 HasSpan, Parser, Span,
7 attributes::Attrlist,
8 blocks::{
9 Block, ChildBlocks, CompoundDelimitedBlock, ContentModel, IsBlock, ListItemMarker,
10 RawDelimitedBlock, SimpleBlock, TableBlock, metadata::BlockMetadata,
11 parse_utils::parse_blocks_until,
12 },
13 content::{Content, SubstitutionGroup},
14 internal::debug::DebugSliceReference,
15 parser::{InlineSubstitutionRenderer, ReferenceResolver, ReferenceWarnings},
16 span::MatchedItem,
17 strings::CowStr,
18 warnings::{MatchAndWarnings, Warning, WarningType},
19};
20
21self_cell! {
22 pub struct OwnedQuoteBlocks {
25 owner: String,
26
27 #[covariant]
28 dependent: OwnedQuoteBlocksInner,
29 }
30
31 impl {Debug, Eq, PartialEq}
32}
33
34#[derive(Debug, Eq, PartialEq)]
36struct OwnedQuoteBlocksInner<'src> {
37 blocks: Vec<Block<'src>>,
38}
39
40#[derive(Clone, Copy, Eq, PartialEq)]
46pub enum QuoteType {
47 Quote,
49
50 Verse,
52}
53
54impl QuoteType {
55 pub fn name(self) -> &'static str {
60 match self {
61 Self::Quote => "quote",
62 Self::Verse => "verse",
63 }
64 }
65}
66
67impl std::fmt::Debug for QuoteType {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 match self {
70 Self::Quote => write!(f, "QuoteType::Quote"),
71 Self::Verse => write!(f, "QuoteType::Verse"),
72 }
73 }
74}
75
76#[derive(Clone, Eq, PartialEq)]
95pub struct QuoteBlock<'src> {
96 type_: QuoteType,
97 content_model: ContentModel,
98 content: Option<Content<'src>>,
99 blocks: Vec<Block<'src>>,
100 markdown_blocks: Option<Arc<OwnedQuoteBlocks>>,
101 attribution: Option<String>,
102 citetitle: Option<String>,
103 source: Span<'src>,
104 title_source: Option<Span<'src>>,
105 title: Option<Content<'src>>,
106 anchor: Option<Span<'src>>,
107 anchor_reftext: Option<Span<'src>>,
108 attrlist: Option<Attrlist<'src>>,
109}
110
111impl<'src> QuoteBlock<'src> {
112 pub fn child_blocks(&'src self) -> ChildBlocks<'src> {
121 ChildBlocks::from_slice(self.blocks())
122 }
123
124 pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
132 self.title.as_mut()
133 }
134
135 pub(crate) fn parse(
140 metadata: &BlockMetadata<'src>,
141 parser: &mut Parser,
142 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
143 let style = metadata.attrlist.as_ref().and_then(|a| a.block_style());
144 let styled_type = match style {
145 Some("quote") => Some(QuoteType::Quote),
146 Some("verse") => Some(QuoteType::Verse),
147 _ => None,
148 };
149
150 let first_line = metadata.block_start.take_normalized_line().item;
151 let is_quote_delimiter = is_quote_verse_delimiter(&first_line);
152
153 if let Some(type_) = styled_type {
156 if is_quote_delimiter {
157 return Some(Self::parse_delimited(metadata, parser, type_));
158 }
159
160 if first_line.data() == "--" {
165 return Some(Self::parse_delimited(metadata, parser, type_));
166 }
167
168 if RawDelimitedBlock::is_valid_delimiter(&first_line)
173 || CompoundDelimitedBlock::is_valid_delimiter(&first_line)
174 || TableBlock::is_table_delimiter(&first_line)
175 {
176 return None;
177 }
178
179 return Self::parse_styled_paragraph(metadata, parser, type_);
180 }
181
182 if is_quote_delimiter {
185 return Some(Self::parse_delimited(metadata, parser, QuoteType::Quote));
186 }
187
188 if first_line.data().starts_with('>') {
190 return Self::parse_markdown(metadata, parser);
191 }
192
193 Self::parse_quoted_paragraph(metadata, parser)
196 }
197
198 fn parse_delimited(
200 metadata: &BlockMetadata<'src>,
201 parser: &mut Parser,
202 type_: QuoteType,
203 ) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
204 let delimiter = metadata.block_start.take_normalized_line();
205
206 let mut next = delimiter.after;
207 let (closing_delimiter, after) = loop {
208 if next.is_empty() {
209 break (next, next);
210 }
211
212 let line = next.take_normalized_line();
213 if line.item.data() == delimiter.item.data() {
214 break (line.item, line.after);
215 }
216 next = line.after;
217 };
218
219 let inside_delimiters = delimiter.after.trim_remainder(closing_delimiter);
220
221 let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
222
223 let (content_model, content, blocks, mut warnings) = match type_ {
224 QuoteType::Quote => {
226 let maw_blocks = parse_blocks_until(inside_delimiters, |_, _| false, parser);
227 (
228 ContentModel::Compound,
229 None,
230 maw_blocks.item.item,
231 maw_blocks.warnings,
232 )
233 }
234
235 QuoteType::Verse => {
238 let content = render_verbatim(inside_delimiters, parser);
239 (ContentModel::Simple, Some(content), vec![], vec![])
240 }
241 };
242
243 let source = metadata
244 .source
245 .trim_remainder(closing_delimiter.discard_all())
246 .trim_trailing_whitespace();
247
248 if closing_delimiter.is_empty() {
249 warnings.insert(
250 0,
251 Warning {
252 source: delimiter.item,
253 warning: WarningType::UnterminatedDelimitedBlock,
254 origin: None,
255 },
256 );
257 }
258
259 MatchAndWarnings {
260 item: Some(MatchedItem {
261 item: Self {
262 type_,
263 content_model,
264 content,
265 blocks,
266 markdown_blocks: None,
267 attribution,
268 citetitle,
269 source,
270 title_source: metadata.title_source,
271 title: metadata.title.clone(),
272 anchor: metadata.anchor,
273 anchor_reftext: metadata.anchor_reftext,
274 attrlist: metadata.attrlist.clone(),
275 },
276 after,
277 }),
278 warnings,
279 }
280 }
281
282 fn parse_styled_paragraph(
284 metadata: &BlockMetadata<'src>,
285 parser: &mut Parser,
286 type_: QuoteType,
287 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
288 let inner = SimpleBlock::parse(metadata, parser)?;
293
294 let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
295
296 let source = metadata
297 .source
298 .trim_remainder(inner.after)
299 .trim_trailing_whitespace();
300
301 Some(MatchAndWarnings {
302 item: Some(MatchedItem {
303 item: Self {
304 type_,
305 content_model: ContentModel::Simple,
306 content: Some(inner.item.content().clone()),
307 blocks: vec![],
308 markdown_blocks: None,
309 attribution,
310 citetitle,
311 source,
312 title_source: metadata.title_source,
313 title: metadata.title.clone(),
314 anchor: metadata.anchor,
315 anchor_reftext: metadata.anchor_reftext,
316 attrlist: metadata.attrlist.clone(),
317 },
318 after: inner.after,
319 }),
320 warnings: vec![],
321 })
322 }
323
324 fn parse_quoted_paragraph(
329 metadata: &BlockMetadata<'src>,
330 parser: &mut Parser,
331 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
332 if !metadata.block_start.data().starts_with('"') {
342 return None;
343 }
344
345 let para = read_paragraph(metadata.block_start);
347 let data = para.data();
348
349 let (quoted, attribution_text) = split_at_attribution_line(data)?;
353
354 let inner = quoted
357 .trim_end()
358 .strip_prefix('"')
359 .and_then(|s| s.strip_suffix('"'))
360 .filter(|inner| !inner.is_empty())?;
361
362 let inner_span = para.slice(1..1 + inner.len());
366 let mut content = Content::from(inner_span);
367 SubstitutionGroup::Normal.apply(&mut content, parser, None);
368
369 let (attribution, citetitle) = split_attribution_line(attribution_text.trim(), parser);
371
372 let source = metadata
373 .source
374 .trim_remainder(read_paragraph_after(metadata.block_start))
375 .trim_trailing_whitespace();
376
377 Some(MatchAndWarnings {
378 item: Some(MatchedItem {
379 item: Self {
380 type_: QuoteType::Quote,
381 content_model: ContentModel::Simple,
382 content: Some(content),
383 blocks: vec![],
384 markdown_blocks: None,
385 attribution,
386 citetitle,
387 source,
388 title_source: metadata.title_source,
389 title: metadata.title.clone(),
390 anchor: metadata.anchor,
391 anchor_reftext: metadata.anchor_reftext,
392 attrlist: metadata.attrlist.clone(),
393 },
394 after: read_paragraph_after(metadata.block_start).discard_empty_lines(),
395 }),
396 warnings: vec![],
397 })
398 }
399
400 fn parse_markdown(
408 metadata: &BlockMetadata<'src>,
409 parser: &mut Parser,
410 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
411 let first_line = metadata.block_start.take_normalized_line().item;
412
413 if !is_markdown_marker_line(first_line.data()) {
417 return None;
418 }
419
420 if let Some(MatchedItem {
424 item: ListItemMarker::DefinedTerm { .. },
425 ..
426 }) = ListItemMarker::parse(metadata.block_start, parser)
427 {
428 return None;
429 }
430
431 let chunk = read_paragraph(metadata.block_start);
436 let after = read_paragraph_after(metadata.block_start);
437
438 let mut lines: Vec<String> = chunk
439 .data()
440 .split('\n')
441 .map(|line| {
442 if line == ">" {
443 String::new()
444 } else if let Some(rest) = line.strip_prefix("> ") {
445 rest.to_string()
446 } else {
447 line.to_string()
448 }
449 })
450 .collect();
451
452 let (attribution, citetitle) = take_trailing_attribution(&mut lines, parser);
455
456 let body = lines.join("\n");
457
458 let mut nested_warning_types: Vec<WarningType> = vec![];
467 let owned = OwnedQuoteBlocks::new(body, |source| {
468 parser.owned_subsource_depth += 1;
472 let mut maw = parse_blocks_until(Span::new(source), |_, _| false, parser);
473 parser.owned_subsource_depth -= 1;
474 nested_warning_types.extend(maw.warnings.drain(..).map(|w| w.warning));
475 OwnedQuoteBlocksInner {
476 blocks: maw.item.item,
477 }
478 });
479
480 let source = metadata
481 .source
482 .trim_remainder(after)
483 .trim_trailing_whitespace();
484
485 let warnings = nested_warning_types
486 .into_iter()
487 .map(|warning| Warning {
488 source,
489 warning,
490 origin: None,
491 })
492 .collect();
493
494 Some(MatchAndWarnings {
495 item: Some(MatchedItem {
496 item: Self {
497 type_: QuoteType::Quote,
498 content_model: ContentModel::Compound,
499 content: None,
500 blocks: vec![],
501 markdown_blocks: Some(Arc::new(owned)),
502 attribution,
503 citetitle,
504 source,
505 title_source: metadata.title_source,
506 title: metadata.title.clone(),
507 anchor: metadata.anchor,
508 anchor_reftext: metadata.anchor_reftext,
509 attrlist: metadata.attrlist.clone(),
510 },
511 after: after.discard_empty_lines(),
512 }),
513 warnings,
514 })
515 }
516
517 pub fn type_(&self) -> QuoteType {
519 self.type_
520 }
521
522 pub fn attribution(&self) -> Option<&str> {
525 self.attribution.as_deref()
526 }
527
528 pub fn citetitle(&self) -> Option<&str> {
531 self.citetitle.as_deref()
532 }
533
534 pub fn content(&self) -> Option<&Content<'src>> {
537 self.content.as_ref()
538 }
539
540 pub fn blocks(&self) -> &[Block<'_>] {
546 match &self.markdown_blocks {
547 Some(owned) => &owned.borrow_dependent().blocks,
548 None => &self.blocks,
549 }
550 }
551
552 pub(crate) fn resolve_references(
560 &mut self,
561 resolver: &dyn ReferenceResolver,
562 renderer: &dyn InlineSubstitutionRenderer,
563 warnings: &mut ReferenceWarnings<'src>,
564 ) {
565 let source = self.source;
566
567 if let Some(owned) = self.markdown_blocks.as_mut()
571 && let Some(owned) = Arc::get_mut(owned)
572 {
573 owned.with_dependent_mut(|_, dependent| {
574 let mut owned_warnings = ReferenceWarnings::default();
578
579 for block in &mut dependent.blocks {
580 block.resolve_references(resolver, renderer, &mut owned_warnings);
581 }
582
583 owned_warnings.rehome_into(warnings, source);
584 });
585 }
586 }
587}
588
589fn is_markdown_marker_line(line: &str) -> bool {
592 line == ">" || line.starts_with("> ")
593}
594
595fn take_trailing_attribution(
602 lines: &mut Vec<String>,
603 parser: &Parser,
604) -> (Option<String>, Option<String>) {
605 while lines.last().is_some_and(|line| line.is_empty()) {
606 lines.pop();
607 }
608
609 if let Some(last) = lines.last()
610 && let Some(rest) = last.strip_prefix("--")
611 && (rest.starts_with(' ') || rest.starts_with('\t'))
612 {
613 let result = split_attribution_line(rest.trim(), parser);
614 lines.pop();
615 while lines.last().is_some_and(|line| line.is_empty()) {
616 lines.pop();
617 }
618 return result;
619 }
620
621 (None, None)
622}
623
624pub(crate) fn is_quote_verse_delimiter(line: &Span<'_>) -> bool {
627 let data = line.data();
628 data.len() >= 4 && data.starts_with("____") && data.chars().all(|c| c == '_')
629}
630
631fn render_verbatim<'src>(inside: Span<'src>, parser: &Parser) -> Content<'src> {
634 let trimmed = inside.discard_empty_lines().trim_trailing_whitespace();
635 let mut content = Content::from(trimmed);
636 SubstitutionGroup::Normal.apply(&mut content, parser, None);
637 content
638}
639
640fn render_inline(parser: &Parser, text: &str) -> String {
643 let span = Span::new(text);
644 let mut content = Content::from(span);
645 SubstitutionGroup::Normal.apply(&mut content, parser, None);
646 content.rendered_owned()
647}
648
649fn extract_attribution(attrlist: Option<&Attrlist<'_>>) -> (Option<String>, Option<String>) {
659 let Some(attrlist) = attrlist else {
660 return (None, None);
661 };
662
663 let attribution = attrlist
664 .named_or_positional_attribute("attribution", 2)
665 .map(|a| a.value())
666 .filter(|v| !v.is_empty())
667 .map(str::to_string);
668
669 let citetitle = attrlist
670 .named_or_positional_attribute("citetitle", 3)
671 .map(|a| a.value())
672 .filter(|v| !v.is_empty())
673 .map(str::to_string);
674
675 (attribution, citetitle)
676}
677
678fn split_attribution_line(text: &str, parser: &Parser) -> (Option<String>, Option<String>) {
681 match text.split_once(',') {
682 Some((attribution, citetitle)) => {
683 let attribution = attribution.trim();
684 let citetitle = citetitle.trim();
685 (
686 non_empty(attribution).map(|v| render_inline(parser, v)),
687 non_empty(citetitle).map(|v| render_inline(parser, v)),
688 )
689 }
690 None => (
691 non_empty(text.trim()).map(|v| render_inline(parser, v)),
692 None,
693 ),
694 }
695}
696
697fn non_empty(s: &str) -> Option<&str> {
698 if s.is_empty() { None } else { Some(s) }
699}
700
701fn split_at_attribution_line(data: &str) -> Option<(&str, &str)> {
712 let mut line_start = 0;
713 let mut attribution: Option<(usize, &str)> = None;
714
715 for line in data.split_inclusive('\n') {
716 let trimmed = line.strip_suffix('\n').unwrap_or(line);
717
718 if let Some(rest) = trimmed.strip_prefix("--")
719 && (rest.starts_with(' ') || rest.starts_with('\t'))
720 {
721 let attribution_text = rest.trim_start_matches([' ', '\t']);
722
723 if !attribution_text.is_empty() && line_start > 0 {
726 attribution = Some((line_start, attribution_text));
727 }
728 }
729
730 line_start += line.len();
731 }
732
733 attribution.map(|(start, text)| (data.split_at(start).0, text))
736}
737
738fn read_paragraph(source: Span<'_>) -> Span<'_> {
741 source.trim_remainder(read_paragraph_after(source))
742}
743
744fn read_paragraph_after(source: Span<'_>) -> Span<'_> {
747 let mut next = source;
748 while let Some(line) = next.take_non_empty_line() {
749 next = line.after;
750 }
751 next
752}
753
754impl<'src> IsBlock<'src> for QuoteBlock<'src> {
755 fn content_model(&self) -> ContentModel {
756 self.content_model
757 }
758
759 fn raw_context(&self) -> CowStr<'src> {
760 self.type_.name().into()
761 }
762
763 fn declared_style(&'src self) -> Option<&'src str> {
764 self.attrlist
765 .as_ref()
766 .and_then(|attrlist| attrlist.block_style())
767 }
768
769 fn rendered_content(&'src self) -> Option<&'src str> {
770 self.content.as_ref().map(|content| content.rendered())
771 }
772
773 fn child_blocks_mut(&mut self) -> &mut [Block<'src>] {
783 &mut self.blocks
784 }
785
786 fn content_mut(&mut self) -> Option<&mut Content<'src>> {
787 self.content.as_mut()
788 }
789
790 fn title_source(&'src self) -> Option<Span<'src>> {
791 self.title_source
792 }
793
794 fn title(&self) -> Option<&str> {
795 self.title.as_ref().map(Content::rendered_str)
796 }
797
798 fn anchor(&'src self) -> Option<Span<'src>> {
799 self.anchor
800 }
801
802 fn anchor_reftext(&'src self) -> Option<Span<'src>> {
803 self.anchor_reftext
804 }
805
806 fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
807 self.attrlist.as_ref()
808 }
809}
810
811impl<'src> HasSpan<'src> for QuoteBlock<'src> {
812 fn span(&self) -> Span<'src> {
813 self.source
814 }
815}
816
817impl std::fmt::Debug for QuoteBlock<'_> {
818 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
819 f.debug_struct("QuoteBlock")
820 .field("type_", &self.type_)
821 .field("content_model", &self.content_model)
822 .field("content", &self.content)
823 .field("blocks", &DebugSliceReference(&self.blocks))
824 .field("attribution", &self.attribution)
825 .field("citetitle", &self.citetitle)
826 .field("source", &self.source)
827 .field("title_source", &self.title_source)
828 .field("title", &self.title)
829 .field("anchor", &self.anchor)
830 .field("anchor_reftext", &self.anchor_reftext)
831 .field("attrlist", &self.attrlist)
832 .finish()
833 }
834}
835
836#[cfg(test)]
837mod tests {
838 #![allow(clippy::unwrap_used)]
839 #![allow(clippy::panic)]
840
841 use std::ops::Deref;
842
843 use crate::{
844 blocks::{Block, ContentModel, IsBlock, QuoteType},
845 tests::prelude::*,
846 };
847
848 fn parse_one(input: &'static str) -> Block<'static> {
849 let mut parser = Parser::default();
850 Block::parse(crate::Span::new(input), &mut parser)
851 .unwrap_if_no_warnings()
852 .unwrap()
853 .item
854 }
855
856 fn as_quote<'a>(block: &'a Block<'a>) -> &'a crate::blocks::QuoteBlock<'a> {
857 match block {
858 Block::Quote(quote) => quote,
859
860 other => panic!("expected a quote block, got {other:?}"),
864 }
865 }
866
867 mod quote_type {
868 use crate::blocks::QuoteType;
869
870 #[test]
871 fn name() {
872 assert_eq!(QuoteType::Quote.name(), "quote");
873 assert_eq!(QuoteType::Verse.name(), "verse");
874 }
875
876 #[test]
877 fn impl_debug() {
878 assert_eq!(format!("{:?}", QuoteType::Quote), "QuoteType::Quote");
879 assert_eq!(format!("{:?}", QuoteType::Verse), "QuoteType::Verse");
880 }
881
882 #[test]
883 fn impl_clone() {
884 let v1 = QuoteType::Quote;
886 let v2 = v1;
887 assert_eq!(v1, v2);
888 }
889 }
890
891 #[test]
892 fn delimited_quote_is_compound() {
893 let block = parse_one("____\nA quote.\n\nWith two paragraphs.\n____");
894 let quote = as_quote(&block);
895
896 assert_eq!(quote.type_(), QuoteType::Quote);
897 assert_eq!(quote.content_model(), ContentModel::Compound);
898 assert_eq!(quote.raw_context().deref(), "quote");
899 assert!(quote.content().is_none());
900 assert!(quote.attribution().is_none());
901 assert!(quote.citetitle().is_none());
902 assert_eq!(quote.blocks().len(), 2);
903 assert_eq!(quote.child_blocks().count(), 2);
904 }
905
906 #[test]
907 fn delimited_quote_with_attribution_and_citation() {
908 let block =
909 parse_one("[quote,Abraham Lincoln,Gettysburg Address]\n____\nFour score.\n____");
910 let quote = as_quote(&block);
911
912 assert_eq!(quote.attribution(), Some("Abraham Lincoln"));
913 assert_eq!(quote.citetitle(), Some("Gettysburg Address"));
914 assert_eq!(quote.declared_style(), Some("quote"));
915 }
916
917 #[test]
918 fn styled_paragraph_quote_is_simple() {
919 let block = parse_one("[quote,Albert Einstein]\nA person who never made a mistake.");
920 let quote = as_quote(&block);
921
922 assert_eq!(quote.type_(), QuoteType::Quote);
923 assert_eq!(quote.content_model(), ContentModel::Simple);
924 assert_eq!(
925 quote.content().unwrap().rendered(),
926 "A person who never made a mistake."
927 );
928 assert_eq!(
929 quote.rendered_content(),
930 Some("A person who never made a mistake.")
931 );
932 assert_eq!(quote.attribution(), Some("Albert Einstein"));
933 assert!(quote.citetitle().is_none());
934 }
935
936 #[test]
937 fn verse_paragraph_is_simple() {
938 let block = parse_one("[verse,Carl Sandburg,Fog]\nThe fog comes\non little cat feet.");
939 let quote = as_quote(&block);
940
941 assert_eq!(quote.type_(), QuoteType::Verse);
942 assert_eq!(quote.content_model(), ContentModel::Simple);
943 assert_eq!(quote.raw_context().deref(), "verse");
944 assert_eq!(
945 quote.content().unwrap().rendered(),
946 "The fog comes\non little cat feet."
947 );
948 assert_eq!(quote.attribution(), Some("Carl Sandburg"));
949 assert_eq!(quote.citetitle(), Some("Fog"));
950 }
951
952 #[test]
953 fn verse_delimited_preserves_line_breaks() {
954 let block = parse_one("[verse]\n____\nA verse\ndelimited block\n____");
955 let quote = as_quote(&block);
956
957 assert_eq!(quote.type_(), QuoteType::Verse);
958 assert_eq!(quote.content_model(), ContentModel::Simple);
959 assert_eq!(
960 quote.content().unwrap().rendered(),
961 "A verse\ndelimited block"
962 );
963 assert!(quote.child_blocks().next().is_none());
964 }
965
966 #[test]
967 fn quote_or_verse_style_over_other_container_is_not_a_quote() {
968 assert_eq!(
971 parse_one("[quote]\n====\nx\n====").raw_context().deref(),
972 "example"
973 );
974 assert_eq!(
975 parse_one("[verse]\n****\nx\n****").raw_context().deref(),
976 "sidebar"
977 );
978 assert_eq!(
979 parse_one("[quote]\n----\nx\n----").raw_context().deref(),
980 "listing"
981 );
982 }
983
984 #[test]
985 fn quoted_paragraph_with_attribution_and_citation() {
986 let block = parse_one("\"A little rebellion is good.\"\n-- Thomas Jefferson, Volume 11");
987 let quote = as_quote(&block);
988
989 assert_eq!(quote.type_(), QuoteType::Quote);
990 assert_eq!(quote.content_model(), ContentModel::Simple);
991 assert_eq!(
992 quote.content().unwrap().rendered(),
993 "A little rebellion is good."
994 );
995 assert_eq!(quote.attribution(), Some("Thomas Jefferson"));
996 assert_eq!(quote.citetitle(), Some("Volume 11"));
997 }
998
999 #[test]
1000 fn quoted_paragraph_without_citation() {
1001 let block = parse_one("\"A quote.\"\n-- Anonymous");
1002 let quote = as_quote(&block);
1003
1004 assert_eq!(quote.attribution(), Some("Anonymous"));
1005 assert!(quote.citetitle().is_none());
1006 }
1007
1008 #[test]
1009 fn quoted_paragraph_requires_attribution_line() {
1010 let block = parse_one("\"Just a quoted sentence.\"");
1013 assert_eq!(block.raw_context().deref(), "paragraph");
1014 }
1015
1016 #[test]
1017 fn quoted_paragraph_requires_opening_quote() {
1018 let block = parse_one("Not quoted.\n-- Someone");
1021 assert_eq!(block.raw_context().deref(), "paragraph");
1022 }
1023
1024 #[test]
1025 fn empty_quoted_paragraph_is_not_a_quote() {
1026 let block = parse_one("\"\"\n-- Someone");
1028 assert_eq!(block.raw_context().deref(), "paragraph");
1029 }
1030
1031 #[test]
1032 fn unclosed_quoted_paragraph_is_not_a_quote() {
1033 let block = parse_one("\"no closing quote\n-- Someone");
1035 assert_eq!(block.raw_context().deref(), "paragraph");
1036 }
1037
1038 #[test]
1039 fn dash_line_without_attribution_text_is_not_a_quote() {
1040 let block = parse_one("\"A quote.\"\n-- ");
1043 assert_eq!(block.raw_context().deref(), "paragraph");
1044 }
1045
1046 #[test]
1047 fn quoted_paragraph_tab_separated_attribution() {
1048 let block = parse_one("\"A quote.\"\n--\tSomeone");
1050 let quote = as_quote(&block);
1051 assert_eq!(quote.attribution(), Some("Someone"));
1052 }
1053
1054 #[test]
1055 fn quoted_paragraph_uses_last_attribution_line() {
1056 let block =
1059 parse_one("\"line one\n-- not really an attribution\nline two\"\n-- Real Attribution");
1060 let quote = as_quote(&block);
1061 assert_eq!(quote.attribution(), Some("Real Attribution"));
1062 let rendered = quote.content().unwrap().rendered();
1063 assert!(
1064 rendered.contains("line one") && rendered.contains("line two"),
1065 "content was: {rendered}"
1066 );
1067 }
1068
1069 #[test]
1070 fn attribution_with_empty_name_keeps_citation() {
1071 let block = parse_one("\"A quote.\"\n-- , Just a citation");
1074 let quote = as_quote(&block);
1075 assert!(quote.attribution().is_none());
1076 assert_eq!(quote.citetitle(), Some("Just a citation"));
1077 }
1078
1079 #[test]
1080 fn styled_paragraph_with_no_content_is_not_a_quote() {
1081 let mut parser = Parser::default();
1084 let maw = Block::parse(crate::Span::new("[quote]\n"), &mut parser);
1085 let block = maw.item.unwrap().item;
1086 assert_eq!(block.raw_context().deref(), "paragraph");
1087 }
1088
1089 #[test]
1090 fn markdown_blockquote_tab_attribution_after_blank() {
1091 let block = parse_one("> A quote.\n>\n> --\tSomeone");
1094 let quote = as_quote(&block);
1095 assert_eq!(quote.attribution(), Some("Someone"));
1096 assert_eq!(quote.blocks().len(), 1);
1097 }
1098
1099 #[test]
1100 fn markdown_blockquote_propagates_nested_warning() {
1101 let mut parser = Parser::default();
1106 let maw = Block::parse(crate::Span::new("> ____\n> unclosed"), &mut parser);
1107
1108 let block = maw.item.unwrap().item;
1109 assert_eq!(block.raw_context().deref(), "quote");
1110 assert_eq!(
1111 maw.warnings.first().unwrap().warning,
1112 WarningType::UnterminatedDelimitedBlock
1113 );
1114
1115 assert_eq!(maw.warnings.first().unwrap().source, block.span());
1117 }
1118
1119 #[test]
1120 fn markdown_blockquote_double_dash_without_space_is_content() {
1121 let block = parse_one("> A quote.\n> --nospace");
1124 let quote = as_quote(&block);
1125 assert!(quote.attribution().is_none());
1126 }
1127
1128 #[test]
1129 fn markdown_blockquote_basic() {
1130 let block = parse_one("> A markdown quote.");
1131 let quote = as_quote(&block);
1132
1133 assert_eq!(quote.type_(), QuoteType::Quote);
1134 assert_eq!(quote.content_model(), ContentModel::Compound);
1135 assert_eq!(quote.blocks().len(), 1);
1136
1137 assert_eq!(quote.child_blocks().count(), 1);
1140 }
1141
1142 #[test]
1143 fn markdown_blockquote_with_attribution() {
1144 let block = parse_one("> A quote.\n> -- Someone");
1145 let quote = as_quote(&block);
1146
1147 assert_eq!(quote.attribution(), Some("Someone"));
1148 assert_eq!(quote.blocks().len(), 1);
1149 }
1150
1151 #[test]
1152 fn markdown_blockquote_lazy_continuation() {
1153 let block = parse_one("> line one\nline two");
1155 let quote = as_quote(&block);
1156
1157 assert_eq!(quote.blocks().len(), 1);
1158 let inner = quote.blocks().first().unwrap();
1159 assert_eq!(inner.rendered_content(), Some("line one\nline two"));
1160 }
1161
1162 #[test]
1163 fn markdown_marker_requires_space() {
1164 let block = parse_one(">foo bar");
1166 assert_eq!(block.raw_context().deref(), "paragraph");
1167 }
1168
1169 #[test]
1170 fn markdown_yields_to_description_list() {
1171 let block = parse_one("> term:: definition");
1174 assert_eq!(block.raw_context().deref(), "list");
1175 }
1176
1177 #[test]
1178 fn unterminated_delimited_quote_warns() {
1179 let mut parser = Parser::default();
1180 let maw = Block::parse(crate::Span::new("____\nunclosed"), &mut parser);
1181
1182 let block = maw.item.unwrap().item;
1183 assert_eq!(block.raw_context().deref(), "quote");
1184 assert_eq!(maw.warnings.len(), 1);
1185 assert_eq!(
1186 maw.warnings.first().unwrap().warning,
1187 WarningType::UnterminatedDelimitedBlock
1188 );
1189 }
1190
1191 #[test]
1192 fn citation_receives_inline_substitutions() {
1193 let block = parse_one(
1195 "[quote,Lewis Carroll,'See https://example.com/lc[the bio]']\n____\nAny road.\n____",
1196 );
1197 let quote = as_quote(&block);
1198 let citetitle = quote.citetitle().unwrap();
1199 assert!(
1200 citetitle.contains("<a href=\"https://example.com/lc\">the bio</a>"),
1201 "citation was: {citetitle}"
1202 );
1203 }
1204
1205 #[test]
1206 fn block_enum_delegates_to_quote() {
1207 let compound = parse_one("____\nx\n____");
1209 assert_eq!(compound.content_model(), ContentModel::Compound);
1210 assert_eq!(compound.raw_context().deref(), "quote");
1211 assert!(compound.title_source().is_none());
1212 assert!(compound.anchor().is_none());
1213 assert!(compound.anchor_reftext().is_none());
1214 assert!(compound.attrlist().is_none());
1215 assert_eq!(compound.substitution_group(), SubstitutionGroup::Normal);
1216 assert_eq!(compound.child_blocks().count(), 1);
1217 assert!(compound.title().is_none());
1218 assert!(compound.declared_style().is_none());
1219 assert!(format!("{compound:?}").starts_with("Block::Quote"));
1220
1221 let simple = parse_one("[verse]\nverse text");
1222 assert_eq!(simple.rendered_content(), Some("verse text"));
1223 assert_eq!(simple.content_model(), ContentModel::Simple);
1224 }
1225
1226 #[test]
1227 fn impl_debug() {
1228 let block = parse_one("____\nx\n____");
1229 let quote = as_quote(&block);
1230 let debug = format!("{quote:?}");
1231 assert!(debug.starts_with("QuoteBlock {"));
1232 assert!(debug.contains("type_: QuoteType::Quote"));
1233 }
1234
1235 #[test]
1236 fn impl_clone() {
1237 let block = parse_one("____\nclone me\n____");
1239 let quote = as_quote(&block).clone();
1240 assert_eq!(quote.type_(), QuoteType::Quote);
1241 }
1242
1243 #[test]
1244 fn title_renders_inside_quote_block() {
1245 let doc = Parser::default()
1246 .parse(".A title\n[quote,Captain Kirk]\nEverybody remember where we parked.");
1247 let block = doc.child_blocks().next().unwrap();
1248 let quote = as_quote(block);
1249 assert_eq!(quote.title(), Some("A title"));
1250 }
1251
1252 #[test]
1264 fn many_consecutive_delimiters_parse_in_roughly_linear_time() {
1265 use std::time::{Duration, Instant};
1266
1267 let example_run = "====\n".repeat(20_000);
1271
1272 let mut example_run_with_text = "====\n".repeat(10_000);
1273 example_run_with_text.push_str("text\n");
1274 example_run_with_text.push_str(&"====\n".repeat(10_000));
1275
1276 let open_run = "--\n".repeat(20_000);
1277
1278 let budget = Duration::from_secs(10);
1279
1280 for source in [&example_run, &example_run_with_text, &open_run] {
1281 let start = Instant::now();
1282 let _ = Parser::default().parse(source);
1283 let elapsed = start.elapsed();
1284
1285 assert!(
1286 elapsed < budget,
1287 "parsing {} delimiter lines took {elapsed:?}, exceeding the {budget:?} budget \
1288 (a sign the quadratic quoted-paragraph rescan has returned)",
1289 source.lines().count(),
1290 );
1291 }
1292 }
1293}