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 origin: None,
231 },
232 );
233 }
234
235 MatchAndWarnings {
236 item: Some(MatchedItem {
237 item: Self {
238 type_,
239 content_model,
240 content,
241 blocks,
242 markdown_blocks: None,
243 attribution,
244 citetitle,
245 source,
246 title_source: metadata.title_source,
247 title: metadata.title.clone(),
248 anchor: metadata.anchor,
249 anchor_reftext: metadata.anchor_reftext,
250 attrlist: metadata.attrlist.clone(),
251 },
252 after,
253 }),
254 warnings,
255 }
256 }
257
258 fn parse_styled_paragraph(
260 metadata: &BlockMetadata<'src>,
261 parser: &mut Parser,
262 type_: QuoteType,
263 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
264 let inner = SimpleBlock::parse(metadata, parser)?;
269
270 let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
271
272 let source = metadata
273 .source
274 .trim_remainder(inner.after)
275 .trim_trailing_whitespace();
276
277 Some(MatchAndWarnings {
278 item: Some(MatchedItem {
279 item: Self {
280 type_,
281 content_model: ContentModel::Simple,
282 content: Some(inner.item.content().clone()),
283 blocks: vec![],
284 markdown_blocks: None,
285 attribution,
286 citetitle,
287 source,
288 title_source: metadata.title_source,
289 title: metadata.title.clone(),
290 anchor: metadata.anchor,
291 anchor_reftext: metadata.anchor_reftext,
292 attrlist: metadata.attrlist.clone(),
293 },
294 after: inner.after,
295 }),
296 warnings: vec![],
297 })
298 }
299
300 fn parse_quoted_paragraph(
305 metadata: &BlockMetadata<'src>,
306 parser: &mut Parser,
307 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
308 let para = read_paragraph(metadata.block_start);
310 let data = para.data();
311
312 if !data.starts_with('"') {
313 return None;
314 }
315
316 let (quoted, attribution_text) = split_at_attribution_line(data)?;
320
321 let inner = quoted
324 .trim_end()
325 .strip_prefix('"')
326 .and_then(|s| s.strip_suffix('"'))
327 .filter(|inner| !inner.is_empty())?;
328
329 let inner_span = para.slice(1..1 + inner.len());
333 let mut content = Content::from(inner_span);
334 SubstitutionGroup::Normal.apply(&mut content, parser, None);
335
336 let (attribution, citetitle) = split_attribution_line(attribution_text.trim(), parser);
338
339 let source = metadata
340 .source
341 .trim_remainder(read_paragraph_after(metadata.block_start))
342 .trim_trailing_whitespace();
343
344 Some(MatchAndWarnings {
345 item: Some(MatchedItem {
346 item: Self {
347 type_: QuoteType::Quote,
348 content_model: ContentModel::Simple,
349 content: Some(content),
350 blocks: vec![],
351 markdown_blocks: None,
352 attribution,
353 citetitle,
354 source,
355 title_source: metadata.title_source,
356 title: metadata.title.clone(),
357 anchor: metadata.anchor,
358 anchor_reftext: metadata.anchor_reftext,
359 attrlist: metadata.attrlist.clone(),
360 },
361 after: read_paragraph_after(metadata.block_start).discard_empty_lines(),
362 }),
363 warnings: vec![],
364 })
365 }
366
367 fn parse_markdown(
375 metadata: &BlockMetadata<'src>,
376 parser: &mut Parser,
377 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
378 let first_line = metadata.block_start.take_normalized_line().item;
379
380 if !is_markdown_marker_line(first_line.data()) {
384 return None;
385 }
386
387 if let Some(MatchedItem {
391 item: ListItemMarker::DefinedTerm { .. },
392 ..
393 }) = ListItemMarker::parse(metadata.block_start, parser)
394 {
395 return None;
396 }
397
398 let chunk = read_paragraph(metadata.block_start);
403 let after = read_paragraph_after(metadata.block_start);
404
405 let mut lines: Vec<String> = chunk
406 .data()
407 .split('\n')
408 .map(|line| {
409 if line == ">" {
410 String::new()
411 } else if let Some(rest) = line.strip_prefix("> ") {
412 rest.to_string()
413 } else {
414 line.to_string()
415 }
416 })
417 .collect();
418
419 let (attribution, citetitle) = take_trailing_attribution(&mut lines, parser);
422
423 let body = lines.join("\n");
424
425 let mut nested_warning_types: Vec<WarningType> = vec![];
434 let owned = OwnedQuoteBlocks::new(body, |source| {
435 let mut maw = parse_blocks_until(Span::new(source), |_, _| false, parser);
436 nested_warning_types.extend(maw.warnings.drain(..).map(|w| w.warning));
437 OwnedQuoteBlocksInner {
438 blocks: maw.item.item,
439 }
440 });
441
442 let source = metadata
443 .source
444 .trim_remainder(after)
445 .trim_trailing_whitespace();
446
447 let warnings = nested_warning_types
448 .into_iter()
449 .map(|warning| Warning {
450 source,
451 warning,
452 origin: None,
453 })
454 .collect();
455
456 Some(MatchAndWarnings {
457 item: Some(MatchedItem {
458 item: Self {
459 type_: QuoteType::Quote,
460 content_model: ContentModel::Compound,
461 content: None,
462 blocks: vec![],
463 markdown_blocks: Some(Arc::new(owned)),
464 attribution,
465 citetitle,
466 source,
467 title_source: metadata.title_source,
468 title: metadata.title.clone(),
469 anchor: metadata.anchor,
470 anchor_reftext: metadata.anchor_reftext,
471 attrlist: metadata.attrlist.clone(),
472 },
473 after: after.discard_empty_lines(),
474 }),
475 warnings,
476 })
477 }
478
479 pub fn type_(&self) -> QuoteType {
481 self.type_
482 }
483
484 pub fn attribution(&self) -> Option<&str> {
487 self.attribution.as_deref()
488 }
489
490 pub fn citetitle(&self) -> Option<&str> {
493 self.citetitle.as_deref()
494 }
495
496 pub fn content(&self) -> Option<&Content<'src>> {
499 self.content.as_ref()
500 }
501
502 pub fn blocks(&self) -> &[Block<'_>] {
509 match &self.markdown_blocks {
510 Some(owned) => &owned.borrow_dependent().blocks,
511 None => &self.blocks,
512 }
513 }
514
515 pub(crate) fn resolve_references(
523 &mut self,
524 resolver: &dyn ReferenceResolver,
525 renderer: &dyn InlineSubstitutionRenderer,
526 warnings: &mut Vec<ReferenceWarning>,
527 ) {
528 if let Some(owned) = self.markdown_blocks.as_mut()
532 && let Some(owned) = Arc::get_mut(owned)
533 {
534 owned.with_dependent_mut(|_, dependent| {
535 for block in &mut dependent.blocks {
536 block.resolve_references(resolver, renderer, warnings);
537 }
538 });
539 }
540 }
541}
542
543fn is_markdown_marker_line(line: &str) -> bool {
546 line == ">" || line.starts_with("> ")
547}
548
549fn take_trailing_attribution(
556 lines: &mut Vec<String>,
557 parser: &Parser,
558) -> (Option<String>, Option<String>) {
559 while lines.last().is_some_and(|line| line.is_empty()) {
560 lines.pop();
561 }
562
563 if let Some(last) = lines.last()
564 && let Some(rest) = last.strip_prefix("--")
565 && (rest.starts_with(' ') || rest.starts_with('\t'))
566 {
567 let result = split_attribution_line(rest.trim(), parser);
568 lines.pop();
569 while lines.last().is_some_and(|line| line.is_empty()) {
570 lines.pop();
571 }
572 return result;
573 }
574
575 (None, None)
576}
577
578pub(crate) fn is_quote_verse_delimiter(line: &Span<'_>) -> bool {
581 let data = line.data();
582 data.len() >= 4 && data.starts_with("____") && data.chars().all(|c| c == '_')
583}
584
585fn render_verbatim<'src>(inside: Span<'src>, parser: &Parser) -> Content<'src> {
588 let trimmed = inside.discard_empty_lines().trim_trailing_whitespace();
589 let mut content = Content::from(trimmed);
590 SubstitutionGroup::Normal.apply(&mut content, parser, None);
591 content
592}
593
594fn render_inline(parser: &Parser, text: &str) -> String {
597 let span = Span::new(text);
598 let mut content = Content::from(span);
599 SubstitutionGroup::Normal.apply(&mut content, parser, None);
600 content.rendered_owned()
601}
602
603fn extract_attribution(attrlist: Option<&Attrlist<'_>>) -> (Option<String>, Option<String>) {
613 let Some(attrlist) = attrlist else {
614 return (None, None);
615 };
616
617 let attribution = attrlist
618 .named_or_positional_attribute("attribution", 2)
619 .map(|a| a.value())
620 .filter(|v| !v.is_empty())
621 .map(str::to_string);
622
623 let citetitle = attrlist
624 .named_or_positional_attribute("citetitle", 3)
625 .map(|a| a.value())
626 .filter(|v| !v.is_empty())
627 .map(str::to_string);
628
629 (attribution, citetitle)
630}
631
632fn split_attribution_line(text: &str, parser: &Parser) -> (Option<String>, Option<String>) {
635 match text.split_once(',') {
636 Some((attribution, citetitle)) => {
637 let attribution = attribution.trim();
638 let citetitle = citetitle.trim();
639 (
640 non_empty(attribution).map(|v| render_inline(parser, v)),
641 non_empty(citetitle).map(|v| render_inline(parser, v)),
642 )
643 }
644 None => (
645 non_empty(text.trim()).map(|v| render_inline(parser, v)),
646 None,
647 ),
648 }
649}
650
651fn non_empty(s: &str) -> Option<&str> {
652 if s.is_empty() { None } else { Some(s) }
653}
654
655fn split_at_attribution_line(data: &str) -> Option<(&str, &str)> {
666 let mut line_start = 0;
667 let mut attribution: Option<(usize, &str)> = None;
668
669 for line in data.split_inclusive('\n') {
670 let trimmed = line.strip_suffix('\n').unwrap_or(line);
671
672 if let Some(rest) = trimmed.strip_prefix("--")
673 && (rest.starts_with(' ') || rest.starts_with('\t'))
674 {
675 let attribution_text = rest.trim_start_matches([' ', '\t']);
676 if !attribution_text.is_empty() && line_start > 0 {
679 attribution = Some((line_start, attribution_text));
680 }
681 }
682
683 line_start += line.len();
684 }
685
686 attribution.map(|(start, text)| (data.split_at(start).0, text))
689}
690
691fn read_paragraph(source: Span<'_>) -> Span<'_> {
694 source.trim_remainder(read_paragraph_after(source))
695}
696
697fn read_paragraph_after(source: Span<'_>) -> Span<'_> {
700 let mut next = source;
701 while let Some(line) = next.take_non_empty_line() {
702 next = line.after;
703 }
704 next
705}
706
707impl<'src> IsBlock<'src> for QuoteBlock<'src> {
708 fn content_model(&self) -> ContentModel {
709 self.content_model
710 }
711
712 fn raw_context(&self) -> CowStr<'src> {
713 self.type_.name().into()
714 }
715
716 fn declared_style(&'src self) -> Option<&'src str> {
717 self.attrlist
718 .as_ref()
719 .and_then(|attrlist| attrlist.block_style())
720 }
721
722 fn rendered_content(&'src self) -> Option<&'src str> {
723 self.content.as_ref().map(|content| content.rendered())
724 }
725
726 fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
737 self.blocks.iter()
738 }
739
740 fn nested_blocks_mut(&mut self) -> &mut [Block<'src>] {
747 &mut self.blocks
748 }
749
750 fn content_mut(&mut self) -> Option<&mut Content<'src>> {
751 self.content.as_mut()
752 }
753
754 fn title_source(&'src self) -> Option<Span<'src>> {
755 self.title_source
756 }
757
758 fn title(&self) -> Option<&str> {
759 self.title.as_deref()
760 }
761
762 fn anchor(&'src self) -> Option<Span<'src>> {
763 self.anchor
764 }
765
766 fn anchor_reftext(&'src self) -> Option<Span<'src>> {
767 self.anchor_reftext
768 }
769
770 fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
771 self.attrlist.as_ref()
772 }
773}
774
775impl<'src> HasSpan<'src> for QuoteBlock<'src> {
776 fn span(&self) -> Span<'src> {
777 self.source
778 }
779}
780
781impl std::fmt::Debug for QuoteBlock<'_> {
782 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
783 f.debug_struct("QuoteBlock")
784 .field("type_", &self.type_)
785 .field("content_model", &self.content_model)
786 .field("content", &self.content)
787 .field("blocks", &DebugSliceReference(&self.blocks))
788 .field("attribution", &self.attribution)
789 .field("citetitle", &self.citetitle)
790 .field("source", &self.source)
791 .field("title_source", &self.title_source)
792 .field("title", &self.title)
793 .field("anchor", &self.anchor)
794 .field("anchor_reftext", &self.anchor_reftext)
795 .field("attrlist", &self.attrlist)
796 .finish()
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 #![allow(clippy::unwrap_used)]
803 #![allow(clippy::panic)]
804
805 use std::ops::Deref;
806
807 use crate::{
808 blocks::{Block, ContentModel, IsBlock, QuoteType},
809 tests::prelude::*,
810 };
811
812 fn parse_one(input: &'static str) -> Block<'static> {
813 let mut parser = Parser::default();
814 Block::parse(crate::Span::new(input), &mut parser)
815 .unwrap_if_no_warnings()
816 .unwrap()
817 .item
818 }
819
820 fn as_quote<'a>(block: &'a Block<'a>) -> &'a crate::blocks::QuoteBlock<'a> {
821 match block {
822 Block::Quote(quote) => quote,
823 other => panic!("expected a quote block, got {other:?}"),
827 }
828 }
829
830 mod quote_type {
831 use crate::blocks::QuoteType;
832
833 #[test]
834 fn name() {
835 assert_eq!(QuoteType::Quote.name(), "quote");
836 assert_eq!(QuoteType::Verse.name(), "verse");
837 }
838
839 #[test]
840 fn impl_debug() {
841 assert_eq!(format!("{:?}", QuoteType::Quote), "QuoteType::Quote");
842 assert_eq!(format!("{:?}", QuoteType::Verse), "QuoteType::Verse");
843 }
844
845 #[test]
846 fn impl_clone() {
847 let v1 = QuoteType::Quote;
849 let v2 = v1;
850 assert_eq!(v1, v2);
851 }
852 }
853
854 #[test]
855 fn delimited_quote_is_compound() {
856 let block = parse_one("____\nA quote.\n\nWith two paragraphs.\n____");
857 let quote = as_quote(&block);
858
859 assert_eq!(quote.type_(), QuoteType::Quote);
860 assert_eq!(quote.content_model(), ContentModel::Compound);
861 assert_eq!(quote.raw_context().deref(), "quote");
862 assert!(quote.content().is_none());
863 assert!(quote.attribution().is_none());
864 assert!(quote.citetitle().is_none());
865 assert_eq!(quote.blocks().len(), 2);
866 assert_eq!(quote.nested_blocks().count(), 2);
867 }
868
869 #[test]
870 fn delimited_quote_with_attribution_and_citation() {
871 let block =
872 parse_one("[quote,Abraham Lincoln,Gettysburg Address]\n____\nFour score.\n____");
873 let quote = as_quote(&block);
874
875 assert_eq!(quote.attribution(), Some("Abraham Lincoln"));
876 assert_eq!(quote.citetitle(), Some("Gettysburg Address"));
877 assert_eq!(quote.declared_style(), Some("quote"));
878 }
879
880 #[test]
881 fn styled_paragraph_quote_is_simple() {
882 let block = parse_one("[quote,Albert Einstein]\nA person who never made a mistake.");
883 let quote = as_quote(&block);
884
885 assert_eq!(quote.type_(), QuoteType::Quote);
886 assert_eq!(quote.content_model(), ContentModel::Simple);
887 assert_eq!(
888 quote.content().unwrap().rendered(),
889 "A person who never made a mistake."
890 );
891 assert_eq!(
892 quote.rendered_content(),
893 Some("A person who never made a mistake.")
894 );
895 assert_eq!(quote.attribution(), Some("Albert Einstein"));
896 assert!(quote.citetitle().is_none());
897 }
898
899 #[test]
900 fn verse_paragraph_is_simple() {
901 let block = parse_one("[verse,Carl Sandburg,Fog]\nThe fog comes\non little cat feet.");
902 let quote = as_quote(&block);
903
904 assert_eq!(quote.type_(), QuoteType::Verse);
905 assert_eq!(quote.content_model(), ContentModel::Simple);
906 assert_eq!(quote.raw_context().deref(), "verse");
907 assert_eq!(
908 quote.content().unwrap().rendered(),
909 "The fog comes\non little cat feet."
910 );
911 assert_eq!(quote.attribution(), Some("Carl Sandburg"));
912 assert_eq!(quote.citetitle(), Some("Fog"));
913 }
914
915 #[test]
916 fn verse_delimited_preserves_line_breaks() {
917 let block = parse_one("[verse]\n____\nA verse\ndelimited block\n____");
918 let quote = as_quote(&block);
919
920 assert_eq!(quote.type_(), QuoteType::Verse);
921 assert_eq!(quote.content_model(), ContentModel::Simple);
922 assert_eq!(
923 quote.content().unwrap().rendered(),
924 "A verse\ndelimited block"
925 );
926 assert!(quote.nested_blocks().next().is_none());
927 }
928
929 #[test]
930 fn quote_or_verse_style_over_other_container_is_not_a_quote() {
931 assert_eq!(
934 parse_one("[quote]\n====\nx\n====").raw_context().deref(),
935 "example"
936 );
937 assert_eq!(
938 parse_one("[verse]\n****\nx\n****").raw_context().deref(),
939 "sidebar"
940 );
941 assert_eq!(
942 parse_one("[quote]\n----\nx\n----").raw_context().deref(),
943 "listing"
944 );
945 }
946
947 #[test]
948 fn quoted_paragraph_with_attribution_and_citation() {
949 let block = parse_one("\"A little rebellion is good.\"\n-- Thomas Jefferson, Volume 11");
950 let quote = as_quote(&block);
951
952 assert_eq!(quote.type_(), QuoteType::Quote);
953 assert_eq!(quote.content_model(), ContentModel::Simple);
954 assert_eq!(
955 quote.content().unwrap().rendered(),
956 "A little rebellion is good."
957 );
958 assert_eq!(quote.attribution(), Some("Thomas Jefferson"));
959 assert_eq!(quote.citetitle(), Some("Volume 11"));
960 }
961
962 #[test]
963 fn quoted_paragraph_without_citation() {
964 let block = parse_one("\"A quote.\"\n-- Anonymous");
965 let quote = as_quote(&block);
966
967 assert_eq!(quote.attribution(), Some("Anonymous"));
968 assert!(quote.citetitle().is_none());
969 }
970
971 #[test]
972 fn quoted_paragraph_requires_attribution_line() {
973 let block = parse_one("\"Just a quoted sentence.\"");
976 assert_eq!(block.raw_context().deref(), "paragraph");
977 }
978
979 #[test]
980 fn quoted_paragraph_requires_opening_quote() {
981 let block = parse_one("Not quoted.\n-- Someone");
984 assert_eq!(block.raw_context().deref(), "paragraph");
985 }
986
987 #[test]
988 fn empty_quoted_paragraph_is_not_a_quote() {
989 let block = parse_one("\"\"\n-- Someone");
991 assert_eq!(block.raw_context().deref(), "paragraph");
992 }
993
994 #[test]
995 fn unclosed_quoted_paragraph_is_not_a_quote() {
996 let block = parse_one("\"no closing quote\n-- Someone");
998 assert_eq!(block.raw_context().deref(), "paragraph");
999 }
1000
1001 #[test]
1002 fn dash_line_without_attribution_text_is_not_a_quote() {
1003 let block = parse_one("\"A quote.\"\n-- ");
1006 assert_eq!(block.raw_context().deref(), "paragraph");
1007 }
1008
1009 #[test]
1010 fn quoted_paragraph_tab_separated_attribution() {
1011 let block = parse_one("\"A quote.\"\n--\tSomeone");
1013 let quote = as_quote(&block);
1014 assert_eq!(quote.attribution(), Some("Someone"));
1015 }
1016
1017 #[test]
1018 fn quoted_paragraph_uses_last_attribution_line() {
1019 let block =
1022 parse_one("\"line one\n-- not really an attribution\nline two\"\n-- Real Attribution");
1023 let quote = as_quote(&block);
1024 assert_eq!(quote.attribution(), Some("Real Attribution"));
1025 let rendered = quote.content().unwrap().rendered();
1026 assert!(
1027 rendered.contains("line one") && rendered.contains("line two"),
1028 "content was: {rendered}"
1029 );
1030 }
1031
1032 #[test]
1033 fn attribution_with_empty_name_keeps_citation() {
1034 let block = parse_one("\"A quote.\"\n-- , Just a citation");
1037 let quote = as_quote(&block);
1038 assert!(quote.attribution().is_none());
1039 assert_eq!(quote.citetitle(), Some("Just a citation"));
1040 }
1041
1042 #[test]
1043 fn styled_paragraph_with_no_content_is_not_a_quote() {
1044 let mut parser = Parser::default();
1047 let maw = Block::parse(crate::Span::new("[quote]\n"), &mut parser);
1048 let block = maw.item.unwrap().item;
1049 assert_eq!(block.raw_context().deref(), "paragraph");
1050 }
1051
1052 #[test]
1053 fn markdown_blockquote_tab_attribution_after_blank() {
1054 let block = parse_one("> A quote.\n>\n> --\tSomeone");
1057 let quote = as_quote(&block);
1058 assert_eq!(quote.attribution(), Some("Someone"));
1059 assert_eq!(quote.blocks().len(), 1);
1060 }
1061
1062 #[test]
1063 fn markdown_blockquote_propagates_nested_warning() {
1064 let mut parser = Parser::default();
1069 let maw = Block::parse(crate::Span::new("> ____\n> unclosed"), &mut parser);
1070
1071 let block = maw.item.unwrap().item;
1072 assert_eq!(block.raw_context().deref(), "quote");
1073 assert_eq!(
1074 maw.warnings.first().unwrap().warning,
1075 WarningType::UnterminatedDelimitedBlock
1076 );
1077 assert_eq!(maw.warnings.first().unwrap().source, block.span());
1079 }
1080
1081 #[test]
1082 fn markdown_blockquote_double_dash_without_space_is_content() {
1083 let block = parse_one("> A quote.\n> --nospace");
1086 let quote = as_quote(&block);
1087 assert!(quote.attribution().is_none());
1088 }
1089
1090 #[test]
1091 fn markdown_blockquote_basic() {
1092 let block = parse_one("> A markdown quote.");
1093 let quote = as_quote(&block);
1094
1095 assert_eq!(quote.type_(), QuoteType::Quote);
1096 assert_eq!(quote.content_model(), ContentModel::Compound);
1097 assert_eq!(quote.blocks().len(), 1);
1098 assert!(quote.nested_blocks().next().is_none());
1101 }
1102
1103 #[test]
1104 fn markdown_blockquote_with_attribution() {
1105 let block = parse_one("> A quote.\n> -- Someone");
1106 let quote = as_quote(&block);
1107
1108 assert_eq!(quote.attribution(), Some("Someone"));
1109 assert_eq!(quote.blocks().len(), 1);
1110 }
1111
1112 #[test]
1113 fn markdown_blockquote_lazy_continuation() {
1114 let block = parse_one("> line one\nline two");
1116 let quote = as_quote(&block);
1117
1118 assert_eq!(quote.blocks().len(), 1);
1119 let inner = quote.blocks().first().unwrap();
1120 assert_eq!(inner.rendered_content(), Some("line one\nline two"));
1121 }
1122
1123 #[test]
1124 fn markdown_marker_requires_space() {
1125 let block = parse_one(">foo bar");
1127 assert_eq!(block.raw_context().deref(), "paragraph");
1128 }
1129
1130 #[test]
1131 fn markdown_yields_to_description_list() {
1132 let block = parse_one("> term:: definition");
1135 assert_eq!(block.raw_context().deref(), "list");
1136 }
1137
1138 #[test]
1139 fn unterminated_delimited_quote_warns() {
1140 let mut parser = Parser::default();
1141 let maw = Block::parse(crate::Span::new("____\nunclosed"), &mut parser);
1142
1143 let block = maw.item.unwrap().item;
1144 assert_eq!(block.raw_context().deref(), "quote");
1145 assert_eq!(maw.warnings.len(), 1);
1146 assert_eq!(
1147 maw.warnings.first().unwrap().warning,
1148 WarningType::UnterminatedDelimitedBlock
1149 );
1150 }
1151
1152 #[test]
1153 fn citation_receives_inline_substitutions() {
1154 let block = parse_one(
1156 "[quote,Lewis Carroll,'See https://example.com/lc[the bio]']\n____\nAny road.\n____",
1157 );
1158 let quote = as_quote(&block);
1159 let citetitle = quote.citetitle().unwrap();
1160 assert!(
1161 citetitle.contains("<a href=\"https://example.com/lc\">the bio</a>"),
1162 "citation was: {citetitle}"
1163 );
1164 }
1165
1166 #[test]
1167 fn block_enum_delegates_to_quote() {
1168 let compound = parse_one("____\nx\n____");
1170 assert_eq!(compound.content_model(), ContentModel::Compound);
1171 assert_eq!(compound.raw_context().deref(), "quote");
1172 assert!(compound.title_source().is_none());
1173 assert!(compound.anchor().is_none());
1174 assert!(compound.anchor_reftext().is_none());
1175 assert!(compound.attrlist().is_none());
1176 assert_eq!(compound.substitution_group(), SubstitutionGroup::Normal);
1177 assert_eq!(compound.nested_blocks().count(), 1);
1178 assert!(compound.title().is_none());
1179 assert!(compound.declared_style().is_none());
1180 assert!(format!("{compound:?}").starts_with("Block::Quote"));
1181
1182 let simple = parse_one("[verse]\nverse text");
1183 assert_eq!(simple.rendered_content(), Some("verse text"));
1184 assert_eq!(simple.content_model(), ContentModel::Simple);
1185 }
1186
1187 #[test]
1188 fn impl_debug() {
1189 let block = parse_one("____\nx\n____");
1190 let quote = as_quote(&block);
1191 let debug = format!("{quote:?}");
1192 assert!(debug.starts_with("QuoteBlock {"));
1193 assert!(debug.contains("type_: QuoteType::Quote"));
1194 }
1195
1196 #[test]
1197 fn impl_clone() {
1198 let block = parse_one("____\nclone me\n____");
1200 let quote = as_quote(&block).clone();
1201 assert_eq!(quote.type_(), QuoteType::Quote);
1202 }
1203
1204 #[test]
1205 fn title_renders_inside_quote_block() {
1206 let doc = Parser::default()
1207 .parse(".A title\n[quote,Captain Kirk]\nEverybody remember where we parked.");
1208 let block = doc.nested_blocks().next().unwrap();
1209 let quote = as_quote(block);
1210 assert_eq!(quote.title(), Some("A title"));
1211 }
1212}