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 {
260 source: delimiter.item,
261 warning: WarningType::UnterminatedDelimitedBlock,
262 origin: None,
263 },
264 );
265 }
266
267 MatchAndWarnings {
268 item: Some(MatchedItem {
269 item: Self {
270 type_,
271 content_model,
272 content,
273 blocks,
274 markdown_blocks: None,
275 attribution,
276 citetitle,
277 source,
278 title_source: metadata.title_source,
279 title: metadata.title.clone(),
280 anchor: metadata.anchor,
281 anchor_reftext: metadata.anchor_reftext,
282 attrlist: metadata.attrlist.clone(),
283 },
284 after,
285 }),
286 warnings,
287 }
288 }
289
290 fn parse_styled_paragraph(
292 metadata: &BlockMetadata<'src>,
293 parser: &mut Parser,
294 type_: QuoteType,
295 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
296 let inner = SimpleBlock::parse(metadata, parser)?;
301
302 let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
303
304 let source = metadata
305 .source
306 .trim_remainder(inner.after)
307 .trim_trailing_whitespace();
308
309 Some(MatchAndWarnings {
310 item: Some(MatchedItem {
311 item: Self {
312 type_,
313 content_model: ContentModel::Simple,
314 content: Some(inner.item.content().clone()),
315 blocks: vec![],
316 markdown_blocks: None,
317 attribution,
318 citetitle,
319 source,
320 title_source: metadata.title_source,
321 title: metadata.title.clone(),
322 anchor: metadata.anchor,
323 anchor_reftext: metadata.anchor_reftext,
324 attrlist: metadata.attrlist.clone(),
325 },
326 after: inner.after,
327 }),
328 warnings: vec![],
329 })
330 }
331
332 fn parse_quoted_paragraph(
337 metadata: &BlockMetadata<'src>,
338 parser: &mut Parser,
339 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
340 if !metadata.block_start.data().starts_with('"') {
350 return None;
351 }
352
353 let para = read_paragraph(metadata.block_start);
355 let data = para.data();
356
357 let (quoted, attribution_text) = split_at_attribution_line(data)?;
361
362 let inner = quoted
365 .trim_end()
366 .strip_prefix('"')
367 .and_then(|s| s.strip_suffix('"'))
368 .filter(|inner| !inner.is_empty())?;
369
370 let inner_span = para.slice(1..1 + inner.len());
374 let mut content = Content::from(inner_span);
375 SubstitutionGroup::Normal.apply(&mut content, parser, None);
376
377 let (attribution, citetitle) = split_attribution_line(attribution_text.trim(), parser);
379
380 let source = metadata
381 .source
382 .trim_remainder(read_paragraph_after(metadata.block_start))
383 .trim_trailing_whitespace();
384
385 Some(MatchAndWarnings {
386 item: Some(MatchedItem {
387 item: Self {
388 type_: QuoteType::Quote,
389 content_model: ContentModel::Simple,
390 content: Some(content),
391 blocks: vec![],
392 markdown_blocks: None,
393 attribution,
394 citetitle,
395 source,
396 title_source: metadata.title_source,
397 title: metadata.title.clone(),
398 anchor: metadata.anchor,
399 anchor_reftext: metadata.anchor_reftext,
400 attrlist: metadata.attrlist.clone(),
401 },
402 after: read_paragraph_after(metadata.block_start).discard_empty_lines(),
403 }),
404 warnings: vec![],
405 })
406 }
407
408 fn parse_markdown(
416 metadata: &BlockMetadata<'src>,
417 parser: &mut Parser,
418 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
419 let first_line = metadata.block_start.take_normalized_line().item;
420
421 if !is_markdown_marker_line(first_line.data()) {
425 return None;
426 }
427
428 if let Some(MatchedItem {
432 item: ListItemMarker::DefinedTerm { .. },
433 ..
434 }) = ListItemMarker::parse(metadata.block_start, parser)
435 {
436 return None;
437 }
438
439 let chunk = read_paragraph(metadata.block_start);
444 let after = read_paragraph_after(metadata.block_start);
445
446 let mut lines: Vec<String> = chunk
447 .data()
448 .split('\n')
449 .map(|line| {
450 if line == ">" {
451 String::new()
452 } else if let Some(rest) = line.strip_prefix("> ") {
453 rest.to_string()
454 } else {
455 line.to_string()
456 }
457 })
458 .collect();
459
460 let (attribution, citetitle) = take_trailing_attribution(&mut lines, parser);
463
464 let body = lines.join("\n");
465
466 let mut nested_warning_types: Vec<WarningType> = vec![];
475 let owned = OwnedQuoteBlocks::new(body, |source| {
476 parser.owned_subsource_depth += 1;
480
481 let previously_in_delimited_block = parser.in_delimited_block;
485 parser.in_delimited_block = true;
486
487 let mut maw = parse_blocks_until(Span::new(source), |_, _| false, parser);
488
489 parser.in_delimited_block = previously_in_delimited_block;
490 parser.owned_subsource_depth -= 1;
491 nested_warning_types.extend(maw.warnings.drain(..).map(|w| w.warning));
492 OwnedQuoteBlocksInner {
493 blocks: maw.item.item,
494 }
495 });
496
497 let source = metadata
498 .source
499 .trim_remainder(after)
500 .trim_trailing_whitespace();
501
502 let warnings = nested_warning_types
503 .into_iter()
504 .map(|warning| Warning {
505 source,
506 warning,
507 origin: None,
508 })
509 .collect();
510
511 Some(MatchAndWarnings {
512 item: Some(MatchedItem {
513 item: Self {
514 type_: QuoteType::Quote,
515 content_model: ContentModel::Compound,
516 content: None,
517 blocks: vec![],
518 markdown_blocks: Some(Arc::new(owned)),
519 attribution,
520 citetitle,
521 source,
522 title_source: metadata.title_source,
523 title: metadata.title.clone(),
524 anchor: metadata.anchor,
525 anchor_reftext: metadata.anchor_reftext,
526 attrlist: metadata.attrlist.clone(),
527 },
528 after: after.discard_empty_lines(),
529 }),
530 warnings,
531 })
532 }
533
534 pub fn type_(&self) -> QuoteType {
536 self.type_
537 }
538
539 pub fn attribution(&self) -> Option<&str> {
542 self.attribution.as_deref()
543 }
544
545 pub fn citetitle(&self) -> Option<&str> {
548 self.citetitle.as_deref()
549 }
550
551 pub fn content(&self) -> Option<&Content<'src>> {
554 self.content.as_ref()
555 }
556
557 pub fn blocks(&self) -> &[Block<'_>] {
563 match &self.markdown_blocks {
564 Some(owned) => &owned.borrow_dependent().blocks,
565 None => &self.blocks,
566 }
567 }
568
569 pub(crate) fn resolve_references(
577 &mut self,
578 resolver: &dyn ReferenceResolver,
579 renderer: &dyn InlineSubstitutionRenderer,
580 warnings: &mut ReferenceWarnings<'src>,
581 ) {
582 let source = self.source;
583
584 if let Some(owned) = self.markdown_blocks.as_mut()
588 && let Some(owned) = Arc::get_mut(owned)
589 {
590 owned.with_dependent_mut(|_, dependent| {
591 let mut owned_warnings = ReferenceWarnings::default();
595
596 for block in &mut dependent.blocks {
597 block.resolve_references(resolver, renderer, &mut owned_warnings);
598 }
599
600 owned_warnings.rehome_into(warnings, source);
601 });
602 }
603 }
604}
605
606fn is_markdown_marker_line(line: &str) -> bool {
609 line == ">" || line.starts_with("> ")
610}
611
612fn take_trailing_attribution(
619 lines: &mut Vec<String>,
620 parser: &Parser,
621) -> (Option<String>, Option<String>) {
622 while lines.last().is_some_and(|line| line.is_empty()) {
623 lines.pop();
624 }
625
626 if let Some(last) = lines.last()
627 && let Some(rest) = last.strip_prefix("--")
628 && (rest.starts_with(' ') || rest.starts_with('\t'))
629 {
630 let result = split_attribution_line(rest.trim(), parser);
631 lines.pop();
632 while lines.last().is_some_and(|line| line.is_empty()) {
633 lines.pop();
634 }
635 return result;
636 }
637
638 (None, None)
639}
640
641pub(crate) fn is_quote_verse_delimiter(line: &Span<'_>) -> bool {
644 let data = line.data();
645 data.len() >= 4 && data.starts_with("____") && data.chars().all(|c| c == '_')
646}
647
648fn render_verbatim<'src>(inside: Span<'src>, parser: &Parser) -> Content<'src> {
651 let trimmed = inside.discard_empty_lines().trim_trailing_whitespace();
652 let mut content = Content::from(trimmed);
653 SubstitutionGroup::Normal.apply(&mut content, parser, None);
654 content
655}
656
657fn render_inline(parser: &Parser, text: &str) -> String {
660 let span = Span::new(text);
661 let mut content = Content::from(span);
662 SubstitutionGroup::Normal.apply(&mut content, parser, None);
663 content.rendered_owned()
664}
665
666fn extract_attribution(attrlist: Option<&Attrlist<'_>>) -> (Option<String>, Option<String>) {
676 let Some(attrlist) = attrlist else {
677 return (None, None);
678 };
679
680 let attribution = attrlist
681 .named_or_positional_attribute("attribution", 2)
682 .map(|a| a.value())
683 .filter(|v| !v.is_empty())
684 .map(str::to_string);
685
686 let citetitle = attrlist
687 .named_or_positional_attribute("citetitle", 3)
688 .map(|a| a.value())
689 .filter(|v| !v.is_empty())
690 .map(str::to_string);
691
692 (attribution, citetitle)
693}
694
695fn split_attribution_line(text: &str, parser: &Parser) -> (Option<String>, Option<String>) {
698 match text.split_once(',') {
699 Some((attribution, citetitle)) => {
700 let attribution = attribution.trim();
701 let citetitle = citetitle.trim();
702 (
703 non_empty(attribution).map(|v| render_inline(parser, v)),
704 non_empty(citetitle).map(|v| render_inline(parser, v)),
705 )
706 }
707 None => (
708 non_empty(text.trim()).map(|v| render_inline(parser, v)),
709 None,
710 ),
711 }
712}
713
714fn non_empty(s: &str) -> Option<&str> {
715 if s.is_empty() { None } else { Some(s) }
716}
717
718fn split_at_attribution_line(data: &str) -> Option<(&str, &str)> {
729 let mut line_start = 0;
730 let mut attribution: Option<(usize, &str)> = None;
731
732 for line in data.split_inclusive('\n') {
733 let trimmed = line.strip_suffix('\n').unwrap_or(line);
734
735 if let Some(rest) = trimmed.strip_prefix("--")
736 && (rest.starts_with(' ') || rest.starts_with('\t'))
737 {
738 let attribution_text = rest.trim_start_matches([' ', '\t']);
739
740 if !attribution_text.is_empty() && line_start > 0 {
743 attribution = Some((line_start, attribution_text));
744 }
745 }
746
747 line_start += line.len();
748 }
749
750 attribution.map(|(start, text)| (data.split_at(start).0, text))
753}
754
755fn read_paragraph(source: Span<'_>) -> Span<'_> {
758 source.trim_remainder(read_paragraph_after(source))
759}
760
761fn read_paragraph_after(source: Span<'_>) -> Span<'_> {
764 let mut next = source;
765 while let Some(line) = next.take_non_empty_line() {
766 next = line.after;
767 }
768 next
769}
770
771impl<'src> IsBlock<'src> for QuoteBlock<'src> {
772 fn content_model(&self) -> ContentModel {
773 self.content_model
774 }
775
776 fn raw_context(&self) -> CowStr<'src> {
777 self.type_.name().into()
778 }
779
780 fn declared_style(&'src self) -> Option<&'src str> {
781 self.attrlist
782 .as_ref()
783 .and_then(|attrlist| attrlist.block_style())
784 }
785
786 fn rendered_content(&'src self) -> Option<&'src str> {
787 self.content.as_ref().map(|content| content.rendered())
788 }
789
790 fn child_blocks_mut(&mut self) -> &mut [Block<'src>] {
800 &mut self.blocks
801 }
802
803 fn content_mut(&mut self) -> Option<&mut Content<'src>> {
804 self.content.as_mut()
805 }
806
807 fn title_source(&'src self) -> Option<Span<'src>> {
808 self.title_source
809 }
810
811 fn title(&self) -> Option<&str> {
812 self.title.as_ref().map(Content::rendered_str)
813 }
814
815 fn anchor(&'src self) -> Option<Span<'src>> {
816 self.anchor
817 }
818
819 fn anchor_reftext(&'src self) -> Option<Span<'src>> {
820 self.anchor_reftext
821 }
822
823 fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
824 self.attrlist.as_ref()
825 }
826}
827
828impl<'src> HasSpan<'src> for QuoteBlock<'src> {
829 fn span(&self) -> Span<'src> {
830 self.source
831 }
832}
833
834impl std::fmt::Debug for QuoteBlock<'_> {
835 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
836 f.debug_struct("QuoteBlock")
837 .field("type_", &self.type_)
838 .field("content_model", &self.content_model)
839 .field("content", &self.content)
840 .field("blocks", &DebugSliceReference(&self.blocks))
841 .field("attribution", &self.attribution)
842 .field("citetitle", &self.citetitle)
843 .field("source", &self.source)
844 .field("title_source", &self.title_source)
845 .field("title", &self.title)
846 .field("anchor", &self.anchor)
847 .field("anchor_reftext", &self.anchor_reftext)
848 .field("attrlist", &self.attrlist)
849 .finish()
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 #![allow(clippy::unwrap_used)]
856 #![allow(clippy::panic)]
857
858 use std::ops::Deref;
859
860 use crate::{
861 blocks::{Block, ContentModel, IsBlock, QuoteType},
862 tests::prelude::*,
863 };
864
865 fn parse_one(input: &'static str) -> Block<'static> {
866 let mut parser = Parser::default();
867 Block::parse(crate::Span::new(input), &mut parser)
868 .unwrap_if_no_warnings()
869 .unwrap()
870 .item
871 }
872
873 fn as_quote<'a>(block: &'a Block<'a>) -> &'a crate::blocks::QuoteBlock<'a> {
874 match block {
875 Block::Quote(quote) => quote,
876
877 other => panic!("expected a quote block, got {other:?}"),
881 }
882 }
883
884 mod quote_type {
885 use crate::blocks::QuoteType;
886
887 #[test]
888 fn name() {
889 assert_eq!(QuoteType::Quote.name(), "quote");
890 assert_eq!(QuoteType::Verse.name(), "verse");
891 }
892
893 #[test]
894 fn impl_debug() {
895 assert_eq!(format!("{:?}", QuoteType::Quote), "QuoteType::Quote");
896 assert_eq!(format!("{:?}", QuoteType::Verse), "QuoteType::Verse");
897 }
898
899 #[test]
900 fn impl_clone() {
901 let v1 = QuoteType::Quote;
903 let v2 = v1;
904 assert_eq!(v1, v2);
905 }
906 }
907
908 #[test]
909 fn delimited_quote_is_compound() {
910 let block = parse_one("____\nA quote.\n\nWith two paragraphs.\n____");
911 let quote = as_quote(&block);
912
913 assert_eq!(quote.type_(), QuoteType::Quote);
914 assert_eq!(quote.content_model(), ContentModel::Compound);
915 assert_eq!(quote.raw_context().deref(), "quote");
916 assert!(quote.content().is_none());
917 assert!(quote.attribution().is_none());
918 assert!(quote.citetitle().is_none());
919 assert_eq!(quote.blocks().len(), 2);
920 assert_eq!(quote.child_blocks().count(), 2);
921 }
922
923 #[test]
924 fn delimited_quote_with_attribution_and_citation() {
925 let block =
926 parse_one("[quote,Abraham Lincoln,Gettysburg Address]\n____\nFour score.\n____");
927 let quote = as_quote(&block);
928
929 assert_eq!(quote.attribution(), Some("Abraham Lincoln"));
930 assert_eq!(quote.citetitle(), Some("Gettysburg Address"));
931 assert_eq!(quote.declared_style(), Some("quote"));
932 }
933
934 #[test]
935 fn styled_paragraph_quote_is_simple() {
936 let block = parse_one("[quote,Albert Einstein]\nA person who never made a mistake.");
937 let quote = as_quote(&block);
938
939 assert_eq!(quote.type_(), QuoteType::Quote);
940 assert_eq!(quote.content_model(), ContentModel::Simple);
941 assert_eq!(
942 quote.content().unwrap().rendered(),
943 "A person who never made a mistake."
944 );
945 assert_eq!(
946 quote.rendered_content(),
947 Some("A person who never made a mistake.")
948 );
949 assert_eq!(quote.attribution(), Some("Albert Einstein"));
950 assert!(quote.citetitle().is_none());
951 }
952
953 #[test]
954 fn verse_paragraph_is_simple() {
955 let block = parse_one("[verse,Carl Sandburg,Fog]\nThe fog comes\non little cat feet.");
956 let quote = as_quote(&block);
957
958 assert_eq!(quote.type_(), QuoteType::Verse);
959 assert_eq!(quote.content_model(), ContentModel::Simple);
960 assert_eq!(quote.raw_context().deref(), "verse");
961 assert_eq!(
962 quote.content().unwrap().rendered(),
963 "The fog comes\non little cat feet."
964 );
965 assert_eq!(quote.attribution(), Some("Carl Sandburg"));
966 assert_eq!(quote.citetitle(), Some("Fog"));
967 }
968
969 #[test]
970 fn verse_delimited_preserves_line_breaks() {
971 let block = parse_one("[verse]\n____\nA verse\ndelimited block\n____");
972 let quote = as_quote(&block);
973
974 assert_eq!(quote.type_(), QuoteType::Verse);
975 assert_eq!(quote.content_model(), ContentModel::Simple);
976 assert_eq!(
977 quote.content().unwrap().rendered(),
978 "A verse\ndelimited block"
979 );
980 assert!(quote.child_blocks().next().is_none());
981 }
982
983 #[test]
984 fn quote_or_verse_style_over_other_container_is_not_a_quote() {
985 assert_eq!(
988 parse_one("[quote]\n====\nx\n====").raw_context().deref(),
989 "example"
990 );
991 assert_eq!(
992 parse_one("[verse]\n****\nx\n****").raw_context().deref(),
993 "sidebar"
994 );
995 assert_eq!(
996 parse_one("[quote]\n----\nx\n----").raw_context().deref(),
997 "listing"
998 );
999 }
1000
1001 #[test]
1002 fn quoted_paragraph_with_attribution_and_citation() {
1003 let block = parse_one("\"A little rebellion is good.\"\n-- Thomas Jefferson, Volume 11");
1004 let quote = as_quote(&block);
1005
1006 assert_eq!(quote.type_(), QuoteType::Quote);
1007 assert_eq!(quote.content_model(), ContentModel::Simple);
1008 assert_eq!(
1009 quote.content().unwrap().rendered(),
1010 "A little rebellion is good."
1011 );
1012 assert_eq!(quote.attribution(), Some("Thomas Jefferson"));
1013 assert_eq!(quote.citetitle(), Some("Volume 11"));
1014 }
1015
1016 #[test]
1017 fn quoted_paragraph_without_citation() {
1018 let block = parse_one("\"A quote.\"\n-- Anonymous");
1019 let quote = as_quote(&block);
1020
1021 assert_eq!(quote.attribution(), Some("Anonymous"));
1022 assert!(quote.citetitle().is_none());
1023 }
1024
1025 #[test]
1026 fn quoted_paragraph_requires_attribution_line() {
1027 let block = parse_one("\"Just a quoted sentence.\"");
1030 assert_eq!(block.raw_context().deref(), "paragraph");
1031 }
1032
1033 #[test]
1034 fn quoted_paragraph_requires_opening_quote() {
1035 let block = parse_one("Not quoted.\n-- Someone");
1038 assert_eq!(block.raw_context().deref(), "paragraph");
1039 }
1040
1041 #[test]
1042 fn empty_quoted_paragraph_is_not_a_quote() {
1043 let block = parse_one("\"\"\n-- Someone");
1045 assert_eq!(block.raw_context().deref(), "paragraph");
1046 }
1047
1048 #[test]
1049 fn unclosed_quoted_paragraph_is_not_a_quote() {
1050 let block = parse_one("\"no closing quote\n-- Someone");
1052 assert_eq!(block.raw_context().deref(), "paragraph");
1053 }
1054
1055 #[test]
1056 fn dash_line_without_attribution_text_is_not_a_quote() {
1057 let block = parse_one("\"A quote.\"\n-- ");
1060 assert_eq!(block.raw_context().deref(), "paragraph");
1061 }
1062
1063 #[test]
1064 fn quoted_paragraph_tab_separated_attribution() {
1065 let block = parse_one("\"A quote.\"\n--\tSomeone");
1067 let quote = as_quote(&block);
1068 assert_eq!(quote.attribution(), Some("Someone"));
1069 }
1070
1071 #[test]
1072 fn quoted_paragraph_uses_last_attribution_line() {
1073 let block =
1076 parse_one("\"line one\n-- not really an attribution\nline two\"\n-- Real Attribution");
1077 let quote = as_quote(&block);
1078 assert_eq!(quote.attribution(), Some("Real Attribution"));
1079 let rendered = quote.content().unwrap().rendered();
1080 assert!(
1081 rendered.contains("line one") && rendered.contains("line two"),
1082 "content was: {rendered}"
1083 );
1084 }
1085
1086 #[test]
1087 fn attribution_with_empty_name_keeps_citation() {
1088 let block = parse_one("\"A quote.\"\n-- , Just a citation");
1091 let quote = as_quote(&block);
1092 assert!(quote.attribution().is_none());
1093 assert_eq!(quote.citetitle(), Some("Just a citation"));
1094 }
1095
1096 #[test]
1097 fn styled_paragraph_with_no_content_is_not_a_quote() {
1098 let mut parser = Parser::default();
1101 let maw = Block::parse(crate::Span::new("[quote]\n"), &mut parser);
1102 let block = maw.item.unwrap().item;
1103 assert_eq!(block.raw_context().deref(), "paragraph");
1104 }
1105
1106 #[test]
1107 fn markdown_blockquote_tab_attribution_after_blank() {
1108 let block = parse_one("> A quote.\n>\n> --\tSomeone");
1111 let quote = as_quote(&block);
1112 assert_eq!(quote.attribution(), Some("Someone"));
1113 assert_eq!(quote.blocks().len(), 1);
1114 }
1115
1116 #[test]
1117 fn markdown_blockquote_propagates_nested_warning() {
1118 let mut parser = Parser::default();
1123 let maw = Block::parse(crate::Span::new("> ____\n> unclosed"), &mut parser);
1124
1125 let block = maw.item.unwrap().item;
1126 assert_eq!(block.raw_context().deref(), "quote");
1127 assert_eq!(
1128 maw.warnings.first().unwrap().warning,
1129 WarningType::UnterminatedDelimitedBlock
1130 );
1131
1132 assert_eq!(maw.warnings.first().unwrap().source, block.span());
1134 }
1135
1136 #[test]
1137 fn markdown_blockquote_double_dash_without_space_is_content() {
1138 let block = parse_one("> A quote.\n> --nospace");
1141 let quote = as_quote(&block);
1142 assert!(quote.attribution().is_none());
1143 }
1144
1145 #[test]
1146 fn markdown_blockquote_basic() {
1147 let block = parse_one("> A markdown quote.");
1148 let quote = as_quote(&block);
1149
1150 assert_eq!(quote.type_(), QuoteType::Quote);
1151 assert_eq!(quote.content_model(), ContentModel::Compound);
1152 assert_eq!(quote.blocks().len(), 1);
1153
1154 assert_eq!(quote.child_blocks().count(), 1);
1157 }
1158
1159 #[test]
1160 fn markdown_blockquote_with_attribution() {
1161 let block = parse_one("> A quote.\n> -- Someone");
1162 let quote = as_quote(&block);
1163
1164 assert_eq!(quote.attribution(), Some("Someone"));
1165 assert_eq!(quote.blocks().len(), 1);
1166 }
1167
1168 #[test]
1169 fn markdown_blockquote_lazy_continuation() {
1170 let block = parse_one("> line one\nline two");
1172 let quote = as_quote(&block);
1173
1174 assert_eq!(quote.blocks().len(), 1);
1175 let inner = quote.blocks().first().unwrap();
1176 assert_eq!(inner.rendered_content(), Some("line one\nline two"));
1177 }
1178
1179 #[test]
1180 fn markdown_marker_requires_space() {
1181 let block = parse_one(">foo bar");
1183 assert_eq!(block.raw_context().deref(), "paragraph");
1184 }
1185
1186 #[test]
1187 fn markdown_yields_to_description_list() {
1188 let block = parse_one("> term:: definition");
1191 assert_eq!(block.raw_context().deref(), "list");
1192 }
1193
1194 #[test]
1195 fn unterminated_delimited_quote_warns() {
1196 let mut parser = Parser::default();
1197 let maw = Block::parse(crate::Span::new("____\nunclosed"), &mut parser);
1198
1199 let block = maw.item.unwrap().item;
1200 assert_eq!(block.raw_context().deref(), "quote");
1201 assert_eq!(maw.warnings.len(), 1);
1202 assert_eq!(
1203 maw.warnings.first().unwrap().warning,
1204 WarningType::UnterminatedDelimitedBlock
1205 );
1206 }
1207
1208 #[test]
1209 fn citation_receives_inline_substitutions() {
1210 let block = parse_one(
1212 "[quote,Lewis Carroll,'See https://example.com/lc[the bio]']\n____\nAny road.\n____",
1213 );
1214 let quote = as_quote(&block);
1215 let citetitle = quote.citetitle().unwrap();
1216 assert!(
1217 citetitle.contains("<a href=\"https://example.com/lc\">the bio</a>"),
1218 "citation was: {citetitle}"
1219 );
1220 }
1221
1222 #[test]
1223 fn block_enum_delegates_to_quote() {
1224 let compound = parse_one("____\nx\n____");
1226 assert_eq!(compound.content_model(), ContentModel::Compound);
1227 assert_eq!(compound.raw_context().deref(), "quote");
1228 assert!(compound.title_source().is_none());
1229 assert!(compound.anchor().is_none());
1230 assert!(compound.anchor_reftext().is_none());
1231 assert!(compound.attrlist().is_none());
1232 assert_eq!(compound.substitution_group(), SubstitutionGroup::Normal);
1233 assert_eq!(compound.child_blocks().count(), 1);
1234 assert!(compound.title().is_none());
1235 assert!(compound.declared_style().is_none());
1236 assert!(format!("{compound:?}").starts_with("Block::Quote"));
1237
1238 let simple = parse_one("[verse]\nverse text");
1239 assert_eq!(simple.rendered_content(), Some("verse text"));
1240 assert_eq!(simple.content_model(), ContentModel::Simple);
1241 }
1242
1243 #[test]
1244 fn impl_debug() {
1245 let block = parse_one("____\nx\n____");
1246 let quote = as_quote(&block);
1247 let debug = format!("{quote:?}");
1248 assert!(debug.starts_with("QuoteBlock {"));
1249 assert!(debug.contains("type_: QuoteType::Quote"));
1250 }
1251
1252 #[test]
1253 fn impl_clone() {
1254 let block = parse_one("____\nclone me\n____");
1256 let quote = as_quote(&block).clone();
1257 assert_eq!(quote.type_(), QuoteType::Quote);
1258 }
1259
1260 #[test]
1261 fn title_renders_inside_quote_block() {
1262 let doc = Parser::default()
1263 .parse(".A title\n[quote,Captain Kirk]\nEverybody remember where we parked.");
1264 let block = doc.child_blocks().next().unwrap();
1265 let quote = as_quote(block);
1266 assert_eq!(quote.title(), Some("A title"));
1267 }
1268
1269 #[test]
1281 fn many_consecutive_delimiters_parse_in_roughly_linear_time() {
1282 use std::time::{Duration, Instant};
1283
1284 let example_run = "====\n".repeat(20_000);
1288
1289 let mut example_run_with_text = "====\n".repeat(10_000);
1290 example_run_with_text.push_str("text\n");
1291 example_run_with_text.push_str(&"====\n".repeat(10_000));
1292
1293 let open_run = "--\n".repeat(20_000);
1294
1295 let budget = Duration::from_secs(10);
1296
1297 for source in [&example_run, &example_run_with_text, &open_run] {
1298 let start = Instant::now();
1299 let _ = Parser::default().parse(source);
1300 let elapsed = start.elapsed();
1301
1302 assert!(
1303 elapsed < budget,
1304 "parsing {} delimiter lines took {elapsed:?}, exceeding the {budget:?} budget \
1305 (a sign the quadratic quoted-paragraph rescan has returned)",
1306 source.lines().count(),
1307 );
1308 }
1309 }
1310
1311 mod section_heading_suppressed {
1312 use crate::tests::prelude::*;
1318
1319 fn assert_literal_heading(input: &str) {
1320 let doc = Parser::default().parse(input);
1321
1322 assert_xpath(&doc, "//h2", 0);
1324
1325 assert!(rendered_paragraphs(&doc).contains(&"== not a heading".to_string()));
1327 }
1328
1329 #[test]
1330 fn quote_block() {
1331 assert_literal_heading("____\n== not a heading\n____\n");
1332 }
1333
1334 #[test]
1335 fn markdown_blockquote() {
1336 assert_literal_heading("> == not a heading\n");
1337 }
1338 }
1339}