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, ReferenceWarning},
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<String>,
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 parse(
116 metadata: &BlockMetadata<'src>,
117 parser: &mut Parser,
118 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
119 let style = metadata.attrlist.as_ref().and_then(|a| a.block_style());
120 let styled_type = match style {
121 Some("quote") => Some(QuoteType::Quote),
122 Some("verse") => Some(QuoteType::Verse),
123 _ => None,
124 };
125
126 let first_line = metadata.block_start.take_normalized_line().item;
127 let is_quote_delimiter = is_quote_verse_delimiter(&first_line);
128
129 if let Some(type_) = styled_type {
132 if is_quote_delimiter {
133 return Some(Self::parse_delimited(metadata, parser, type_));
134 }
135
136 if first_line.data() == "--" {
141 return Some(Self::parse_delimited(metadata, parser, type_));
142 }
143
144 if RawDelimitedBlock::is_valid_delimiter(&first_line)
149 || CompoundDelimitedBlock::is_valid_delimiter(&first_line)
150 || TableBlock::is_table_delimiter(&first_line)
151 {
152 return None;
153 }
154
155 return Self::parse_styled_paragraph(metadata, parser, type_);
156 }
157
158 if is_quote_delimiter {
161 return Some(Self::parse_delimited(metadata, parser, QuoteType::Quote));
162 }
163
164 if first_line.data().starts_with('>') {
166 return Self::parse_markdown(metadata, parser);
167 }
168
169 Self::parse_quoted_paragraph(metadata, parser)
172 }
173
174 fn parse_delimited(
176 metadata: &BlockMetadata<'src>,
177 parser: &mut Parser,
178 type_: QuoteType,
179 ) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
180 let delimiter = metadata.block_start.take_normalized_line();
181
182 let mut next = delimiter.after;
183 let (closing_delimiter, after) = loop {
184 if next.is_empty() {
185 break (next, next);
186 }
187
188 let line = next.take_normalized_line();
189 if line.item.data() == delimiter.item.data() {
190 break (line.item, line.after);
191 }
192 next = line.after;
193 };
194
195 let inside_delimiters = delimiter.after.trim_remainder(closing_delimiter);
196
197 let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
198
199 let (content_model, content, blocks, mut warnings) = match type_ {
200 QuoteType::Quote => {
202 let maw_blocks = parse_blocks_until(inside_delimiters, |_| false, parser);
203 (
204 ContentModel::Compound,
205 None,
206 maw_blocks.item.item,
207 maw_blocks.warnings,
208 )
209 }
210
211 QuoteType::Verse => {
214 let content = render_verbatim(inside_delimiters, parser);
215 (ContentModel::Simple, Some(content), vec![], vec![])
216 }
217 };
218
219 let source = metadata
220 .source
221 .trim_remainder(closing_delimiter.discard_all())
222 .trim_trailing_whitespace();
223
224 if closing_delimiter.is_empty() {
225 warnings.insert(
226 0,
227 Warning {
228 source: delimiter.item,
229 warning: WarningType::UnterminatedDelimitedBlock,
230 },
231 );
232 }
233
234 MatchAndWarnings {
235 item: Some(MatchedItem {
236 item: Self {
237 type_,
238 content_model,
239 content,
240 blocks,
241 markdown_blocks: None,
242 attribution,
243 citetitle,
244 source,
245 title_source: metadata.title_source,
246 title: metadata.title.clone(),
247 anchor: metadata.anchor,
248 anchor_reftext: metadata.anchor_reftext,
249 attrlist: metadata.attrlist.clone(),
250 },
251 after,
252 }),
253 warnings,
254 }
255 }
256
257 fn parse_styled_paragraph(
259 metadata: &BlockMetadata<'src>,
260 parser: &mut Parser,
261 type_: QuoteType,
262 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
263 let inner = SimpleBlock::parse(metadata, parser)?;
268
269 let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
270
271 let source = metadata
272 .source
273 .trim_remainder(inner.after)
274 .trim_trailing_whitespace();
275
276 Some(MatchAndWarnings {
277 item: Some(MatchedItem {
278 item: Self {
279 type_,
280 content_model: ContentModel::Simple,
281 content: Some(inner.item.content().clone()),
282 blocks: vec![],
283 markdown_blocks: None,
284 attribution,
285 citetitle,
286 source,
287 title_source: metadata.title_source,
288 title: metadata.title.clone(),
289 anchor: metadata.anchor,
290 anchor_reftext: metadata.anchor_reftext,
291 attrlist: metadata.attrlist.clone(),
292 },
293 after: inner.after,
294 }),
295 warnings: vec![],
296 })
297 }
298
299 fn parse_quoted_paragraph(
304 metadata: &BlockMetadata<'src>,
305 parser: &mut Parser,
306 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
307 let para = read_paragraph(metadata.block_start);
309 let data = para.data();
310
311 if !data.starts_with('"') {
312 return None;
313 }
314
315 let (quoted, attribution_text) = split_at_attribution_line(data)?;
319
320 let inner = quoted
323 .trim_end()
324 .strip_prefix('"')
325 .and_then(|s| s.strip_suffix('"'))
326 .filter(|inner| !inner.is_empty())?;
327
328 let inner_span = para.slice(1..1 + inner.len());
332 let mut content = Content::from(inner_span);
333 SubstitutionGroup::Normal.apply(&mut content, parser, None);
334
335 let (attribution, citetitle) = split_attribution_line(attribution_text.trim(), parser);
337
338 let source = metadata
339 .source
340 .trim_remainder(read_paragraph_after(metadata.block_start))
341 .trim_trailing_whitespace();
342
343 Some(MatchAndWarnings {
344 item: Some(MatchedItem {
345 item: Self {
346 type_: QuoteType::Quote,
347 content_model: ContentModel::Simple,
348 content: Some(content),
349 blocks: vec![],
350 markdown_blocks: None,
351 attribution,
352 citetitle,
353 source,
354 title_source: metadata.title_source,
355 title: metadata.title.clone(),
356 anchor: metadata.anchor,
357 anchor_reftext: metadata.anchor_reftext,
358 attrlist: metadata.attrlist.clone(),
359 },
360 after: read_paragraph_after(metadata.block_start).discard_empty_lines(),
361 }),
362 warnings: vec![],
363 })
364 }
365
366 fn parse_markdown(
374 metadata: &BlockMetadata<'src>,
375 parser: &mut Parser,
376 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
377 let first_line = metadata.block_start.take_normalized_line().item;
378
379 if !is_markdown_marker_line(first_line.data()) {
383 return None;
384 }
385
386 if let Some(MatchedItem {
390 item: ListItemMarker::DefinedTerm { .. },
391 ..
392 }) = ListItemMarker::parse(metadata.block_start, parser)
393 {
394 return None;
395 }
396
397 let chunk = read_paragraph(metadata.block_start);
402 let after = read_paragraph_after(metadata.block_start);
403
404 let mut lines: Vec<String> = chunk
405 .data()
406 .split('\n')
407 .map(|line| {
408 if line == ">" {
409 String::new()
410 } else if let Some(rest) = line.strip_prefix("> ") {
411 rest.to_string()
412 } else {
413 line.to_string()
414 }
415 })
416 .collect();
417
418 let (attribution, citetitle) = take_trailing_attribution(&mut lines, parser);
421
422 let body = lines.join("\n");
423
424 let mut nested_warning_types: Vec<WarningType> = vec![];
433 let owned = OwnedQuoteBlocks::new(body, |source| {
434 let mut maw = parse_blocks_until(Span::new(source), |_| false, parser);
435 nested_warning_types.extend(maw.warnings.drain(..).map(|w| w.warning));
436 OwnedQuoteBlocksInner {
437 blocks: maw.item.item,
438 }
439 });
440
441 let source = metadata
442 .source
443 .trim_remainder(after)
444 .trim_trailing_whitespace();
445
446 let warnings = nested_warning_types
447 .into_iter()
448 .map(|warning| Warning { source, warning })
449 .collect();
450
451 Some(MatchAndWarnings {
452 item: Some(MatchedItem {
453 item: Self {
454 type_: QuoteType::Quote,
455 content_model: ContentModel::Compound,
456 content: None,
457 blocks: vec![],
458 markdown_blocks: Some(Arc::new(owned)),
459 attribution,
460 citetitle,
461 source,
462 title_source: metadata.title_source,
463 title: metadata.title.clone(),
464 anchor: metadata.anchor,
465 anchor_reftext: metadata.anchor_reftext,
466 attrlist: metadata.attrlist.clone(),
467 },
468 after: after.discard_empty_lines(),
469 }),
470 warnings,
471 })
472 }
473
474 pub fn type_(&self) -> QuoteType {
476 self.type_
477 }
478
479 pub fn attribution(&self) -> Option<&str> {
482 self.attribution.as_deref()
483 }
484
485 pub fn citetitle(&self) -> Option<&str> {
488 self.citetitle.as_deref()
489 }
490
491 pub fn content(&self) -> Option<&Content<'src>> {
494 self.content.as_ref()
495 }
496
497 pub fn blocks(&self) -> &[Block<'_>] {
504 match &self.markdown_blocks {
505 Some(owned) => &owned.borrow_dependent().blocks,
506 None => &self.blocks,
507 }
508 }
509
510 pub(crate) fn resolve_references(
518 &mut self,
519 resolver: &dyn ReferenceResolver,
520 renderer: &dyn InlineSubstitutionRenderer,
521 warnings: &mut Vec<ReferenceWarning>,
522 ) {
523 if let Some(owned) = self.markdown_blocks.as_mut()
527 && let Some(owned) = Arc::get_mut(owned)
528 {
529 owned.with_dependent_mut(|_, dependent| {
530 for block in &mut dependent.blocks {
531 block.resolve_references(resolver, renderer, warnings);
532 }
533 });
534 }
535 }
536}
537
538fn is_markdown_marker_line(line: &str) -> bool {
541 line == ">" || line.starts_with("> ")
542}
543
544fn take_trailing_attribution(
551 lines: &mut Vec<String>,
552 parser: &Parser,
553) -> (Option<String>, Option<String>) {
554 while lines.last().is_some_and(|line| line.is_empty()) {
555 lines.pop();
556 }
557
558 if let Some(last) = lines.last()
559 && let Some(rest) = last.strip_prefix("--")
560 && (rest.starts_with(' ') || rest.starts_with('\t'))
561 {
562 let result = split_attribution_line(rest.trim(), parser);
563 lines.pop();
564 while lines.last().is_some_and(|line| line.is_empty()) {
565 lines.pop();
566 }
567 return result;
568 }
569
570 (None, None)
571}
572
573pub(crate) fn is_quote_verse_delimiter(line: &Span<'_>) -> bool {
576 let data = line.data();
577 data.len() >= 4 && data.starts_with("____") && data.chars().all(|c| c == '_')
578}
579
580fn render_verbatim<'src>(inside: Span<'src>, parser: &Parser) -> Content<'src> {
583 let trimmed = inside.discard_empty_lines().trim_trailing_whitespace();
584 let mut content = Content::from(trimmed);
585 SubstitutionGroup::Normal.apply(&mut content, parser, None);
586 content
587}
588
589fn render_inline(parser: &Parser, text: &str) -> String {
592 let span = Span::new(text);
593 let mut content = Content::from(span);
594 SubstitutionGroup::Normal.apply(&mut content, parser, None);
595 content.rendered_owned()
596}
597
598fn extract_attribution(attrlist: Option<&Attrlist<'_>>) -> (Option<String>, Option<String>) {
608 let Some(attrlist) = attrlist else {
609 return (None, None);
610 };
611
612 let attribution = attrlist
613 .named_or_positional_attribute("attribution", 2)
614 .map(|a| a.value())
615 .filter(|v| !v.is_empty())
616 .map(str::to_string);
617
618 let citetitle = attrlist
619 .named_or_positional_attribute("citetitle", 3)
620 .map(|a| a.value())
621 .filter(|v| !v.is_empty())
622 .map(str::to_string);
623
624 (attribution, citetitle)
625}
626
627fn split_attribution_line(text: &str, parser: &Parser) -> (Option<String>, Option<String>) {
630 match text.split_once(',') {
631 Some((attribution, citetitle)) => {
632 let attribution = attribution.trim();
633 let citetitle = citetitle.trim();
634 (
635 non_empty(attribution).map(|v| render_inline(parser, v)),
636 non_empty(citetitle).map(|v| render_inline(parser, v)),
637 )
638 }
639 None => (
640 non_empty(text.trim()).map(|v| render_inline(parser, v)),
641 None,
642 ),
643 }
644}
645
646fn non_empty(s: &str) -> Option<&str> {
647 if s.is_empty() { None } else { Some(s) }
648}
649
650fn split_at_attribution_line(data: &str) -> Option<(&str, &str)> {
661 let mut line_start = 0;
662 let mut attribution: Option<(usize, &str)> = None;
663
664 for line in data.split_inclusive('\n') {
665 let trimmed = line.strip_suffix('\n').unwrap_or(line);
666
667 if let Some(rest) = trimmed.strip_prefix("--")
668 && (rest.starts_with(' ') || rest.starts_with('\t'))
669 {
670 let attribution_text = rest.trim_start_matches([' ', '\t']);
671 if !attribution_text.is_empty() && line_start > 0 {
674 attribution = Some((line_start, attribution_text));
675 }
676 }
677
678 line_start += line.len();
679 }
680
681 attribution.map(|(start, text)| (data.split_at(start).0, text))
684}
685
686fn read_paragraph(source: Span<'_>) -> Span<'_> {
689 source.trim_remainder(read_paragraph_after(source))
690}
691
692fn read_paragraph_after(source: Span<'_>) -> Span<'_> {
695 let mut next = source;
696 while let Some(line) = next.take_non_empty_line() {
697 next = line.after;
698 }
699 next
700}
701
702impl<'src> IsBlock<'src> for QuoteBlock<'src> {
703 fn content_model(&self) -> ContentModel {
704 self.content_model
705 }
706
707 fn raw_context(&self) -> CowStr<'src> {
708 self.type_.name().into()
709 }
710
711 fn declared_style(&'src self) -> Option<&'src str> {
712 self.attrlist
713 .as_ref()
714 .and_then(|attrlist| attrlist.block_style())
715 }
716
717 fn rendered_content(&'src self) -> Option<&'src str> {
718 self.content.as_ref().map(|content| content.rendered())
719 }
720
721 fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
732 self.blocks.iter()
733 }
734
735 fn nested_blocks_mut(&mut self) -> &mut [Block<'src>] {
742 &mut self.blocks
743 }
744
745 fn content_mut(&mut self) -> Option<&mut Content<'src>> {
746 self.content.as_mut()
747 }
748
749 fn title_source(&'src self) -> Option<Span<'src>> {
750 self.title_source
751 }
752
753 fn title(&self) -> Option<&str> {
754 self.title.as_deref()
755 }
756
757 fn anchor(&'src self) -> Option<Span<'src>> {
758 self.anchor
759 }
760
761 fn anchor_reftext(&'src self) -> Option<Span<'src>> {
762 self.anchor_reftext
763 }
764
765 fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
766 self.attrlist.as_ref()
767 }
768}
769
770impl<'src> HasSpan<'src> for QuoteBlock<'src> {
771 fn span(&self) -> Span<'src> {
772 self.source
773 }
774}
775
776impl std::fmt::Debug for QuoteBlock<'_> {
777 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
778 f.debug_struct("QuoteBlock")
779 .field("type_", &self.type_)
780 .field("content_model", &self.content_model)
781 .field("content", &self.content)
782 .field("blocks", &DebugSliceReference(&self.blocks))
783 .field("attribution", &self.attribution)
784 .field("citetitle", &self.citetitle)
785 .field("source", &self.source)
786 .field("title_source", &self.title_source)
787 .field("title", &self.title)
788 .field("anchor", &self.anchor)
789 .field("anchor_reftext", &self.anchor_reftext)
790 .field("attrlist", &self.attrlist)
791 .finish()
792 }
793}
794
795#[cfg(test)]
796mod tests {
797 #![allow(clippy::unwrap_used)]
798 #![allow(clippy::panic)]
799
800 use std::ops::Deref;
801
802 use crate::{
803 blocks::{Block, ContentModel, IsBlock, QuoteType},
804 tests::prelude::*,
805 };
806
807 fn parse_one(input: &'static str) -> Block<'static> {
808 let mut parser = Parser::default();
809 Block::parse(crate::Span::new(input), &mut parser)
810 .unwrap_if_no_warnings()
811 .unwrap()
812 .item
813 }
814
815 fn as_quote<'a>(block: &'a Block<'a>) -> &'a crate::blocks::QuoteBlock<'a> {
816 match block {
817 Block::Quote(quote) => quote,
818 other => panic!("expected a quote block, got {other:?}"),
822 }
823 }
824
825 mod quote_type {
826 use crate::blocks::QuoteType;
827
828 #[test]
829 fn name() {
830 assert_eq!(QuoteType::Quote.name(), "quote");
831 assert_eq!(QuoteType::Verse.name(), "verse");
832 }
833
834 #[test]
835 fn impl_debug() {
836 assert_eq!(format!("{:?}", QuoteType::Quote), "QuoteType::Quote");
837 assert_eq!(format!("{:?}", QuoteType::Verse), "QuoteType::Verse");
838 }
839
840 #[test]
841 fn impl_clone() {
842 let v1 = QuoteType::Quote;
844 let v2 = v1;
845 assert_eq!(v1, v2);
846 }
847 }
848
849 #[test]
850 fn delimited_quote_is_compound() {
851 let block = parse_one("____\nA quote.\n\nWith two paragraphs.\n____");
852 let quote = as_quote(&block);
853
854 assert_eq!(quote.type_(), QuoteType::Quote);
855 assert_eq!(quote.content_model(), ContentModel::Compound);
856 assert_eq!(quote.raw_context().deref(), "quote");
857 assert!(quote.content().is_none());
858 assert!(quote.attribution().is_none());
859 assert!(quote.citetitle().is_none());
860 assert_eq!(quote.blocks().len(), 2);
861 assert_eq!(quote.nested_blocks().count(), 2);
862 }
863
864 #[test]
865 fn delimited_quote_with_attribution_and_citation() {
866 let block =
867 parse_one("[quote,Abraham Lincoln,Gettysburg Address]\n____\nFour score.\n____");
868 let quote = as_quote(&block);
869
870 assert_eq!(quote.attribution(), Some("Abraham Lincoln"));
871 assert_eq!(quote.citetitle(), Some("Gettysburg Address"));
872 assert_eq!(quote.declared_style(), Some("quote"));
873 }
874
875 #[test]
876 fn styled_paragraph_quote_is_simple() {
877 let block = parse_one("[quote,Albert Einstein]\nA person who never made a mistake.");
878 let quote = as_quote(&block);
879
880 assert_eq!(quote.type_(), QuoteType::Quote);
881 assert_eq!(quote.content_model(), ContentModel::Simple);
882 assert_eq!(
883 quote.content().unwrap().rendered(),
884 "A person who never made a mistake."
885 );
886 assert_eq!(
887 quote.rendered_content(),
888 Some("A person who never made a mistake.")
889 );
890 assert_eq!(quote.attribution(), Some("Albert Einstein"));
891 assert!(quote.citetitle().is_none());
892 }
893
894 #[test]
895 fn verse_paragraph_is_simple() {
896 let block = parse_one("[verse,Carl Sandburg,Fog]\nThe fog comes\non little cat feet.");
897 let quote = as_quote(&block);
898
899 assert_eq!(quote.type_(), QuoteType::Verse);
900 assert_eq!(quote.content_model(), ContentModel::Simple);
901 assert_eq!(quote.raw_context().deref(), "verse");
902 assert_eq!(
903 quote.content().unwrap().rendered(),
904 "The fog comes\non little cat feet."
905 );
906 assert_eq!(quote.attribution(), Some("Carl Sandburg"));
907 assert_eq!(quote.citetitle(), Some("Fog"));
908 }
909
910 #[test]
911 fn verse_delimited_preserves_line_breaks() {
912 let block = parse_one("[verse]\n____\nA verse\ndelimited block\n____");
913 let quote = as_quote(&block);
914
915 assert_eq!(quote.type_(), QuoteType::Verse);
916 assert_eq!(quote.content_model(), ContentModel::Simple);
917 assert_eq!(
918 quote.content().unwrap().rendered(),
919 "A verse\ndelimited block"
920 );
921 assert!(quote.nested_blocks().next().is_none());
922 }
923
924 #[test]
925 fn quote_or_verse_style_over_other_container_is_not_a_quote() {
926 assert_eq!(
929 parse_one("[quote]\n====\nx\n====").raw_context().deref(),
930 "example"
931 );
932 assert_eq!(
933 parse_one("[verse]\n****\nx\n****").raw_context().deref(),
934 "sidebar"
935 );
936 assert_eq!(
937 parse_one("[quote]\n----\nx\n----").raw_context().deref(),
938 "listing"
939 );
940 }
941
942 #[test]
943 fn quoted_paragraph_with_attribution_and_citation() {
944 let block = parse_one("\"A little rebellion is good.\"\n-- Thomas Jefferson, Volume 11");
945 let quote = as_quote(&block);
946
947 assert_eq!(quote.type_(), QuoteType::Quote);
948 assert_eq!(quote.content_model(), ContentModel::Simple);
949 assert_eq!(
950 quote.content().unwrap().rendered(),
951 "A little rebellion is good."
952 );
953 assert_eq!(quote.attribution(), Some("Thomas Jefferson"));
954 assert_eq!(quote.citetitle(), Some("Volume 11"));
955 }
956
957 #[test]
958 fn quoted_paragraph_without_citation() {
959 let block = parse_one("\"A quote.\"\n-- Anonymous");
960 let quote = as_quote(&block);
961
962 assert_eq!(quote.attribution(), Some("Anonymous"));
963 assert!(quote.citetitle().is_none());
964 }
965
966 #[test]
967 fn quoted_paragraph_requires_attribution_line() {
968 let block = parse_one("\"Just a quoted sentence.\"");
971 assert_eq!(block.raw_context().deref(), "paragraph");
972 }
973
974 #[test]
975 fn quoted_paragraph_requires_opening_quote() {
976 let block = parse_one("Not quoted.\n-- Someone");
979 assert_eq!(block.raw_context().deref(), "paragraph");
980 }
981
982 #[test]
983 fn empty_quoted_paragraph_is_not_a_quote() {
984 let block = parse_one("\"\"\n-- Someone");
986 assert_eq!(block.raw_context().deref(), "paragraph");
987 }
988
989 #[test]
990 fn unclosed_quoted_paragraph_is_not_a_quote() {
991 let block = parse_one("\"no closing quote\n-- Someone");
993 assert_eq!(block.raw_context().deref(), "paragraph");
994 }
995
996 #[test]
997 fn dash_line_without_attribution_text_is_not_a_quote() {
998 let block = parse_one("\"A quote.\"\n-- ");
1001 assert_eq!(block.raw_context().deref(), "paragraph");
1002 }
1003
1004 #[test]
1005 fn quoted_paragraph_tab_separated_attribution() {
1006 let block = parse_one("\"A quote.\"\n--\tSomeone");
1008 let quote = as_quote(&block);
1009 assert_eq!(quote.attribution(), Some("Someone"));
1010 }
1011
1012 #[test]
1013 fn quoted_paragraph_uses_last_attribution_line() {
1014 let block =
1017 parse_one("\"line one\n-- not really an attribution\nline two\"\n-- Real Attribution");
1018 let quote = as_quote(&block);
1019 assert_eq!(quote.attribution(), Some("Real Attribution"));
1020 let rendered = quote.content().unwrap().rendered();
1021 assert!(
1022 rendered.contains("line one") && rendered.contains("line two"),
1023 "content was: {rendered}"
1024 );
1025 }
1026
1027 #[test]
1028 fn attribution_with_empty_name_keeps_citation() {
1029 let block = parse_one("\"A quote.\"\n-- , Just a citation");
1032 let quote = as_quote(&block);
1033 assert!(quote.attribution().is_none());
1034 assert_eq!(quote.citetitle(), Some("Just a citation"));
1035 }
1036
1037 #[test]
1038 fn styled_paragraph_with_no_content_is_not_a_quote() {
1039 let mut parser = Parser::default();
1042 let maw = Block::parse(crate::Span::new("[quote]\n"), &mut parser);
1043 let block = maw.item.unwrap().item;
1044 assert_eq!(block.raw_context().deref(), "paragraph");
1045 }
1046
1047 #[test]
1048 fn markdown_blockquote_tab_attribution_after_blank() {
1049 let block = parse_one("> A quote.\n>\n> --\tSomeone");
1052 let quote = as_quote(&block);
1053 assert_eq!(quote.attribution(), Some("Someone"));
1054 assert_eq!(quote.blocks().len(), 1);
1055 }
1056
1057 #[test]
1058 fn markdown_blockquote_propagates_nested_warning() {
1059 let mut parser = Parser::default();
1064 let maw = Block::parse(crate::Span::new("> ____\n> unclosed"), &mut parser);
1065
1066 let block = maw.item.unwrap().item;
1067 assert_eq!(block.raw_context().deref(), "quote");
1068 assert_eq!(
1069 maw.warnings.first().unwrap().warning,
1070 WarningType::UnterminatedDelimitedBlock
1071 );
1072 assert_eq!(maw.warnings.first().unwrap().source, block.span());
1074 }
1075
1076 #[test]
1077 fn markdown_blockquote_double_dash_without_space_is_content() {
1078 let block = parse_one("> A quote.\n> --nospace");
1081 let quote = as_quote(&block);
1082 assert!(quote.attribution().is_none());
1083 }
1084
1085 #[test]
1086 fn markdown_blockquote_basic() {
1087 let block = parse_one("> A markdown quote.");
1088 let quote = as_quote(&block);
1089
1090 assert_eq!(quote.type_(), QuoteType::Quote);
1091 assert_eq!(quote.content_model(), ContentModel::Compound);
1092 assert_eq!(quote.blocks().len(), 1);
1093 assert!(quote.nested_blocks().next().is_none());
1096 }
1097
1098 #[test]
1099 fn markdown_blockquote_with_attribution() {
1100 let block = parse_one("> A quote.\n> -- Someone");
1101 let quote = as_quote(&block);
1102
1103 assert_eq!(quote.attribution(), Some("Someone"));
1104 assert_eq!(quote.blocks().len(), 1);
1105 }
1106
1107 #[test]
1108 fn markdown_blockquote_lazy_continuation() {
1109 let block = parse_one("> line one\nline two");
1111 let quote = as_quote(&block);
1112
1113 assert_eq!(quote.blocks().len(), 1);
1114 let inner = quote.blocks().first().unwrap();
1115 assert_eq!(inner.rendered_content(), Some("line one\nline two"));
1116 }
1117
1118 #[test]
1119 fn markdown_marker_requires_space() {
1120 let block = parse_one(">foo bar");
1122 assert_eq!(block.raw_context().deref(), "paragraph");
1123 }
1124
1125 #[test]
1126 fn markdown_yields_to_description_list() {
1127 let block = parse_one("> term:: definition");
1130 assert_eq!(block.raw_context().deref(), "list");
1131 }
1132
1133 #[test]
1134 fn unterminated_delimited_quote_warns() {
1135 let mut parser = Parser::default();
1136 let maw = Block::parse(crate::Span::new("____\nunclosed"), &mut parser);
1137
1138 let block = maw.item.unwrap().item;
1139 assert_eq!(block.raw_context().deref(), "quote");
1140 assert_eq!(maw.warnings.len(), 1);
1141 assert_eq!(
1142 maw.warnings.first().unwrap().warning,
1143 WarningType::UnterminatedDelimitedBlock
1144 );
1145 }
1146
1147 #[test]
1148 fn citation_receives_inline_substitutions() {
1149 let block = parse_one(
1151 "[quote,Lewis Carroll,'See https://example.com/lc[the bio]']\n____\nAny road.\n____",
1152 );
1153 let quote = as_quote(&block);
1154 let citetitle = quote.citetitle().unwrap();
1155 assert!(
1156 citetitle.contains("<a href=\"https://example.com/lc\">the bio</a>"),
1157 "citation was: {citetitle}"
1158 );
1159 }
1160
1161 #[test]
1162 fn block_enum_delegates_to_quote() {
1163 let compound = parse_one("____\nx\n____");
1165 assert_eq!(compound.content_model(), ContentModel::Compound);
1166 assert_eq!(compound.raw_context().deref(), "quote");
1167 assert!(compound.title_source().is_none());
1168 assert!(compound.anchor().is_none());
1169 assert!(compound.anchor_reftext().is_none());
1170 assert!(compound.attrlist().is_none());
1171 assert_eq!(compound.substitution_group(), SubstitutionGroup::Normal);
1172 assert_eq!(compound.nested_blocks().count(), 1);
1173 assert!(compound.title().is_none());
1174 assert!(compound.declared_style().is_none());
1175 assert!(format!("{compound:?}").starts_with("Block::Quote"));
1176
1177 let simple = parse_one("[verse]\nverse text");
1178 assert_eq!(simple.rendered_content(), Some("verse text"));
1179 assert_eq!(simple.content_model(), ContentModel::Simple);
1180 }
1181
1182 #[test]
1183 fn impl_debug() {
1184 let block = parse_one("____\nx\n____");
1185 let quote = as_quote(&block);
1186 let debug = format!("{quote:?}");
1187 assert!(debug.starts_with("QuoteBlock {"));
1188 assert!(debug.contains("type_: QuoteType::Quote"));
1189 }
1190
1191 #[test]
1192 fn impl_clone() {
1193 let block = parse_one("____\nclone me\n____");
1195 let quote = as_quote(&block).clone();
1196 assert_eq!(quote.type_(), QuoteType::Quote);
1197 }
1198
1199 #[test]
1200 fn title_renders_inside_quote_block() {
1201 let doc = Parser::default()
1202 .parse(".A title\n[quote,Captain Kirk]\nEverybody remember where we parked.");
1203 let block = doc.nested_blocks().next().unwrap();
1204 let quote = as_quote(block);
1205 assert_eq!(quote.title(), Some("A title"));
1206 }
1207}