1use std::{slice::Iter, sync::Arc};
2
3use self_cell::self_cell;
4
5use crate::{
6 HasSpan, Parser, Span,
7 attributes::Attrlist,
8 blocks::{
9 Block, CompoundDelimitedBlock, ContentModel, IsBlock, ListItemMarker, RawDelimitedBlock,
10 SimpleBlock, TableBlock, metadata::BlockMetadata, parse_utils::parse_blocks_until,
11 },
12 content::{Content, SubstitutionGroup},
13 internal::debug::DebugSliceReference,
14 parser::{InlineSubstitutionRenderer, ReferenceResolver, ReferenceWarnings},
15 span::MatchedItem,
16 strings::CowStr,
17 warnings::{MatchAndWarnings, Warning, WarningType},
18};
19
20self_cell! {
21 pub struct OwnedQuoteBlocks {
24 owner: String,
25
26 #[covariant]
27 dependent: OwnedQuoteBlocksInner,
28 }
29
30 impl {Debug, Eq, PartialEq}
31}
32
33#[derive(Debug, Eq, PartialEq)]
35struct OwnedQuoteBlocksInner<'src> {
36 blocks: Vec<Block<'src>>,
37}
38
39#[derive(Clone, Copy, Eq, PartialEq)]
45pub enum QuoteType {
46 Quote,
48
49 Verse,
51}
52
53impl QuoteType {
54 pub fn name(self) -> &'static str {
59 match self {
60 Self::Quote => "quote",
61 Self::Verse => "verse",
62 }
63 }
64}
65
66impl std::fmt::Debug for QuoteType {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 Self::Quote => write!(f, "QuoteType::Quote"),
70 Self::Verse => write!(f, "QuoteType::Verse"),
71 }
72 }
73}
74
75#[derive(Clone, Eq, PartialEq)]
94pub struct QuoteBlock<'src> {
95 type_: QuoteType,
96 content_model: ContentModel,
97 content: Option<Content<'src>>,
98 blocks: Vec<Block<'src>>,
99 markdown_blocks: Option<Arc<OwnedQuoteBlocks>>,
100 attribution: Option<String>,
101 citetitle: Option<String>,
102 source: Span<'src>,
103 title_source: Option<Span<'src>>,
104 title: Option<Content<'src>>,
105 anchor: Option<Span<'src>>,
106 anchor_reftext: Option<Span<'src>>,
107 attrlist: Option<Attrlist<'src>>,
108}
109
110impl<'src> QuoteBlock<'src> {
111 pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
119 self.title.as_mut()
120 }
121
122 pub(crate) fn parse(
127 metadata: &BlockMetadata<'src>,
128 parser: &mut Parser,
129 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
130 let style = metadata.attrlist.as_ref().and_then(|a| a.block_style());
131 let styled_type = match style {
132 Some("quote") => Some(QuoteType::Quote),
133 Some("verse") => Some(QuoteType::Verse),
134 _ => None,
135 };
136
137 let first_line = metadata.block_start.take_normalized_line().item;
138 let is_quote_delimiter = is_quote_verse_delimiter(&first_line);
139
140 if let Some(type_) = styled_type {
143 if is_quote_delimiter {
144 return Some(Self::parse_delimited(metadata, parser, type_));
145 }
146
147 if first_line.data() == "--" {
152 return Some(Self::parse_delimited(metadata, parser, type_));
153 }
154
155 if RawDelimitedBlock::is_valid_delimiter(&first_line)
160 || CompoundDelimitedBlock::is_valid_delimiter(&first_line)
161 || TableBlock::is_table_delimiter(&first_line)
162 {
163 return None;
164 }
165
166 return Self::parse_styled_paragraph(metadata, parser, type_);
167 }
168
169 if is_quote_delimiter {
172 return Some(Self::parse_delimited(metadata, parser, QuoteType::Quote));
173 }
174
175 if first_line.data().starts_with('>') {
177 return Self::parse_markdown(metadata, parser);
178 }
179
180 Self::parse_quoted_paragraph(metadata, parser)
183 }
184
185 fn parse_delimited(
187 metadata: &BlockMetadata<'src>,
188 parser: &mut Parser,
189 type_: QuoteType,
190 ) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
191 let delimiter = metadata.block_start.take_normalized_line();
192
193 let mut next = delimiter.after;
194 let (closing_delimiter, after) = loop {
195 if next.is_empty() {
196 break (next, next);
197 }
198
199 let line = next.take_normalized_line();
200 if line.item.data() == delimiter.item.data() {
201 break (line.item, line.after);
202 }
203 next = line.after;
204 };
205
206 let inside_delimiters = delimiter.after.trim_remainder(closing_delimiter);
207
208 let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
209
210 let (content_model, content, blocks, mut warnings) = match type_ {
211 QuoteType::Quote => {
213 let maw_blocks = parse_blocks_until(inside_delimiters, |_, _| false, parser);
214 (
215 ContentModel::Compound,
216 None,
217 maw_blocks.item.item,
218 maw_blocks.warnings,
219 )
220 }
221
222 QuoteType::Verse => {
225 let content = render_verbatim(inside_delimiters, parser);
226 (ContentModel::Simple, Some(content), vec![], vec![])
227 }
228 };
229
230 let source = metadata
231 .source
232 .trim_remainder(closing_delimiter.discard_all())
233 .trim_trailing_whitespace();
234
235 if closing_delimiter.is_empty() {
236 warnings.insert(
237 0,
238 Warning {
239 source: delimiter.item,
240 warning: WarningType::UnterminatedDelimitedBlock,
241 origin: None,
242 },
243 );
244 }
245
246 MatchAndWarnings {
247 item: Some(MatchedItem {
248 item: Self {
249 type_,
250 content_model,
251 content,
252 blocks,
253 markdown_blocks: None,
254 attribution,
255 citetitle,
256 source,
257 title_source: metadata.title_source,
258 title: metadata.title.clone(),
259 anchor: metadata.anchor,
260 anchor_reftext: metadata.anchor_reftext,
261 attrlist: metadata.attrlist.clone(),
262 },
263 after,
264 }),
265 warnings,
266 }
267 }
268
269 fn parse_styled_paragraph(
271 metadata: &BlockMetadata<'src>,
272 parser: &mut Parser,
273 type_: QuoteType,
274 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
275 let inner = SimpleBlock::parse(metadata, parser)?;
280
281 let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
282
283 let source = metadata
284 .source
285 .trim_remainder(inner.after)
286 .trim_trailing_whitespace();
287
288 Some(MatchAndWarnings {
289 item: Some(MatchedItem {
290 item: Self {
291 type_,
292 content_model: ContentModel::Simple,
293 content: Some(inner.item.content().clone()),
294 blocks: vec![],
295 markdown_blocks: None,
296 attribution,
297 citetitle,
298 source,
299 title_source: metadata.title_source,
300 title: metadata.title.clone(),
301 anchor: metadata.anchor,
302 anchor_reftext: metadata.anchor_reftext,
303 attrlist: metadata.attrlist.clone(),
304 },
305 after: inner.after,
306 }),
307 warnings: vec![],
308 })
309 }
310
311 fn parse_quoted_paragraph(
316 metadata: &BlockMetadata<'src>,
317 parser: &mut Parser,
318 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
319 let para = read_paragraph(metadata.block_start);
321 let data = para.data();
322
323 if !data.starts_with('"') {
324 return None;
325 }
326
327 let (quoted, attribution_text) = split_at_attribution_line(data)?;
331
332 let inner = quoted
335 .trim_end()
336 .strip_prefix('"')
337 .and_then(|s| s.strip_suffix('"'))
338 .filter(|inner| !inner.is_empty())?;
339
340 let inner_span = para.slice(1..1 + inner.len());
344 let mut content = Content::from(inner_span);
345 SubstitutionGroup::Normal.apply(&mut content, parser, None);
346
347 let (attribution, citetitle) = split_attribution_line(attribution_text.trim(), parser);
349
350 let source = metadata
351 .source
352 .trim_remainder(read_paragraph_after(metadata.block_start))
353 .trim_trailing_whitespace();
354
355 Some(MatchAndWarnings {
356 item: Some(MatchedItem {
357 item: Self {
358 type_: QuoteType::Quote,
359 content_model: ContentModel::Simple,
360 content: Some(content),
361 blocks: vec![],
362 markdown_blocks: None,
363 attribution,
364 citetitle,
365 source,
366 title_source: metadata.title_source,
367 title: metadata.title.clone(),
368 anchor: metadata.anchor,
369 anchor_reftext: metadata.anchor_reftext,
370 attrlist: metadata.attrlist.clone(),
371 },
372 after: read_paragraph_after(metadata.block_start).discard_empty_lines(),
373 }),
374 warnings: vec![],
375 })
376 }
377
378 fn parse_markdown(
386 metadata: &BlockMetadata<'src>,
387 parser: &mut Parser,
388 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
389 let first_line = metadata.block_start.take_normalized_line().item;
390
391 if !is_markdown_marker_line(first_line.data()) {
395 return None;
396 }
397
398 if let Some(MatchedItem {
402 item: ListItemMarker::DefinedTerm { .. },
403 ..
404 }) = ListItemMarker::parse(metadata.block_start, parser)
405 {
406 return None;
407 }
408
409 let chunk = read_paragraph(metadata.block_start);
414 let after = read_paragraph_after(metadata.block_start);
415
416 let mut lines: Vec<String> = chunk
417 .data()
418 .split('\n')
419 .map(|line| {
420 if line == ">" {
421 String::new()
422 } else if let Some(rest) = line.strip_prefix("> ") {
423 rest.to_string()
424 } else {
425 line.to_string()
426 }
427 })
428 .collect();
429
430 let (attribution, citetitle) = take_trailing_attribution(&mut lines, parser);
433
434 let body = lines.join("\n");
435
436 let mut nested_warning_types: Vec<WarningType> = vec![];
445 let owned = OwnedQuoteBlocks::new(body, |source| {
446 parser.owned_subsource_depth += 1;
450 let mut maw = parse_blocks_until(Span::new(source), |_, _| false, parser);
451 parser.owned_subsource_depth -= 1;
452 nested_warning_types.extend(maw.warnings.drain(..).map(|w| w.warning));
453 OwnedQuoteBlocksInner {
454 blocks: maw.item.item,
455 }
456 });
457
458 let source = metadata
459 .source
460 .trim_remainder(after)
461 .trim_trailing_whitespace();
462
463 let warnings = nested_warning_types
464 .into_iter()
465 .map(|warning| Warning {
466 source,
467 warning,
468 origin: None,
469 })
470 .collect();
471
472 Some(MatchAndWarnings {
473 item: Some(MatchedItem {
474 item: Self {
475 type_: QuoteType::Quote,
476 content_model: ContentModel::Compound,
477 content: None,
478 blocks: vec![],
479 markdown_blocks: Some(Arc::new(owned)),
480 attribution,
481 citetitle,
482 source,
483 title_source: metadata.title_source,
484 title: metadata.title.clone(),
485 anchor: metadata.anchor,
486 anchor_reftext: metadata.anchor_reftext,
487 attrlist: metadata.attrlist.clone(),
488 },
489 after: after.discard_empty_lines(),
490 }),
491 warnings,
492 })
493 }
494
495 pub fn type_(&self) -> QuoteType {
497 self.type_
498 }
499
500 pub fn attribution(&self) -> Option<&str> {
503 self.attribution.as_deref()
504 }
505
506 pub fn citetitle(&self) -> Option<&str> {
509 self.citetitle.as_deref()
510 }
511
512 pub fn content(&self) -> Option<&Content<'src>> {
515 self.content.as_ref()
516 }
517
518 pub fn blocks(&self) -> &[Block<'_>] {
525 match &self.markdown_blocks {
526 Some(owned) => &owned.borrow_dependent().blocks,
527 None => &self.blocks,
528 }
529 }
530
531 pub(crate) fn resolve_references(
539 &mut self,
540 resolver: &dyn ReferenceResolver,
541 renderer: &dyn InlineSubstitutionRenderer,
542 warnings: &mut ReferenceWarnings<'src>,
543 ) {
544 let source = self.source;
545
546 if let Some(owned) = self.markdown_blocks.as_mut()
550 && let Some(owned) = Arc::get_mut(owned)
551 {
552 owned.with_dependent_mut(|_, dependent| {
553 let mut owned_warnings = ReferenceWarnings::default();
557
558 for block in &mut dependent.blocks {
559 block.resolve_references(resolver, renderer, &mut owned_warnings);
560 }
561
562 owned_warnings.rehome_into(warnings, source);
563 });
564 }
565 }
566}
567
568fn is_markdown_marker_line(line: &str) -> bool {
571 line == ">" || line.starts_with("> ")
572}
573
574fn take_trailing_attribution(
581 lines: &mut Vec<String>,
582 parser: &Parser,
583) -> (Option<String>, Option<String>) {
584 while lines.last().is_some_and(|line| line.is_empty()) {
585 lines.pop();
586 }
587
588 if let Some(last) = lines.last()
589 && let Some(rest) = last.strip_prefix("--")
590 && (rest.starts_with(' ') || rest.starts_with('\t'))
591 {
592 let result = split_attribution_line(rest.trim(), parser);
593 lines.pop();
594 while lines.last().is_some_and(|line| line.is_empty()) {
595 lines.pop();
596 }
597 return result;
598 }
599
600 (None, None)
601}
602
603pub(crate) fn is_quote_verse_delimiter(line: &Span<'_>) -> bool {
606 let data = line.data();
607 data.len() >= 4 && data.starts_with("____") && data.chars().all(|c| c == '_')
608}
609
610fn render_verbatim<'src>(inside: Span<'src>, parser: &Parser) -> Content<'src> {
613 let trimmed = inside.discard_empty_lines().trim_trailing_whitespace();
614 let mut content = Content::from(trimmed);
615 SubstitutionGroup::Normal.apply(&mut content, parser, None);
616 content
617}
618
619fn render_inline(parser: &Parser, text: &str) -> String {
622 let span = Span::new(text);
623 let mut content = Content::from(span);
624 SubstitutionGroup::Normal.apply(&mut content, parser, None);
625 content.rendered_owned()
626}
627
628fn extract_attribution(attrlist: Option<&Attrlist<'_>>) -> (Option<String>, Option<String>) {
638 let Some(attrlist) = attrlist else {
639 return (None, None);
640 };
641
642 let attribution = attrlist
643 .named_or_positional_attribute("attribution", 2)
644 .map(|a| a.value())
645 .filter(|v| !v.is_empty())
646 .map(str::to_string);
647
648 let citetitle = attrlist
649 .named_or_positional_attribute("citetitle", 3)
650 .map(|a| a.value())
651 .filter(|v| !v.is_empty())
652 .map(str::to_string);
653
654 (attribution, citetitle)
655}
656
657fn split_attribution_line(text: &str, parser: &Parser) -> (Option<String>, Option<String>) {
660 match text.split_once(',') {
661 Some((attribution, citetitle)) => {
662 let attribution = attribution.trim();
663 let citetitle = citetitle.trim();
664 (
665 non_empty(attribution).map(|v| render_inline(parser, v)),
666 non_empty(citetitle).map(|v| render_inline(parser, v)),
667 )
668 }
669 None => (
670 non_empty(text.trim()).map(|v| render_inline(parser, v)),
671 None,
672 ),
673 }
674}
675
676fn non_empty(s: &str) -> Option<&str> {
677 if s.is_empty() { None } else { Some(s) }
678}
679
680fn split_at_attribution_line(data: &str) -> Option<(&str, &str)> {
691 let mut line_start = 0;
692 let mut attribution: Option<(usize, &str)> = None;
693
694 for line in data.split_inclusive('\n') {
695 let trimmed = line.strip_suffix('\n').unwrap_or(line);
696
697 if let Some(rest) = trimmed.strip_prefix("--")
698 && (rest.starts_with(' ') || rest.starts_with('\t'))
699 {
700 let attribution_text = rest.trim_start_matches([' ', '\t']);
701 if !attribution_text.is_empty() && line_start > 0 {
704 attribution = Some((line_start, attribution_text));
705 }
706 }
707
708 line_start += line.len();
709 }
710
711 attribution.map(|(start, text)| (data.split_at(start).0, text))
714}
715
716fn read_paragraph(source: Span<'_>) -> Span<'_> {
719 source.trim_remainder(read_paragraph_after(source))
720}
721
722fn read_paragraph_after(source: Span<'_>) -> Span<'_> {
725 let mut next = source;
726 while let Some(line) = next.take_non_empty_line() {
727 next = line.after;
728 }
729 next
730}
731
732impl<'src> IsBlock<'src> for QuoteBlock<'src> {
733 fn content_model(&self) -> ContentModel {
734 self.content_model
735 }
736
737 fn raw_context(&self) -> CowStr<'src> {
738 self.type_.name().into()
739 }
740
741 fn declared_style(&'src self) -> Option<&'src str> {
742 self.attrlist
743 .as_ref()
744 .and_then(|attrlist| attrlist.block_style())
745 }
746
747 fn rendered_content(&'src self) -> Option<&'src str> {
748 self.content.as_ref().map(|content| content.rendered())
749 }
750
751 fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
762 self.blocks.iter()
763 }
764
765 fn nested_blocks_mut(&mut self) -> &mut [Block<'src>] {
772 &mut self.blocks
773 }
774
775 fn content_mut(&mut self) -> Option<&mut Content<'src>> {
776 self.content.as_mut()
777 }
778
779 fn title_source(&'src self) -> Option<Span<'src>> {
780 self.title_source
781 }
782
783 fn title(&self) -> Option<&str> {
784 self.title.as_ref().map(Content::rendered_str)
785 }
786
787 fn anchor(&'src self) -> Option<Span<'src>> {
788 self.anchor
789 }
790
791 fn anchor_reftext(&'src self) -> Option<Span<'src>> {
792 self.anchor_reftext
793 }
794
795 fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
796 self.attrlist.as_ref()
797 }
798}
799
800impl<'src> HasSpan<'src> for QuoteBlock<'src> {
801 fn span(&self) -> Span<'src> {
802 self.source
803 }
804}
805
806impl std::fmt::Debug for QuoteBlock<'_> {
807 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
808 f.debug_struct("QuoteBlock")
809 .field("type_", &self.type_)
810 .field("content_model", &self.content_model)
811 .field("content", &self.content)
812 .field("blocks", &DebugSliceReference(&self.blocks))
813 .field("attribution", &self.attribution)
814 .field("citetitle", &self.citetitle)
815 .field("source", &self.source)
816 .field("title_source", &self.title_source)
817 .field("title", &self.title)
818 .field("anchor", &self.anchor)
819 .field("anchor_reftext", &self.anchor_reftext)
820 .field("attrlist", &self.attrlist)
821 .finish()
822 }
823}
824
825#[cfg(test)]
826mod tests {
827 #![allow(clippy::unwrap_used)]
828 #![allow(clippy::panic)]
829
830 use std::ops::Deref;
831
832 use crate::{
833 blocks::{Block, ContentModel, IsBlock, QuoteType},
834 tests::prelude::*,
835 };
836
837 fn parse_one(input: &'static str) -> Block<'static> {
838 let mut parser = Parser::default();
839 Block::parse(crate::Span::new(input), &mut parser)
840 .unwrap_if_no_warnings()
841 .unwrap()
842 .item
843 }
844
845 fn as_quote<'a>(block: &'a Block<'a>) -> &'a crate::blocks::QuoteBlock<'a> {
846 match block {
847 Block::Quote(quote) => quote,
848 other => panic!("expected a quote block, got {other:?}"),
852 }
853 }
854
855 mod quote_type {
856 use crate::blocks::QuoteType;
857
858 #[test]
859 fn name() {
860 assert_eq!(QuoteType::Quote.name(), "quote");
861 assert_eq!(QuoteType::Verse.name(), "verse");
862 }
863
864 #[test]
865 fn impl_debug() {
866 assert_eq!(format!("{:?}", QuoteType::Quote), "QuoteType::Quote");
867 assert_eq!(format!("{:?}", QuoteType::Verse), "QuoteType::Verse");
868 }
869
870 #[test]
871 fn impl_clone() {
872 let v1 = QuoteType::Quote;
874 let v2 = v1;
875 assert_eq!(v1, v2);
876 }
877 }
878
879 #[test]
880 fn delimited_quote_is_compound() {
881 let block = parse_one("____\nA quote.\n\nWith two paragraphs.\n____");
882 let quote = as_quote(&block);
883
884 assert_eq!(quote.type_(), QuoteType::Quote);
885 assert_eq!(quote.content_model(), ContentModel::Compound);
886 assert_eq!(quote.raw_context().deref(), "quote");
887 assert!(quote.content().is_none());
888 assert!(quote.attribution().is_none());
889 assert!(quote.citetitle().is_none());
890 assert_eq!(quote.blocks().len(), 2);
891 assert_eq!(quote.nested_blocks().count(), 2);
892 }
893
894 #[test]
895 fn delimited_quote_with_attribution_and_citation() {
896 let block =
897 parse_one("[quote,Abraham Lincoln,Gettysburg Address]\n____\nFour score.\n____");
898 let quote = as_quote(&block);
899
900 assert_eq!(quote.attribution(), Some("Abraham Lincoln"));
901 assert_eq!(quote.citetitle(), Some("Gettysburg Address"));
902 assert_eq!(quote.declared_style(), Some("quote"));
903 }
904
905 #[test]
906 fn styled_paragraph_quote_is_simple() {
907 let block = parse_one("[quote,Albert Einstein]\nA person who never made a mistake.");
908 let quote = as_quote(&block);
909
910 assert_eq!(quote.type_(), QuoteType::Quote);
911 assert_eq!(quote.content_model(), ContentModel::Simple);
912 assert_eq!(
913 quote.content().unwrap().rendered(),
914 "A person who never made a mistake."
915 );
916 assert_eq!(
917 quote.rendered_content(),
918 Some("A person who never made a mistake.")
919 );
920 assert_eq!(quote.attribution(), Some("Albert Einstein"));
921 assert!(quote.citetitle().is_none());
922 }
923
924 #[test]
925 fn verse_paragraph_is_simple() {
926 let block = parse_one("[verse,Carl Sandburg,Fog]\nThe fog comes\non little cat feet.");
927 let quote = as_quote(&block);
928
929 assert_eq!(quote.type_(), QuoteType::Verse);
930 assert_eq!(quote.content_model(), ContentModel::Simple);
931 assert_eq!(quote.raw_context().deref(), "verse");
932 assert_eq!(
933 quote.content().unwrap().rendered(),
934 "The fog comes\non little cat feet."
935 );
936 assert_eq!(quote.attribution(), Some("Carl Sandburg"));
937 assert_eq!(quote.citetitle(), Some("Fog"));
938 }
939
940 #[test]
941 fn verse_delimited_preserves_line_breaks() {
942 let block = parse_one("[verse]\n____\nA verse\ndelimited block\n____");
943 let quote = as_quote(&block);
944
945 assert_eq!(quote.type_(), QuoteType::Verse);
946 assert_eq!(quote.content_model(), ContentModel::Simple);
947 assert_eq!(
948 quote.content().unwrap().rendered(),
949 "A verse\ndelimited block"
950 );
951 assert!(quote.nested_blocks().next().is_none());
952 }
953
954 #[test]
955 fn quote_or_verse_style_over_other_container_is_not_a_quote() {
956 assert_eq!(
959 parse_one("[quote]\n====\nx\n====").raw_context().deref(),
960 "example"
961 );
962 assert_eq!(
963 parse_one("[verse]\n****\nx\n****").raw_context().deref(),
964 "sidebar"
965 );
966 assert_eq!(
967 parse_one("[quote]\n----\nx\n----").raw_context().deref(),
968 "listing"
969 );
970 }
971
972 #[test]
973 fn quoted_paragraph_with_attribution_and_citation() {
974 let block = parse_one("\"A little rebellion is good.\"\n-- Thomas Jefferson, Volume 11");
975 let quote = as_quote(&block);
976
977 assert_eq!(quote.type_(), QuoteType::Quote);
978 assert_eq!(quote.content_model(), ContentModel::Simple);
979 assert_eq!(
980 quote.content().unwrap().rendered(),
981 "A little rebellion is good."
982 );
983 assert_eq!(quote.attribution(), Some("Thomas Jefferson"));
984 assert_eq!(quote.citetitle(), Some("Volume 11"));
985 }
986
987 #[test]
988 fn quoted_paragraph_without_citation() {
989 let block = parse_one("\"A quote.\"\n-- Anonymous");
990 let quote = as_quote(&block);
991
992 assert_eq!(quote.attribution(), Some("Anonymous"));
993 assert!(quote.citetitle().is_none());
994 }
995
996 #[test]
997 fn quoted_paragraph_requires_attribution_line() {
998 let block = parse_one("\"Just a quoted sentence.\"");
1001 assert_eq!(block.raw_context().deref(), "paragraph");
1002 }
1003
1004 #[test]
1005 fn quoted_paragraph_requires_opening_quote() {
1006 let block = parse_one("Not quoted.\n-- Someone");
1009 assert_eq!(block.raw_context().deref(), "paragraph");
1010 }
1011
1012 #[test]
1013 fn empty_quoted_paragraph_is_not_a_quote() {
1014 let block = parse_one("\"\"\n-- Someone");
1016 assert_eq!(block.raw_context().deref(), "paragraph");
1017 }
1018
1019 #[test]
1020 fn unclosed_quoted_paragraph_is_not_a_quote() {
1021 let block = parse_one("\"no closing quote\n-- Someone");
1023 assert_eq!(block.raw_context().deref(), "paragraph");
1024 }
1025
1026 #[test]
1027 fn dash_line_without_attribution_text_is_not_a_quote() {
1028 let block = parse_one("\"A quote.\"\n-- ");
1031 assert_eq!(block.raw_context().deref(), "paragraph");
1032 }
1033
1034 #[test]
1035 fn quoted_paragraph_tab_separated_attribution() {
1036 let block = parse_one("\"A quote.\"\n--\tSomeone");
1038 let quote = as_quote(&block);
1039 assert_eq!(quote.attribution(), Some("Someone"));
1040 }
1041
1042 #[test]
1043 fn quoted_paragraph_uses_last_attribution_line() {
1044 let block =
1047 parse_one("\"line one\n-- not really an attribution\nline two\"\n-- Real Attribution");
1048 let quote = as_quote(&block);
1049 assert_eq!(quote.attribution(), Some("Real Attribution"));
1050 let rendered = quote.content().unwrap().rendered();
1051 assert!(
1052 rendered.contains("line one") && rendered.contains("line two"),
1053 "content was: {rendered}"
1054 );
1055 }
1056
1057 #[test]
1058 fn attribution_with_empty_name_keeps_citation() {
1059 let block = parse_one("\"A quote.\"\n-- , Just a citation");
1062 let quote = as_quote(&block);
1063 assert!(quote.attribution().is_none());
1064 assert_eq!(quote.citetitle(), Some("Just a citation"));
1065 }
1066
1067 #[test]
1068 fn styled_paragraph_with_no_content_is_not_a_quote() {
1069 let mut parser = Parser::default();
1072 let maw = Block::parse(crate::Span::new("[quote]\n"), &mut parser);
1073 let block = maw.item.unwrap().item;
1074 assert_eq!(block.raw_context().deref(), "paragraph");
1075 }
1076
1077 #[test]
1078 fn markdown_blockquote_tab_attribution_after_blank() {
1079 let block = parse_one("> A quote.\n>\n> --\tSomeone");
1082 let quote = as_quote(&block);
1083 assert_eq!(quote.attribution(), Some("Someone"));
1084 assert_eq!(quote.blocks().len(), 1);
1085 }
1086
1087 #[test]
1088 fn markdown_blockquote_propagates_nested_warning() {
1089 let mut parser = Parser::default();
1094 let maw = Block::parse(crate::Span::new("> ____\n> unclosed"), &mut parser);
1095
1096 let block = maw.item.unwrap().item;
1097 assert_eq!(block.raw_context().deref(), "quote");
1098 assert_eq!(
1099 maw.warnings.first().unwrap().warning,
1100 WarningType::UnterminatedDelimitedBlock
1101 );
1102 assert_eq!(maw.warnings.first().unwrap().source, block.span());
1104 }
1105
1106 #[test]
1107 fn markdown_blockquote_double_dash_without_space_is_content() {
1108 let block = parse_one("> A quote.\n> --nospace");
1111 let quote = as_quote(&block);
1112 assert!(quote.attribution().is_none());
1113 }
1114
1115 #[test]
1116 fn markdown_blockquote_basic() {
1117 let block = parse_one("> A markdown quote.");
1118 let quote = as_quote(&block);
1119
1120 assert_eq!(quote.type_(), QuoteType::Quote);
1121 assert_eq!(quote.content_model(), ContentModel::Compound);
1122 assert_eq!(quote.blocks().len(), 1);
1123 assert!(quote.nested_blocks().next().is_none());
1126 }
1127
1128 #[test]
1129 fn markdown_blockquote_with_attribution() {
1130 let block = parse_one("> A quote.\n> -- Someone");
1131 let quote = as_quote(&block);
1132
1133 assert_eq!(quote.attribution(), Some("Someone"));
1134 assert_eq!(quote.blocks().len(), 1);
1135 }
1136
1137 #[test]
1138 fn markdown_blockquote_lazy_continuation() {
1139 let block = parse_one("> line one\nline two");
1141 let quote = as_quote(&block);
1142
1143 assert_eq!(quote.blocks().len(), 1);
1144 let inner = quote.blocks().first().unwrap();
1145 assert_eq!(inner.rendered_content(), Some("line one\nline two"));
1146 }
1147
1148 #[test]
1149 fn markdown_marker_requires_space() {
1150 let block = parse_one(">foo bar");
1152 assert_eq!(block.raw_context().deref(), "paragraph");
1153 }
1154
1155 #[test]
1156 fn markdown_yields_to_description_list() {
1157 let block = parse_one("> term:: definition");
1160 assert_eq!(block.raw_context().deref(), "list");
1161 }
1162
1163 #[test]
1164 fn unterminated_delimited_quote_warns() {
1165 let mut parser = Parser::default();
1166 let maw = Block::parse(crate::Span::new("____\nunclosed"), &mut parser);
1167
1168 let block = maw.item.unwrap().item;
1169 assert_eq!(block.raw_context().deref(), "quote");
1170 assert_eq!(maw.warnings.len(), 1);
1171 assert_eq!(
1172 maw.warnings.first().unwrap().warning,
1173 WarningType::UnterminatedDelimitedBlock
1174 );
1175 }
1176
1177 #[test]
1178 fn citation_receives_inline_substitutions() {
1179 let block = parse_one(
1181 "[quote,Lewis Carroll,'See https://example.com/lc[the bio]']\n____\nAny road.\n____",
1182 );
1183 let quote = as_quote(&block);
1184 let citetitle = quote.citetitle().unwrap();
1185 assert!(
1186 citetitle.contains("<a href=\"https://example.com/lc\">the bio</a>"),
1187 "citation was: {citetitle}"
1188 );
1189 }
1190
1191 #[test]
1192 fn block_enum_delegates_to_quote() {
1193 let compound = parse_one("____\nx\n____");
1195 assert_eq!(compound.content_model(), ContentModel::Compound);
1196 assert_eq!(compound.raw_context().deref(), "quote");
1197 assert!(compound.title_source().is_none());
1198 assert!(compound.anchor().is_none());
1199 assert!(compound.anchor_reftext().is_none());
1200 assert!(compound.attrlist().is_none());
1201 assert_eq!(compound.substitution_group(), SubstitutionGroup::Normal);
1202 assert_eq!(compound.nested_blocks().count(), 1);
1203 assert!(compound.title().is_none());
1204 assert!(compound.declared_style().is_none());
1205 assert!(format!("{compound:?}").starts_with("Block::Quote"));
1206
1207 let simple = parse_one("[verse]\nverse text");
1208 assert_eq!(simple.rendered_content(), Some("verse text"));
1209 assert_eq!(simple.content_model(), ContentModel::Simple);
1210 }
1211
1212 #[test]
1213 fn impl_debug() {
1214 let block = parse_one("____\nx\n____");
1215 let quote = as_quote(&block);
1216 let debug = format!("{quote:?}");
1217 assert!(debug.starts_with("QuoteBlock {"));
1218 assert!(debug.contains("type_: QuoteType::Quote"));
1219 }
1220
1221 #[test]
1222 fn impl_clone() {
1223 let block = parse_one("____\nclone me\n____");
1225 let quote = as_quote(&block).clone();
1226 assert_eq!(quote.type_(), QuoteType::Quote);
1227 }
1228
1229 #[test]
1230 fn title_renders_inside_quote_block() {
1231 let doc = Parser::default()
1232 .parse(".A title\n[quote,Captain Kirk]\nEverybody remember where we parked.");
1233 let block = doc.nested_blocks().next().unwrap();
1234 let quote = as_quote(block);
1235 assert_eq!(quote.title(), Some("A title"));
1236 }
1237}