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, Hash, PartialEq}
32}
33
34#[derive(Debug, Eq, PartialEq)]
36struct OwnedQuoteBlocksInner<'src> {
37 blocks: Vec<Block<'src>>,
38}
39
40#[derive(Clone, Copy, Eq, Hash, 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, Hash, 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 previously_in_delimited_block = parser.in_delimited_block;
230 parser.in_delimited_block = true;
231
232 let maw_blocks = parse_blocks_until(inside_delimiters, |_, _| false, parser);
233
234 parser.in_delimited_block = previously_in_delimited_block;
235 (
236 ContentModel::Compound,
237 None,
238 maw_blocks.item.item,
239 maw_blocks.warnings,
240 )
241 }
242
243 QuoteType::Verse => {
246 let content = render_verbatim(inside_delimiters, parser);
247 (ContentModel::Simple, Some(content), vec![], vec![])
248 }
249 };
250
251 let source = metadata
252 .source
253 .trim_remainder(closing_delimiter.discard_all())
254 .trim_trailing_whitespace();
255
256 if closing_delimiter.is_empty() {
257 warnings.insert(
258 0,
259 Warning::new(delimiter.item, WarningType::UnterminatedDelimitedBlock),
260 );
261 }
262
263 MatchAndWarnings {
264 item: Some(MatchedItem {
265 item: Self {
266 type_,
267 content_model,
268 content,
269 blocks,
270 markdown_blocks: None,
271 attribution,
272 citetitle,
273 source,
274 title_source: metadata.title_source,
275 title: metadata.title.clone(),
276 anchor: metadata.anchor,
277 anchor_reftext: metadata.anchor_reftext,
278 attrlist: metadata.attrlist.clone(),
279 },
280 after,
281 }),
282 warnings,
283 }
284 }
285
286 fn parse_styled_paragraph(
288 metadata: &BlockMetadata<'src>,
289 parser: &mut Parser,
290 type_: QuoteType,
291 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
292 let inner = SimpleBlock::parse(metadata, parser)?;
297
298 let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
299
300 let source = metadata
301 .source
302 .trim_remainder(inner.after)
303 .trim_trailing_whitespace();
304
305 Some(MatchAndWarnings {
306 item: Some(MatchedItem {
307 item: Self {
308 type_,
309 content_model: ContentModel::Simple,
310 content: Some(inner.item.content().clone()),
311 blocks: vec![],
312 markdown_blocks: None,
313 attribution,
314 citetitle,
315 source,
316 title_source: metadata.title_source,
317 title: metadata.title.clone(),
318 anchor: metadata.anchor,
319 anchor_reftext: metadata.anchor_reftext,
320 attrlist: metadata.attrlist.clone(),
321 },
322 after: inner.after,
323 }),
324 warnings: vec![],
325 })
326 }
327
328 fn parse_quoted_paragraph(
333 metadata: &BlockMetadata<'src>,
334 parser: &mut Parser,
335 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
336 if !metadata.block_start.data().starts_with('"') {
346 return None;
347 }
348
349 let para = read_paragraph(metadata.block_start);
351 let data = para.data();
352
353 let (quoted, attribution_text) = split_at_attribution_line(data)?;
357
358 let inner = quoted
361 .trim_end()
362 .strip_prefix('"')
363 .and_then(|s| s.strip_suffix('"'))
364 .filter(|inner| !inner.is_empty())?;
365
366 let inner_span = para.slice(1..1 + inner.len());
370 let mut content = Content::from(inner_span);
371 SubstitutionGroup::Normal.apply(&mut content, parser, None);
372
373 let (attribution, citetitle) = split_attribution_line(attribution_text.trim(), parser);
375
376 let source = metadata
377 .source
378 .trim_remainder(read_paragraph_after(metadata.block_start))
379 .trim_trailing_whitespace();
380
381 Some(MatchAndWarnings {
382 item: Some(MatchedItem {
383 item: Self {
384 type_: QuoteType::Quote,
385 content_model: ContentModel::Simple,
386 content: Some(content),
387 blocks: vec![],
388 markdown_blocks: None,
389 attribution,
390 citetitle,
391 source,
392 title_source: metadata.title_source,
393 title: metadata.title.clone(),
394 anchor: metadata.anchor,
395 anchor_reftext: metadata.anchor_reftext,
396 attrlist: metadata.attrlist.clone(),
397 },
398 after: read_paragraph_after(metadata.block_start).discard_empty_lines(),
399 }),
400 warnings: vec![],
401 })
402 }
403
404 fn parse_markdown(
412 metadata: &BlockMetadata<'src>,
413 parser: &mut Parser,
414 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
415 let first_line = metadata.block_start.take_normalized_line().item;
416
417 if !is_markdown_marker_line(first_line.data()) {
421 return None;
422 }
423
424 if let Some(MatchedItem {
428 item: ListItemMarker::DefinedTerm { .. },
429 ..
430 }) = ListItemMarker::parse(metadata.block_start, parser)
431 {
432 return None;
433 }
434
435 let chunk = read_paragraph(metadata.block_start);
440 let after = read_paragraph_after(metadata.block_start);
441
442 let mut lines: Vec<String> = chunk
443 .data()
444 .split('\n')
445 .map(|line| {
446 if line == ">" {
447 String::new()
448 } else if let Some(rest) = line.strip_prefix("> ") {
449 rest.to_string()
450 } else {
451 line.to_string()
452 }
453 })
454 .collect();
455
456 let (attribution, citetitle) = take_trailing_attribution(&mut lines, parser);
459
460 let body = lines.join("\n");
461
462 let mut nested_warning_types: Vec<WarningType> = vec![];
471 let owned = OwnedQuoteBlocks::new(body, |source| {
472 parser.owned_subsource_depth += 1;
476
477 let previously_in_delimited_block = parser.in_delimited_block;
481 parser.in_delimited_block = true;
482
483 let mut maw = parse_blocks_until(Span::new(source), |_, _| false, parser);
484
485 parser.in_delimited_block = previously_in_delimited_block;
486 parser.owned_subsource_depth -= 1;
487 nested_warning_types.extend(maw.warnings.drain(..).map(|w| w.warning));
488 OwnedQuoteBlocksInner {
489 blocks: maw.item.item,
490 }
491 });
492
493 let source = metadata
494 .source
495 .trim_remainder(after)
496 .trim_trailing_whitespace();
497
498 let warnings = nested_warning_types
499 .into_iter()
500 .map(|warning| Warning::new(source, warning))
501 .collect();
502
503 Some(MatchAndWarnings {
504 item: Some(MatchedItem {
505 item: Self {
506 type_: QuoteType::Quote,
507 content_model: ContentModel::Compound,
508 content: None,
509 blocks: vec![],
510 markdown_blocks: Some(Arc::new(owned)),
511 attribution,
512 citetitle,
513 source,
514 title_source: metadata.title_source,
515 title: metadata.title.clone(),
516 anchor: metadata.anchor,
517 anchor_reftext: metadata.anchor_reftext,
518 attrlist: metadata.attrlist.clone(),
519 },
520 after: after.discard_empty_lines(),
521 }),
522 warnings,
523 })
524 }
525
526 pub fn type_(&self) -> QuoteType {
528 self.type_
529 }
530
531 pub fn attribution(&self) -> Option<&str> {
534 self.attribution.as_deref()
535 }
536
537 pub fn citetitle(&self) -> Option<&str> {
540 self.citetitle.as_deref()
541 }
542
543 pub fn content(&self) -> Option<&Content<'src>> {
546 self.content.as_ref()
547 }
548
549 pub fn blocks(&self) -> &[Block<'_>] {
555 match &self.markdown_blocks {
556 Some(owned) => &owned.borrow_dependent().blocks,
557 None => &self.blocks,
558 }
559 }
560
561 pub(crate) fn resolve_references(
569 &mut self,
570 resolver: &dyn ReferenceResolver,
571 renderer: &dyn InlineSubstitutionRenderer,
572 warnings: &mut ReferenceWarnings<'src>,
573 ) {
574 let source = self.source;
575
576 if let Some(owned) = self.markdown_blocks.as_mut()
580 && let Some(owned) = Arc::get_mut(owned)
581 {
582 owned.with_dependent_mut(|_, dependent| {
583 let mut owned_warnings = ReferenceWarnings::default();
587
588 for block in &mut dependent.blocks {
589 block.resolve_references(resolver, renderer, &mut owned_warnings);
590 }
591
592 owned_warnings.rehome_into(warnings, source);
593 });
594 }
595 }
596}
597
598fn is_markdown_marker_line(line: &str) -> bool {
601 line == ">" || line.starts_with("> ")
602}
603
604fn take_trailing_attribution(
611 lines: &mut Vec<String>,
612 parser: &Parser,
613) -> (Option<String>, Option<String>) {
614 while lines.last().is_some_and(|line| line.is_empty()) {
615 lines.pop();
616 }
617
618 if let Some(last) = lines.last()
619 && let Some(rest) = last.strip_prefix("--")
620 && (rest.starts_with(' ') || rest.starts_with('\t'))
621 {
622 let result = split_attribution_line(rest.trim(), parser);
623 lines.pop();
624 while lines.last().is_some_and(|line| line.is_empty()) {
625 lines.pop();
626 }
627 return result;
628 }
629
630 (None, None)
631}
632
633pub(crate) fn is_quote_verse_delimiter(line: &Span<'_>) -> bool {
636 let data = line.data();
637 data.len() >= 4 && data.starts_with("____") && data.chars().all(|c| c == '_')
638}
639
640fn render_verbatim<'src>(inside: Span<'src>, parser: &Parser) -> Content<'src> {
643 let trimmed = inside.discard_empty_lines().trim_trailing_whitespace();
644 let mut content = Content::from(trimmed);
645 SubstitutionGroup::Normal.apply(&mut content, parser, None);
646 content
647}
648
649fn render_inline(parser: &Parser, text: &str) -> String {
652 let span = Span::new(text);
653 let mut content = Content::from(span);
654 SubstitutionGroup::Normal.apply(&mut content, parser, None);
655 content.rendered_owned()
656}
657
658fn extract_attribution(attrlist: Option<&Attrlist<'_>>) -> (Option<String>, Option<String>) {
668 let Some(attrlist) = attrlist else {
669 return (None, None);
670 };
671
672 let attribution = attrlist
673 .named_or_positional_attribute("attribution", 2)
674 .map(|a| a.value())
675 .filter(|v| !v.is_empty())
676 .map(str::to_string);
677
678 let citetitle = attrlist
679 .named_or_positional_attribute("citetitle", 3)
680 .map(|a| a.value())
681 .filter(|v| !v.is_empty())
682 .map(str::to_string);
683
684 (attribution, citetitle)
685}
686
687fn split_attribution_line(text: &str, parser: &Parser) -> (Option<String>, Option<String>) {
690 match text.split_once(',') {
691 Some((attribution, citetitle)) => {
692 let attribution = attribution.trim();
693 let citetitle = citetitle.trim();
694 (
695 non_empty(attribution).map(|v| render_inline(parser, v)),
696 non_empty(citetitle).map(|v| render_inline(parser, v)),
697 )
698 }
699 None => (
700 non_empty(text.trim()).map(|v| render_inline(parser, v)),
701 None,
702 ),
703 }
704}
705
706fn non_empty(s: &str) -> Option<&str> {
707 if s.is_empty() { None } else { Some(s) }
708}
709
710fn split_at_attribution_line(data: &str) -> Option<(&str, &str)> {
721 let mut line_start = 0;
722 let mut attribution: Option<(usize, &str)> = None;
723
724 for line in data.split_inclusive('\n') {
725 let trimmed = line.strip_suffix('\n').unwrap_or(line);
726
727 if let Some(rest) = trimmed.strip_prefix("--")
728 && (rest.starts_with(' ') || rest.starts_with('\t'))
729 {
730 let attribution_text = rest.trim_start_matches([' ', '\t']);
731
732 if !attribution_text.is_empty() && line_start > 0 {
735 attribution = Some((line_start, attribution_text));
736 }
737 }
738
739 line_start += line.len();
740 }
741
742 attribution.map(|(start, text)| (data.split_at(start).0, text))
745}
746
747fn read_paragraph(source: Span<'_>) -> Span<'_> {
750 source.trim_remainder(read_paragraph_after(source))
751}
752
753fn read_paragraph_after(source: Span<'_>) -> Span<'_> {
756 let mut next = source;
757 while let Some(line) = next.take_non_empty_line() {
758 next = line.after;
759 }
760 next
761}
762
763impl<'src> IsBlock<'src> for QuoteBlock<'src> {
764 fn content_model(&self) -> ContentModel {
765 self.content_model
766 }
767
768 fn raw_context(&self) -> CowStr<'src> {
769 self.type_.name().into()
770 }
771
772 fn declared_style(&'src self) -> Option<&'src str> {
773 self.attrlist
774 .as_ref()
775 .and_then(|attrlist| attrlist.block_style())
776 }
777
778 fn rendered_content(&'src self) -> Option<&'src str> {
779 self.content.as_ref().map(|content| content.rendered())
780 }
781
782 fn child_blocks_mut(&mut self) -> &mut [Block<'src>] {
792 &mut self.blocks
793 }
794
795 fn content_mut(&mut self) -> Option<&mut Content<'src>> {
796 self.content.as_mut()
797 }
798
799 fn title_source(&'src self) -> Option<Span<'src>> {
800 self.title_source
801 }
802
803 fn title(&self) -> Option<&str> {
804 self.title.as_ref().map(Content::rendered_str)
805 }
806
807 fn anchor(&'src self) -> Option<Span<'src>> {
808 self.anchor
809 }
810
811 fn anchor_reftext(&'src self) -> Option<Span<'src>> {
812 self.anchor_reftext
813 }
814
815 fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
816 self.attrlist.as_ref()
817 }
818}
819
820impl<'src> HasSpan<'src> for QuoteBlock<'src> {
821 fn span(&self) -> Span<'src> {
822 self.source
823 }
824}
825
826impl std::fmt::Debug for QuoteBlock<'_> {
827 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
828 f.debug_struct("QuoteBlock")
829 .field("type_", &self.type_)
830 .field("content_model", &self.content_model)
831 .field("content", &self.content)
832 .field("blocks", &DebugSliceReference(&self.blocks))
833 .field("attribution", &self.attribution)
834 .field("citetitle", &self.citetitle)
835 .field("source", &self.source)
836 .field("title_source", &self.title_source)
837 .field("title", &self.title)
838 .field("anchor", &self.anchor)
839 .field("anchor_reftext", &self.anchor_reftext)
840 .field("attrlist", &self.attrlist)
841 .finish()
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 #![allow(clippy::unwrap_used)]
848 #![allow(clippy::panic)]
849
850 use std::ops::Deref;
851
852 use crate::{
853 blocks::{Block, ContentModel, IsBlock, QuoteType},
854 tests::prelude::*,
855 };
856
857 fn parse_one(input: &'static str) -> Block<'static> {
858 let mut parser = Parser::default();
859 Block::parse(crate::Span::new(input), &mut parser)
860 .unwrap_if_no_warnings()
861 .unwrap()
862 .item
863 }
864
865 fn as_quote<'a>(block: &'a Block<'a>) -> &'a crate::blocks::QuoteBlock<'a> {
866 match block {
867 Block::Quote(quote) => quote,
868
869 other => panic!("expected a quote block, got {other:?}"),
873 }
874 }
875
876 mod quote_type {
877 use crate::blocks::QuoteType;
878
879 #[test]
880 fn name() {
881 assert_eq!(QuoteType::Quote.name(), "quote");
882 assert_eq!(QuoteType::Verse.name(), "verse");
883 }
884
885 #[test]
886 fn impl_debug() {
887 assert_eq!(format!("{:?}", QuoteType::Quote), "QuoteType::Quote");
888 assert_eq!(format!("{:?}", QuoteType::Verse), "QuoteType::Verse");
889 }
890
891 #[test]
892 fn impl_clone() {
893 let v1 = QuoteType::Quote;
895 let v2 = v1;
896 assert_eq!(v1, v2);
897 }
898 }
899
900 #[test]
901 fn delimited_quote_is_compound() {
902 let block = parse_one("____\nA quote.\n\nWith two paragraphs.\n____");
903 let quote = as_quote(&block);
904
905 assert_eq!(quote.type_(), QuoteType::Quote);
906 assert_eq!(quote.content_model(), ContentModel::Compound);
907 assert_eq!(quote.raw_context().deref(), "quote");
908 assert!(quote.content().is_none());
909 assert!(quote.attribution().is_none());
910 assert!(quote.citetitle().is_none());
911 assert_eq!(quote.blocks().len(), 2);
912 assert_eq!(quote.child_blocks().count(), 2);
913 }
914
915 #[test]
916 fn delimited_quote_with_attribution_and_citation() {
917 let block =
918 parse_one("[quote,Abraham Lincoln,Gettysburg Address]\n____\nFour score.\n____");
919 let quote = as_quote(&block);
920
921 assert_eq!(quote.attribution(), Some("Abraham Lincoln"));
922 assert_eq!(quote.citetitle(), Some("Gettysburg Address"));
923 assert_eq!(quote.declared_style(), Some("quote"));
924 }
925
926 #[test]
927 fn styled_paragraph_quote_is_simple() {
928 let block = parse_one("[quote,Albert Einstein]\nA person who never made a mistake.");
929 let quote = as_quote(&block);
930
931 assert_eq!(quote.type_(), QuoteType::Quote);
932 assert_eq!(quote.content_model(), ContentModel::Simple);
933 assert_eq!(
934 quote.content().unwrap().rendered(),
935 "A person who never made a mistake."
936 );
937 assert_eq!(
938 quote.rendered_content(),
939 Some("A person who never made a mistake.")
940 );
941 assert_eq!(quote.attribution(), Some("Albert Einstein"));
942 assert!(quote.citetitle().is_none());
943 }
944
945 #[test]
946 fn verse_paragraph_is_simple() {
947 let block = parse_one("[verse,Carl Sandburg,Fog]\nThe fog comes\non little cat feet.");
948 let quote = as_quote(&block);
949
950 assert_eq!(quote.type_(), QuoteType::Verse);
951 assert_eq!(quote.content_model(), ContentModel::Simple);
952 assert_eq!(quote.raw_context().deref(), "verse");
953 assert_eq!(
954 quote.content().unwrap().rendered(),
955 "The fog comes\non little cat feet."
956 );
957 assert_eq!(quote.attribution(), Some("Carl Sandburg"));
958 assert_eq!(quote.citetitle(), Some("Fog"));
959 }
960
961 #[test]
962 fn verse_delimited_preserves_line_breaks() {
963 let block = parse_one("[verse]\n____\nA verse\ndelimited block\n____");
964 let quote = as_quote(&block);
965
966 assert_eq!(quote.type_(), QuoteType::Verse);
967 assert_eq!(quote.content_model(), ContentModel::Simple);
968 assert_eq!(
969 quote.content().unwrap().rendered(),
970 "A verse\ndelimited block"
971 );
972 assert!(quote.child_blocks().next().is_none());
973 }
974
975 #[test]
976 fn quote_or_verse_style_over_other_container_is_not_a_quote() {
977 assert_eq!(
980 parse_one("[quote]\n====\nx\n====").raw_context().deref(),
981 "example"
982 );
983 assert_eq!(
984 parse_one("[verse]\n****\nx\n****").raw_context().deref(),
985 "sidebar"
986 );
987 assert_eq!(
988 parse_one("[quote]\n----\nx\n----").raw_context().deref(),
989 "listing"
990 );
991 }
992
993 #[test]
994 fn quoted_paragraph_with_attribution_and_citation() {
995 let block = parse_one("\"A little rebellion is good.\"\n-- Thomas Jefferson, Volume 11");
996 let quote = as_quote(&block);
997
998 assert_eq!(quote.type_(), QuoteType::Quote);
999 assert_eq!(quote.content_model(), ContentModel::Simple);
1000 assert_eq!(
1001 quote.content().unwrap().rendered(),
1002 "A little rebellion is good."
1003 );
1004 assert_eq!(quote.attribution(), Some("Thomas Jefferson"));
1005 assert_eq!(quote.citetitle(), Some("Volume 11"));
1006 }
1007
1008 #[test]
1009 fn quoted_paragraph_without_citation() {
1010 let block = parse_one("\"A quote.\"\n-- Anonymous");
1011 let quote = as_quote(&block);
1012
1013 assert_eq!(quote.attribution(), Some("Anonymous"));
1014 assert!(quote.citetitle().is_none());
1015 }
1016
1017 #[test]
1018 fn quoted_paragraph_requires_attribution_line() {
1019 let block = parse_one("\"Just a quoted sentence.\"");
1022 assert_eq!(block.raw_context().deref(), "paragraph");
1023 }
1024
1025 #[test]
1026 fn quoted_paragraph_requires_opening_quote() {
1027 let block = parse_one("Not quoted.\n-- Someone");
1030 assert_eq!(block.raw_context().deref(), "paragraph");
1031 }
1032
1033 #[test]
1034 fn empty_quoted_paragraph_is_not_a_quote() {
1035 let block = parse_one("\"\"\n-- Someone");
1037 assert_eq!(block.raw_context().deref(), "paragraph");
1038 }
1039
1040 #[test]
1041 fn unclosed_quoted_paragraph_is_not_a_quote() {
1042 let block = parse_one("\"no closing quote\n-- Someone");
1044 assert_eq!(block.raw_context().deref(), "paragraph");
1045 }
1046
1047 #[test]
1048 fn dash_line_without_attribution_text_is_not_a_quote() {
1049 let block = parse_one("\"A quote.\"\n-- ");
1052 assert_eq!(block.raw_context().deref(), "paragraph");
1053 }
1054
1055 #[test]
1056 fn quoted_paragraph_tab_separated_attribution() {
1057 let block = parse_one("\"A quote.\"\n--\tSomeone");
1059 let quote = as_quote(&block);
1060 assert_eq!(quote.attribution(), Some("Someone"));
1061 }
1062
1063 #[test]
1064 fn quoted_paragraph_uses_last_attribution_line() {
1065 let block =
1068 parse_one("\"line one\n-- not really an attribution\nline two\"\n-- Real Attribution");
1069 let quote = as_quote(&block);
1070 assert_eq!(quote.attribution(), Some("Real Attribution"));
1071 let rendered = quote.content().unwrap().rendered();
1072 assert!(
1073 rendered.contains("line one") && rendered.contains("line two"),
1074 "content was: {rendered}"
1075 );
1076 }
1077
1078 #[test]
1079 fn attribution_with_empty_name_keeps_citation() {
1080 let block = parse_one("\"A quote.\"\n-- , Just a citation");
1083 let quote = as_quote(&block);
1084 assert!(quote.attribution().is_none());
1085 assert_eq!(quote.citetitle(), Some("Just a citation"));
1086 }
1087
1088 #[test]
1089 fn styled_paragraph_with_no_content_is_not_a_quote() {
1090 let mut parser = Parser::default();
1093 let maw = Block::parse(crate::Span::new("[quote]\n"), &mut parser);
1094 let block = maw.item.unwrap().item;
1095 assert_eq!(block.raw_context().deref(), "paragraph");
1096 }
1097
1098 #[test]
1099 fn markdown_blockquote_tab_attribution_after_blank() {
1100 let block = parse_one("> A quote.\n>\n> --\tSomeone");
1103 let quote = as_quote(&block);
1104 assert_eq!(quote.attribution(), Some("Someone"));
1105 assert_eq!(quote.blocks().len(), 1);
1106 }
1107
1108 #[test]
1109 fn markdown_blockquote_propagates_nested_warning() {
1110 let mut parser = Parser::default();
1115 let maw = Block::parse(crate::Span::new("> ____\n> unclosed"), &mut parser);
1116
1117 let block = maw.item.unwrap().item;
1118 assert_eq!(block.raw_context().deref(), "quote");
1119 assert_eq!(
1120 maw.warnings.first().unwrap().warning,
1121 WarningType::UnterminatedDelimitedBlock
1122 );
1123
1124 assert_eq!(maw.warnings.first().unwrap().source, block.span());
1126 }
1127
1128 #[test]
1129 fn markdown_blockquote_double_dash_without_space_is_content() {
1130 let block = parse_one("> A quote.\n> --nospace");
1133 let quote = as_quote(&block);
1134 assert!(quote.attribution().is_none());
1135 }
1136
1137 #[test]
1138 fn markdown_blockquote_basic() {
1139 let block = parse_one("> A markdown quote.");
1140 let quote = as_quote(&block);
1141
1142 assert_eq!(quote.type_(), QuoteType::Quote);
1143 assert_eq!(quote.content_model(), ContentModel::Compound);
1144 assert_eq!(quote.blocks().len(), 1);
1145
1146 assert_eq!(quote.child_blocks().count(), 1);
1149 }
1150
1151 #[test]
1152 fn markdown_blockquote_with_attribution() {
1153 let block = parse_one("> A quote.\n> -- Someone");
1154 let quote = as_quote(&block);
1155
1156 assert_eq!(quote.attribution(), Some("Someone"));
1157 assert_eq!(quote.blocks().len(), 1);
1158 }
1159
1160 #[test]
1161 fn markdown_blockquote_lazy_continuation() {
1162 let block = parse_one("> line one\nline two");
1164 let quote = as_quote(&block);
1165
1166 assert_eq!(quote.blocks().len(), 1);
1167 let inner = quote.blocks().first().unwrap();
1168 assert_eq!(inner.rendered_content(), Some("line one\nline two"));
1169 }
1170
1171 #[test]
1172 fn markdown_marker_requires_space() {
1173 let block = parse_one(">foo bar");
1175 assert_eq!(block.raw_context().deref(), "paragraph");
1176 }
1177
1178 #[test]
1179 fn markdown_yields_to_description_list() {
1180 let block = parse_one("> term:: definition");
1183 assert_eq!(block.raw_context().deref(), "list");
1184 }
1185
1186 #[test]
1187 fn unterminated_delimited_quote_warns() {
1188 let mut parser = Parser::default();
1189 let maw = Block::parse(crate::Span::new("____\nunclosed"), &mut parser);
1190
1191 let block = maw.item.unwrap().item;
1192 assert_eq!(block.raw_context().deref(), "quote");
1193 assert_eq!(maw.warnings.len(), 1);
1194 assert_eq!(
1195 maw.warnings.first().unwrap().warning,
1196 WarningType::UnterminatedDelimitedBlock
1197 );
1198 }
1199
1200 #[test]
1201 fn citation_receives_inline_substitutions() {
1202 let block = parse_one(
1204 "[quote,Lewis Carroll,'See https://example.com/lc[the bio]']\n____\nAny road.\n____",
1205 );
1206 let quote = as_quote(&block);
1207 let citetitle = quote.citetitle().unwrap();
1208 assert!(
1209 citetitle.contains("<a href=\"https://example.com/lc\">the bio</a>"),
1210 "citation was: {citetitle}"
1211 );
1212 }
1213
1214 #[test]
1215 fn block_enum_delegates_to_quote() {
1216 let compound = parse_one("____\nx\n____");
1218 assert_eq!(compound.content_model(), ContentModel::Compound);
1219 assert_eq!(compound.raw_context().deref(), "quote");
1220 assert!(compound.title_source().is_none());
1221 assert!(compound.anchor().is_none());
1222 assert!(compound.anchor_reftext().is_none());
1223 assert!(compound.attrlist().is_none());
1224 assert_eq!(compound.substitution_group(), SubstitutionGroup::Normal);
1225 assert_eq!(compound.child_blocks().count(), 1);
1226 assert!(compound.title().is_none());
1227 assert!(compound.declared_style().is_none());
1228 assert!(format!("{compound:?}").starts_with("Block::Quote"));
1229
1230 let simple = parse_one("[verse]\nverse text");
1231 assert_eq!(simple.rendered_content(), Some("verse text"));
1232 assert_eq!(simple.content_model(), ContentModel::Simple);
1233 }
1234
1235 #[test]
1236 fn impl_debug() {
1237 let block = parse_one("____\nx\n____");
1238 let quote = as_quote(&block);
1239 let debug = format!("{quote:?}");
1240 assert!(debug.starts_with("QuoteBlock {"));
1241 assert!(debug.contains("type_: QuoteType::Quote"));
1242 }
1243
1244 #[test]
1245 fn impl_clone() {
1246 let block = parse_one("____\nclone me\n____");
1248 let quote = as_quote(&block).clone();
1249 assert_eq!(quote.type_(), QuoteType::Quote);
1250 }
1251
1252 #[test]
1253 fn title_renders_inside_quote_block() {
1254 let doc = Parser::default()
1255 .parse(".A title\n[quote,Captain Kirk]\nEverybody remember where we parked.");
1256 let block = doc.child_blocks().next().unwrap();
1257 let quote = as_quote(block);
1258 assert_eq!(quote.title(), Some("A title"));
1259 }
1260
1261 #[test]
1273 fn many_consecutive_delimiters_parse_in_roughly_linear_time() {
1274 use std::time::{Duration, Instant};
1275
1276 let example_run = "====\n".repeat(20_000);
1280
1281 let mut example_run_with_text = "====\n".repeat(10_000);
1282 example_run_with_text.push_str("text\n");
1283 example_run_with_text.push_str(&"====\n".repeat(10_000));
1284
1285 let open_run = "--\n".repeat(20_000);
1286
1287 let budget = Duration::from_secs(10);
1288
1289 for source in [&example_run, &example_run_with_text, &open_run] {
1290 let start = Instant::now();
1291 let _ = Parser::default().parse(source);
1292 let elapsed = start.elapsed();
1293
1294 assert!(
1295 elapsed < budget,
1296 "parsing {} delimiter lines took {elapsed:?}, exceeding the {budget:?} budget \
1297 (a sign the quadratic quoted-paragraph rescan has returned)",
1298 source.lines().count(),
1299 );
1300 }
1301 }
1302
1303 mod section_heading_suppressed {
1304 use crate::tests::prelude::*;
1310
1311 fn assert_literal_heading(input: &str) {
1312 let doc = Parser::default().parse(input);
1313
1314 assert_xpath(&doc, "//h2", 0);
1316
1317 assert!(rendered_paragraphs(&doc).contains(&"== not a heading".to_string()));
1319 }
1320
1321 #[test]
1322 fn quote_block() {
1323 assert_literal_heading("____\n== not a heading\n____\n");
1324 }
1325
1326 #[test]
1327 fn markdown_blockquote() {
1328 assert_literal_heading("> == not a heading\n");
1329 }
1330 }
1331}