Skip to main content

asciidoc_parser/blocks/
quote.rs

1use std::{slice::Iter, sync::Arc};
2
3use self_cell::self_cell;
4
5use crate::{
6    HasSpan, Parser, Span,
7    attributes::Attrlist,
8    blocks::{
9        Block, CompoundDelimitedBlock, ContentModel, IsBlock, ListItemMarker, RawDelimitedBlock,
10        SimpleBlock, TableBlock, metadata::BlockMetadata, parse_utils::parse_blocks_until,
11    },
12    content::{Content, SubstitutionGroup},
13    internal::debug::DebugSliceReference,
14    parser::{InlineSubstitutionRenderer, ReferenceResolver, ReferenceWarnings},
15    span::MatchedItem,
16    strings::CowStr,
17    warnings::{MatchAndWarnings, Warning, WarningType},
18};
19
20self_cell! {
21    /// A Markdown-style blockquote's nested blocks, which borrow the owned
22    /// (`>`-stripped) source the block carries.
23    pub struct OwnedQuoteBlocks {
24        owner: String,
25
26        #[covariant]
27        dependent: OwnedQuoteBlocksInner,
28    }
29
30    impl {Debug, Eq, PartialEq}
31}
32
33/// The parsed blocks of an [`OwnedQuoteBlocks`], borrowing its owned source.
34#[derive(Debug, Eq, PartialEq)]
35struct OwnedQuoteBlocksInner<'src> {
36    blocks: Vec<Block<'src>>,
37}
38
39/// Distinguishes the two block types that share the blockquote syntax.
40///
41/// Prose excerpts and quotes use the [`Quote`](Self::Quote) type, which does
42/// not preserve line breaks. Verses (e.g., poems or song lyrics) use the
43/// [`Verse`](Self::Verse) type, which preserves line breaks in the output.
44#[derive(Clone, Copy, Eq, PartialEq)]
45pub enum QuoteType {
46    /// A prose excerpt or quote. Line breaks are not preserved.
47    Quote,
48
49    /// A verse (e.g., a poem or song lyric). Line breaks are preserved.
50    Verse,
51}
52
53impl QuoteType {
54    /// Returns the lowercase name for this type (e.g., `quote`).
55    ///
56    /// This is the block's context and the basis for its CSS class
57    /// (`quoteblock` or `verseblock`).
58    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/// A blockquote: a quote, prose excerpt, or verse, optionally attributed to a
76/// person and a source citation.
77///
78/// A blockquote can be written in several ways, all of which produce this
79/// block:
80///
81/// * A **delimited block** bounded by lines of four underscores (`____`),
82///   optionally with a `[quote,…]` or `[verse,…]` attribute list. A `quote`
83///   block has the [`Compound`](ContentModel::Compound) content model (it
84///   contains other blocks); a `verse` block has the
85///   [`Simple`](ContentModel::Simple) content model and preserves its line
86///   breaks.
87/// * A **styled paragraph** introduced by a `[quote,…]` or `[verse,…]`
88///   attribute list. This produces the [`Simple`](ContentModel::Simple) content
89///   model.
90/// * A **quoted paragraph**: a paragraph wrapped in double quotes and followed
91///   by an attribution line introduced by two hyphens (`-- …`). This produces a
92///   `quote` block with the [`Simple`](ContentModel::Simple) content model.
93#[derive(Clone, Eq, PartialEq)]
94pub struct QuoteBlock<'src> {
95    type_: QuoteType,
96    content_model: ContentModel,
97    content: Option<Content<'src>>,
98    blocks: Vec<Block<'src>>,
99    markdown_blocks: Option<Arc<OwnedQuoteBlocks>>,
100    attribution: Option<String>,
101    citetitle: Option<String>,
102    source: Span<'src>,
103    title_source: Option<Span<'src>>,
104    title: Option<Content<'src>>,
105    anchor: Option<Span<'src>>,
106    anchor_reftext: Option<Span<'src>>,
107    attrlist: Option<Attrlist<'src>>,
108}
109
110impl<'src> QuoteBlock<'src> {
111    /// Returns the block's title as a mutable [`Content`], if the block has
112    /// one.
113    ///
114    /// This narrow seam exists for the document-order title resolution pass
115    /// (see `document::title_refs`), which installs the re-rendered title
116    /// after resolving any cross-references embedded in it. All other access
117    /// goes through the read-only [`IsBlock::title`] accessor.
118    pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
119        self.title.as_mut()
120    }
121
122    /// Parse a blockquote, if the given metadata and content describe one.
123    ///
124    /// Returns `None` (without consuming the block) when the content is not a
125    /// blockquote, so that the caller can fall through to other block parsers.
126    pub(crate) fn parse(
127        metadata: &BlockMetadata<'src>,
128        parser: &mut Parser,
129    ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
130        let style = metadata.attrlist.as_ref().and_then(|a| a.block_style());
131        let styled_type = match style {
132            Some("quote") => Some(QuoteType::Quote),
133            Some("verse") => Some(QuoteType::Verse),
134            _ => None,
135        };
136
137        let first_line = metadata.block_start.take_normalized_line().item;
138        let is_quote_delimiter = is_quote_verse_delimiter(&first_line);
139
140        // A `[quote]` or `[verse]` style masquerades over a quote-delimited
141        // block, an open block, or a paragraph.
142        if let Some(type_) = styled_type {
143            if is_quote_delimiter {
144                return Some(Self::parse_delimited(metadata, parser, type_));
145            }
146
147            // A `[quote]`/`[verse]` style also masquerades over an open block
148            // (`--`): the open delimiter adopts the quote/verse context. This is
149            // unique to the open block — every other structural container (below)
150            // keeps its own context and ignores the style.
151            if first_line.data() == "--" {
152                return Some(Self::parse_delimited(metadata, parser, type_));
153            }
154
155            // The style is set on some other structural container (example,
156            // sidebar, listing, literal, passthrough, table). That container
157            // keeps its own context and the style is ignored, so this is not a
158            // blockquote.
159            if RawDelimitedBlock::is_valid_delimiter(&first_line)
160                || CompoundDelimitedBlock::is_valid_delimiter(&first_line)
161                || TableBlock::is_table_delimiter(&first_line)
162            {
163                return None;
164            }
165
166            return Self::parse_styled_paragraph(metadata, parser, type_);
167        }
168
169        // A bare quote-delimited block (no `[quote]`/`[verse]` style) is a quote
170        // block. Any other style on it (e.g. an admonition label) is ignored.
171        if is_quote_delimiter {
172            return Some(Self::parse_delimited(metadata, parser, QuoteType::Quote));
173        }
174
175        // A Markdown-style blockquote, introduced by a `>` marker.
176        if first_line.data().starts_with('>') {
177            return Self::parse_markdown(metadata, parser);
178        }
179
180        // A quoted paragraph: text wrapped in double quotes followed by a `-- `
181        // attribution line.
182        Self::parse_quoted_paragraph(metadata, parser)
183    }
184
185    /// Parse a quote- or verse-delimited block (bounded by `____` lines).
186    fn parse_delimited(
187        metadata: &BlockMetadata<'src>,
188        parser: &mut Parser,
189        type_: QuoteType,
190    ) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
191        let delimiter = metadata.block_start.take_normalized_line();
192
193        let mut next = delimiter.after;
194        let (closing_delimiter, after) = loop {
195            if next.is_empty() {
196                break (next, next);
197            }
198
199            let line = next.take_normalized_line();
200            if line.item.data() == delimiter.item.data() {
201                break (line.item, line.after);
202            }
203            next = line.after;
204        };
205
206        let inside_delimiters = delimiter.after.trim_remainder(closing_delimiter);
207
208        let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
209
210        let (content_model, content, blocks, mut warnings) = match type_ {
211            // A quote block contains other blocks.
212            QuoteType::Quote => {
213                let maw_blocks = parse_blocks_until(inside_delimiters, |_, _| false, parser);
214                (
215                    ContentModel::Compound,
216                    None,
217                    maw_blocks.item.item,
218                    maw_blocks.warnings,
219                )
220            }
221
222            // A verse block preserves its content verbatim (line breaks
223            // included), with normal substitutions applied.
224            QuoteType::Verse => {
225                let content = render_verbatim(inside_delimiters, parser);
226                (ContentModel::Simple, Some(content), vec![], vec![])
227            }
228        };
229
230        let source = metadata
231            .source
232            .trim_remainder(closing_delimiter.discard_all())
233            .trim_trailing_whitespace();
234
235        if closing_delimiter.is_empty() {
236            warnings.insert(
237                0,
238                Warning {
239                    source: delimiter.item,
240                    warning: WarningType::UnterminatedDelimitedBlock,
241                    origin: None,
242                },
243            );
244        }
245
246        MatchAndWarnings {
247            item: Some(MatchedItem {
248                item: Self {
249                    type_,
250                    content_model,
251                    content,
252                    blocks,
253                    markdown_blocks: None,
254                    attribution,
255                    citetitle,
256                    source,
257                    title_source: metadata.title_source,
258                    title: metadata.title.clone(),
259                    anchor: metadata.anchor,
260                    anchor_reftext: metadata.anchor_reftext,
261                    attrlist: metadata.attrlist.clone(),
262                },
263                after,
264            }),
265            warnings,
266        }
267    }
268
269    /// Parse a `[quote,…]` or `[verse,…]` styled paragraph.
270    fn parse_styled_paragraph(
271        metadata: &BlockMetadata<'src>,
272        parser: &mut Parser,
273        type_: QuoteType,
274    ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
275        // The paragraph text is read as a simple block. The `quote`/`verse`
276        // block style is not one of the verbatim styles, so the content is
277        // parsed as a normal paragraph (normal substitutions, line breaks
278        // preserved in the rendered text).
279        let inner = SimpleBlock::parse(metadata, parser)?;
280
281        let (attribution, citetitle) = extract_attribution(metadata.attrlist.as_ref());
282
283        let source = metadata
284            .source
285            .trim_remainder(inner.after)
286            .trim_trailing_whitespace();
287
288        Some(MatchAndWarnings {
289            item: Some(MatchedItem {
290                item: Self {
291                    type_,
292                    content_model: ContentModel::Simple,
293                    content: Some(inner.item.content().clone()),
294                    blocks: vec![],
295                    markdown_blocks: None,
296                    attribution,
297                    citetitle,
298                    source,
299                    title_source: metadata.title_source,
300                    title: metadata.title.clone(),
301                    anchor: metadata.anchor,
302                    anchor_reftext: metadata.anchor_reftext,
303                    attrlist: metadata.attrlist.clone(),
304                },
305                after: inner.after,
306            }),
307            warnings: vec![],
308        })
309    }
310
311    /// Parse a quoted paragraph: text wrapped in double quotes followed by a
312    /// `-- ` attribution line.
313    ///
314    /// Returns `None` if the content does not match this shape.
315    fn parse_quoted_paragraph(
316        metadata: &BlockMetadata<'src>,
317        parser: &mut Parser,
318    ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
319        // The paragraph extends to the first blank line.
320        let para = read_paragraph(metadata.block_start);
321        let data = para.data();
322
323        if !data.starts_with('"') {
324            return None;
325        }
326
327        // Locate the attribution line: the first line that begins with `--`
328        // followed by whitespace and at least one more character. Splits the
329        // paragraph into the quoted text and the attribution text.
330        let (quoted, attribution_text) = split_at_attribution_line(data)?;
331
332        // The quoted text, with surrounding whitespace removed, must be wrapped
333        // in double quotes.
334        let inner = quoted
335            .trim_end()
336            .strip_prefix('"')
337            .and_then(|s| s.strip_suffix('"'))
338            .filter(|inner| !inner.is_empty())?;
339
340        // The quoted text, with the surrounding quotes removed, becomes the
341        // blockquote content. Build a span that points just past the opening
342        // quote so source positions stay meaningful.
343        let inner_span = para.slice(1..1 + inner.len());
344        let mut content = Content::from(inner_span);
345        SubstitutionGroup::Normal.apply(&mut content, parser, None);
346
347        // The attribution line provides the attribution and (optional) citation.
348        let (attribution, citetitle) = split_attribution_line(attribution_text.trim(), parser);
349
350        let source = metadata
351            .source
352            .trim_remainder(read_paragraph_after(metadata.block_start))
353            .trim_trailing_whitespace();
354
355        Some(MatchAndWarnings {
356            item: Some(MatchedItem {
357                item: Self {
358                    type_: QuoteType::Quote,
359                    content_model: ContentModel::Simple,
360                    content: Some(content),
361                    blocks: vec![],
362                    markdown_blocks: None,
363                    attribution,
364                    citetitle,
365                    source,
366                    title_source: metadata.title_source,
367                    title: metadata.title.clone(),
368                    anchor: metadata.anchor,
369                    anchor_reftext: metadata.anchor_reftext,
370                    attrlist: metadata.attrlist.clone(),
371                },
372                after: read_paragraph_after(metadata.block_start).discard_empty_lines(),
373            }),
374            warnings: vec![],
375        })
376    }
377
378    /// Parse a Markdown-style blockquote: a run of lines introduced by a `>`
379    /// marker.
380    ///
381    /// Returns `None` when the content is not a Markdown-style blockquote,
382    /// including the case where it is a description list (which takes
383    /// precedence, since the `>` marker is a valid description-list term
384    /// prefix).
385    fn parse_markdown(
386        metadata: &BlockMetadata<'src>,
387        parser: &mut Parser,
388    ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
389        let first_line = metadata.block_start.take_normalized_line().item;
390
391        // The first line must begin the Markdown blockquote: a `>` followed by a
392        // space, or a bare `>`. A `>` immediately followed by other text (e.g.
393        // `>foo`) is not a blockquote.
394        if !is_markdown_marker_line(first_line.data()) {
395            return None;
396        }
397
398        // A description list takes precedence over a Markdown-style blockquote:
399        // the `>` marker is a valid prefix for a description-list term, so a
400        // line like `> term:: def` is a description list, not a blockquote.
401        if let Some(MatchedItem {
402            item: ListItemMarker::DefinedTerm { .. },
403            ..
404        }) = ListItemMarker::parse(metadata.block_start, parser)
405        {
406            return None;
407        }
408
409        // The blockquote spans the paragraph: every line up to the first blank
410        // line. Within it, each line's `>` marker is stripped (a bare `>`
411        // becomes a blank line); a line with no marker is a lazy continuation
412        // and is kept as-is.
413        let chunk = read_paragraph(metadata.block_start);
414        let after = read_paragraph_after(metadata.block_start);
415
416        let mut lines: Vec<String> = chunk
417            .data()
418            .split('\n')
419            .map(|line| {
420                if line == ">" {
421                    String::new()
422                } else if let Some(rest) = line.strip_prefix("> ") {
423                    rest.to_string()
424                } else {
425                    line.to_string()
426                }
427            })
428            .collect();
429
430        // A trailing attribution line (introduced by `--`) is pulled out of the
431        // content and used as the attribution and citation.
432        let (attribution, citetitle) = take_trailing_attribution(&mut lines, parser);
433
434        let body = lines.join("\n");
435
436        // The stripped body owns its source; its blocks borrow from it. Warnings
437        // from the owned parse reference spans in that owned source, so they
438        // cannot be returned to the caller as `Warning<'src>`. Their
439        // [`WarningType`]s (which carry no borrowed data) are collected instead
440        // and re-anchored at the blockquote's own source span below: the
441        // `>`-stripped body is not contiguous in the document source, so a
442        // precise location is not available anyway, but the diagnostic itself
443        // must not be lost.
444        let mut nested_warning_types: Vec<WarningType> = vec![];
445        let owned = OwnedQuoteBlocks::new(body, |source| {
446            // The blocks parse from `source`, an owned copy that does not map to
447            // the document. Mark that so a footnote defined inside records no
448            // (misleading) document location; see `Parser::owned_subsource_depth`.
449            parser.owned_subsource_depth += 1;
450            let mut maw = parse_blocks_until(Span::new(source), |_, _| false, parser);
451            parser.owned_subsource_depth -= 1;
452            nested_warning_types.extend(maw.warnings.drain(..).map(|w| w.warning));
453            OwnedQuoteBlocksInner {
454                blocks: maw.item.item,
455            }
456        });
457
458        let source = metadata
459            .source
460            .trim_remainder(after)
461            .trim_trailing_whitespace();
462
463        let warnings = nested_warning_types
464            .into_iter()
465            .map(|warning| Warning {
466                source,
467                warning,
468                origin: None,
469            })
470            .collect();
471
472        Some(MatchAndWarnings {
473            item: Some(MatchedItem {
474                item: Self {
475                    type_: QuoteType::Quote,
476                    content_model: ContentModel::Compound,
477                    content: None,
478                    blocks: vec![],
479                    markdown_blocks: Some(Arc::new(owned)),
480                    attribution,
481                    citetitle,
482                    source,
483                    title_source: metadata.title_source,
484                    title: metadata.title.clone(),
485                    anchor: metadata.anchor,
486                    anchor_reftext: metadata.anchor_reftext,
487                    attrlist: metadata.attrlist.clone(),
488                },
489                after: after.discard_empty_lines(),
490            }),
491            warnings,
492        })
493    }
494
495    /// Returns the blockquote type (quote or verse).
496    pub fn type_(&self) -> QuoteType {
497        self.type_
498    }
499
500    /// Returns the rendered attribution (the person the content is attributed
501    /// to), if any.
502    pub fn attribution(&self) -> Option<&str> {
503        self.attribution.as_deref()
504    }
505
506    /// Returns the rendered citation title (the work the content is drawn
507    /// from), if any.
508    pub fn citetitle(&self) -> Option<&str> {
509        self.citetitle.as_deref()
510    }
511
512    /// Returns the simple content of this blockquote, if it has the
513    /// [`Simple`](ContentModel::Simple) content model.
514    pub fn content(&self) -> Option<&Content<'src>> {
515        self.content.as_ref()
516    }
517
518    /// Returns the nested blocks of a compound blockquote.
519    ///
520    /// Unlike [`nested_blocks()`](IsBlock::nested_blocks), this also returns
521    /// the blocks of a Markdown-style blockquote, which borrow the block's
522    /// own owned source rather than the document source and so are not
523    /// exposed through the `'src`-bound trait method.
524    pub fn blocks(&self) -> &[Block<'_>] {
525        match &self.markdown_blocks {
526            Some(owned) => &owned.borrow_dependent().blocks,
527            None => &self.blocks,
528        }
529    }
530
531    /// Resolves any deferred cross-references in this blockquote's nested
532    /// blocks.
533    ///
534    /// The blocks of a `____`-delimited quote are reached through
535    /// [`nested_blocks_mut()`](IsBlock::nested_blocks_mut) by the generic block
536    /// walker; this method handles the Markdown-style case, whose blocks borrow
537    /// the block's own owned source.
538    pub(crate) fn resolve_references(
539        &mut self,
540        resolver: &dyn ReferenceResolver,
541        renderer: &dyn InlineSubstitutionRenderer,
542        warnings: &mut ReferenceWarnings<'src>,
543    ) {
544        let source = self.source;
545
546        // The owned store is shared behind an `Arc`, but references are resolved
547        // immediately after parsing while the block is still its sole owner, so
548        // `get_mut` succeeds.
549        if let Some(owned) = self.markdown_blocks.as_mut()
550            && let Some(owned) = Arc::get_mut(owned)
551        {
552            owned.with_dependent_mut(|_, dependent| {
553                // These blocks borrow the quote's own owned source, so their
554                // warnings are collected separately and then re-anchored to the
555                // quote block's span in the document.
556                let mut owned_warnings = ReferenceWarnings::default();
557
558                for block in &mut dependent.blocks {
559                    block.resolve_references(resolver, renderer, &mut owned_warnings);
560                }
561
562                owned_warnings.rehome_into(warnings, source);
563            });
564        }
565    }
566}
567
568/// Returns `true` if `line` introduces a Markdown-style blockquote: a `>`
569/// followed by a space, or a bare `>`.
570fn is_markdown_marker_line(line: &str) -> bool {
571    line == ">" || line.starts_with("> ")
572}
573
574/// Removes a trailing attribution line (introduced by `--`) from `lines`,
575/// returning the rendered attribution and citation.
576///
577/// Trailing blank lines are discarded first. If the last remaining line begins
578/// with `--` followed by whitespace and text, it is consumed and split into the
579/// attribution and (optional) citation.
580fn take_trailing_attribution(
581    lines: &mut Vec<String>,
582    parser: &Parser,
583) -> (Option<String>, Option<String>) {
584    while lines.last().is_some_and(|line| line.is_empty()) {
585        lines.pop();
586    }
587
588    if let Some(last) = lines.last()
589        && let Some(rest) = last.strip_prefix("--")
590        && (rest.starts_with(' ') || rest.starts_with('\t'))
591    {
592        let result = split_attribution_line(rest.trim(), parser);
593        lines.pop();
594        while lines.last().is_some_and(|line| line.is_empty()) {
595            lines.pop();
596        }
597        return result;
598    }
599
600    (None, None)
601}
602
603/// Returns `true` if `line` is a quote/verse delimiter (a line of four or more
604/// underscores).
605pub(crate) fn is_quote_verse_delimiter(line: &Span<'_>) -> bool {
606    let data = line.data();
607    data.len() >= 4 && data.starts_with("____") && data.chars().all(|c| c == '_')
608}
609
610/// Builds a verse block's verbatim content: the text between the delimiters
611/// with normal substitutions applied and line breaks preserved.
612fn render_verbatim<'src>(inside: Span<'src>, parser: &Parser) -> Content<'src> {
613    let trimmed = inside.discard_empty_lines().trim_trailing_whitespace();
614    let mut content = Content::from(trimmed);
615    SubstitutionGroup::Normal.apply(&mut content, parser, None);
616    content
617}
618
619/// Renders a fragment of attribution text (the attribution or citation) by
620/// applying normal inline substitutions, returning the owned result.
621fn render_inline(parser: &Parser, text: &str) -> String {
622    let span = Span::new(text);
623    let mut content = Content::from(span);
624    SubstitutionGroup::Normal.apply(&mut content, parser, None);
625    content.rendered_owned()
626}
627
628/// Extracts the attribution and citation from a block's attribute list.
629///
630/// The attribution is the `attribution` named attribute or the second
631/// positional attribute; the citation is the `citetitle` named attribute or the
632/// third positional attribute. An empty value is treated as absent.
633///
634/// The values are used as-is: an attribute list value already has its
635/// substitutions applied (e.g. a single-quoted value is rendered when the
636/// attribute list is parsed), so they must not be re-rendered here.
637fn extract_attribution(attrlist: Option<&Attrlist<'_>>) -> (Option<String>, Option<String>) {
638    let Some(attrlist) = attrlist else {
639        return (None, None);
640    };
641
642    let attribution = attrlist
643        .named_or_positional_attribute("attribution", 2)
644        .map(|a| a.value())
645        .filter(|v| !v.is_empty())
646        .map(str::to_string);
647
648    let citetitle = attrlist
649        .named_or_positional_attribute("citetitle", 3)
650        .map(|a| a.value())
651        .filter(|v| !v.is_empty())
652        .map(str::to_string);
653
654    (attribution, citetitle)
655}
656
657/// Splits an attribution line's text (everything after the leading `-- `) into
658/// the attribution and the optional citation, separated by the first comma.
659fn split_attribution_line(text: &str, parser: &Parser) -> (Option<String>, Option<String>) {
660    match text.split_once(',') {
661        Some((attribution, citetitle)) => {
662            let attribution = attribution.trim();
663            let citetitle = citetitle.trim();
664            (
665                non_empty(attribution).map(|v| render_inline(parser, v)),
666                non_empty(citetitle).map(|v| render_inline(parser, v)),
667            )
668        }
669        None => (
670            non_empty(text.trim()).map(|v| render_inline(parser, v)),
671            None,
672        ),
673    }
674}
675
676fn non_empty(s: &str) -> Option<&str> {
677    if s.is_empty() { None } else { Some(s) }
678}
679
680/// Finds the attribution line in a quoted paragraph and splits it from the
681/// quoted text.
682///
683/// The attribution line begins with `--` followed by at least one space or tab
684/// and then more text. The **last** such line is used (matching Asciidoctor's
685/// greedy match and the Markdown-style path), so a `-- …` that appears earlier
686/// in the quoted body does not prematurely terminate the quote. Returns the
687/// text before that line (the quoted text) and the attribution text (everything
688/// after the `--` marker and its trailing whitespace), or `None` if no
689/// attribution line is present.
690fn split_at_attribution_line(data: &str) -> Option<(&str, &str)> {
691    let mut line_start = 0;
692    let mut attribution: Option<(usize, &str)> = None;
693
694    for line in data.split_inclusive('\n') {
695        let trimmed = line.strip_suffix('\n').unwrap_or(line);
696
697        if let Some(rest) = trimmed.strip_prefix("--")
698            && (rest.starts_with(' ') || rest.starts_with('\t'))
699        {
700            let attribution_text = rest.trim_start_matches([' ', '\t']);
701            // `line_start > 0` ensures there is at least one line of quoted text
702            // before the attribution line.
703            if !attribution_text.is_empty() && line_start > 0 {
704                attribution = Some((line_start, attribution_text));
705            }
706        }
707
708        line_start += line.len();
709    }
710
711    // `line_start` is always a line boundary, so `split_at` never lands inside a
712    // character.
713    attribution.map(|(start, text)| (data.split_at(start).0, text))
714}
715
716/// Returns the span of the paragraph that begins at `source`: all lines up to
717/// (but not including) the first blank line.
718fn read_paragraph(source: Span<'_>) -> Span<'_> {
719    source.trim_remainder(read_paragraph_after(source))
720}
721
722/// Returns the span that follows the paragraph beginning at `source` (starting
723/// at the first blank line or end of input).
724fn read_paragraph_after(source: Span<'_>) -> Span<'_> {
725    let mut next = source;
726    while let Some(line) = next.take_non_empty_line() {
727        next = line.after;
728    }
729    next
730}
731
732impl<'src> IsBlock<'src> for QuoteBlock<'src> {
733    fn content_model(&self) -> ContentModel {
734        self.content_model
735    }
736
737    fn raw_context(&self) -> CowStr<'src> {
738        self.type_.name().into()
739    }
740
741    fn declared_style(&'src self) -> Option<&'src str> {
742        self.attrlist
743            .as_ref()
744            .and_then(|attrlist| attrlist.block_style())
745    }
746
747    fn rendered_content(&'src self) -> Option<&'src str> {
748        self.content.as_ref().map(|content| content.rendered())
749    }
750
751    /// Returns the nested blocks of a `____`-delimited quote.
752    ///
753    /// **Note:** a Markdown-style blockquote's nested blocks borrow the block's
754    /// own owned source rather than the document source, so they cannot be
755    /// returned through this `'src`-bound trait method and this iterator is
756    /// empty for them. Use [`QuoteBlock::blocks()`] to read the nested blocks
757    /// of any compound quote, Markdown-style or not. (Rendering and
758    /// reference resolution go through `blocks()` and an explicit
759    /// crate-internal `resolve_references`, so this gap is internal to the
760    /// crate.)
761    fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
762        self.blocks.iter()
763    }
764
765    /// Returns a mutable slice of the nested blocks of a `____`-delimited
766    /// quote.
767    ///
768    /// See [the note on `nested_blocks()`](Self::nested_blocks): a
769    /// Markdown-style blockquote's nested blocks are not reachable through
770    /// this method.
771    fn nested_blocks_mut(&mut self) -> &mut [Block<'src>] {
772        &mut self.blocks
773    }
774
775    fn content_mut(&mut self) -> Option<&mut Content<'src>> {
776        self.content.as_mut()
777    }
778
779    fn title_source(&'src self) -> Option<Span<'src>> {
780        self.title_source
781    }
782
783    fn title(&self) -> Option<&str> {
784        self.title.as_ref().map(Content::rendered_str)
785    }
786
787    fn anchor(&'src self) -> Option<Span<'src>> {
788        self.anchor
789    }
790
791    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
792        self.anchor_reftext
793    }
794
795    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
796        self.attrlist.as_ref()
797    }
798}
799
800impl<'src> HasSpan<'src> for QuoteBlock<'src> {
801    fn span(&self) -> Span<'src> {
802        self.source
803    }
804}
805
806impl std::fmt::Debug for QuoteBlock<'_> {
807    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
808        f.debug_struct("QuoteBlock")
809            .field("type_", &self.type_)
810            .field("content_model", &self.content_model)
811            .field("content", &self.content)
812            .field("blocks", &DebugSliceReference(&self.blocks))
813            .field("attribution", &self.attribution)
814            .field("citetitle", &self.citetitle)
815            .field("source", &self.source)
816            .field("title_source", &self.title_source)
817            .field("title", &self.title)
818            .field("anchor", &self.anchor)
819            .field("anchor_reftext", &self.anchor_reftext)
820            .field("attrlist", &self.attrlist)
821            .finish()
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    #![allow(clippy::unwrap_used)]
828    #![allow(clippy::panic)]
829
830    use std::ops::Deref;
831
832    use crate::{
833        blocks::{Block, ContentModel, IsBlock, QuoteType},
834        tests::prelude::*,
835    };
836
837    fn parse_one(input: &'static str) -> Block<'static> {
838        let mut parser = Parser::default();
839        Block::parse(crate::Span::new(input), &mut parser)
840            .unwrap_if_no_warnings()
841            .unwrap()
842            .item
843    }
844
845    fn as_quote<'a>(block: &'a Block<'a>) -> &'a crate::blocks::QuoteBlock<'a> {
846        match block {
847            Block::Quote(quote) => quote,
848            // Only reached if a test parses an input that is not a quote block;
849            // it exists to fail that test loudly, so it is uncovered while the
850            // tests pass.
851            other => panic!("expected a quote block, got {other:?}"),
852        }
853    }
854
855    mod quote_type {
856        use crate::blocks::QuoteType;
857
858        #[test]
859        fn name() {
860            assert_eq!(QuoteType::Quote.name(), "quote");
861            assert_eq!(QuoteType::Verse.name(), "verse");
862        }
863
864        #[test]
865        fn impl_debug() {
866            assert_eq!(format!("{:?}", QuoteType::Quote), "QuoteType::Quote");
867            assert_eq!(format!("{:?}", QuoteType::Verse), "QuoteType::Verse");
868        }
869
870        #[test]
871        fn impl_clone() {
872            // Silly test to mark the #[derive(...)] line as covered.
873            let v1 = QuoteType::Quote;
874            let v2 = v1;
875            assert_eq!(v1, v2);
876        }
877    }
878
879    #[test]
880    fn delimited_quote_is_compound() {
881        let block = parse_one("____\nA quote.\n\nWith two paragraphs.\n____");
882        let quote = as_quote(&block);
883
884        assert_eq!(quote.type_(), QuoteType::Quote);
885        assert_eq!(quote.content_model(), ContentModel::Compound);
886        assert_eq!(quote.raw_context().deref(), "quote");
887        assert!(quote.content().is_none());
888        assert!(quote.attribution().is_none());
889        assert!(quote.citetitle().is_none());
890        assert_eq!(quote.blocks().len(), 2);
891        assert_eq!(quote.nested_blocks().count(), 2);
892    }
893
894    #[test]
895    fn delimited_quote_with_attribution_and_citation() {
896        let block =
897            parse_one("[quote,Abraham Lincoln,Gettysburg Address]\n____\nFour score.\n____");
898        let quote = as_quote(&block);
899
900        assert_eq!(quote.attribution(), Some("Abraham Lincoln"));
901        assert_eq!(quote.citetitle(), Some("Gettysburg Address"));
902        assert_eq!(quote.declared_style(), Some("quote"));
903    }
904
905    #[test]
906    fn styled_paragraph_quote_is_simple() {
907        let block = parse_one("[quote,Albert Einstein]\nA person who never made a mistake.");
908        let quote = as_quote(&block);
909
910        assert_eq!(quote.type_(), QuoteType::Quote);
911        assert_eq!(quote.content_model(), ContentModel::Simple);
912        assert_eq!(
913            quote.content().unwrap().rendered(),
914            "A person who never made a mistake."
915        );
916        assert_eq!(
917            quote.rendered_content(),
918            Some("A person who never made a mistake.")
919        );
920        assert_eq!(quote.attribution(), Some("Albert Einstein"));
921        assert!(quote.citetitle().is_none());
922    }
923
924    #[test]
925    fn verse_paragraph_is_simple() {
926        let block = parse_one("[verse,Carl Sandburg,Fog]\nThe fog comes\non little cat feet.");
927        let quote = as_quote(&block);
928
929        assert_eq!(quote.type_(), QuoteType::Verse);
930        assert_eq!(quote.content_model(), ContentModel::Simple);
931        assert_eq!(quote.raw_context().deref(), "verse");
932        assert_eq!(
933            quote.content().unwrap().rendered(),
934            "The fog comes\non little cat feet."
935        );
936        assert_eq!(quote.attribution(), Some("Carl Sandburg"));
937        assert_eq!(quote.citetitle(), Some("Fog"));
938    }
939
940    #[test]
941    fn verse_delimited_preserves_line_breaks() {
942        let block = parse_one("[verse]\n____\nA verse\ndelimited block\n____");
943        let quote = as_quote(&block);
944
945        assert_eq!(quote.type_(), QuoteType::Verse);
946        assert_eq!(quote.content_model(), ContentModel::Simple);
947        assert_eq!(
948            quote.content().unwrap().rendered(),
949            "A verse\ndelimited block"
950        );
951        assert!(quote.nested_blocks().next().is_none());
952    }
953
954    #[test]
955    fn quote_or_verse_style_over_other_container_is_not_a_quote() {
956        // A `quote`/`verse` style over an example, sidebar, listing, literal, or
957        // passthrough block keeps that container's own context.
958        assert_eq!(
959            parse_one("[quote]\n====\nx\n====").raw_context().deref(),
960            "example"
961        );
962        assert_eq!(
963            parse_one("[verse]\n****\nx\n****").raw_context().deref(),
964            "sidebar"
965        );
966        assert_eq!(
967            parse_one("[quote]\n----\nx\n----").raw_context().deref(),
968            "listing"
969        );
970    }
971
972    #[test]
973    fn quoted_paragraph_with_attribution_and_citation() {
974        let block = parse_one("\"A little rebellion is good.\"\n-- Thomas Jefferson, Volume 11");
975        let quote = as_quote(&block);
976
977        assert_eq!(quote.type_(), QuoteType::Quote);
978        assert_eq!(quote.content_model(), ContentModel::Simple);
979        assert_eq!(
980            quote.content().unwrap().rendered(),
981            "A little rebellion is good."
982        );
983        assert_eq!(quote.attribution(), Some("Thomas Jefferson"));
984        assert_eq!(quote.citetitle(), Some("Volume 11"));
985    }
986
987    #[test]
988    fn quoted_paragraph_without_citation() {
989        let block = parse_one("\"A quote.\"\n-- Anonymous");
990        let quote = as_quote(&block);
991
992        assert_eq!(quote.attribution(), Some("Anonymous"));
993        assert!(quote.citetitle().is_none());
994    }
995
996    #[test]
997    fn quoted_paragraph_requires_attribution_line() {
998        // Without a `-- ` attribution line, a quoted sentence is just a
999        // paragraph.
1000        let block = parse_one("\"Just a quoted sentence.\"");
1001        assert_eq!(block.raw_context().deref(), "paragraph");
1002    }
1003
1004    #[test]
1005    fn quoted_paragraph_requires_opening_quote() {
1006        // The attribution line alone, with no opening quote, is not a quoted
1007        // paragraph.
1008        let block = parse_one("Not quoted.\n-- Someone");
1009        assert_eq!(block.raw_context().deref(), "paragraph");
1010    }
1011
1012    #[test]
1013    fn empty_quoted_paragraph_is_not_a_quote() {
1014        // A pair of quotes wrapping no text is not a quoted paragraph.
1015        let block = parse_one("\"\"\n-- Someone");
1016        assert_eq!(block.raw_context().deref(), "paragraph");
1017    }
1018
1019    #[test]
1020    fn unclosed_quoted_paragraph_is_not_a_quote() {
1021        // An opening quote with no closing quote is not a quoted paragraph.
1022        let block = parse_one("\"no closing quote\n-- Someone");
1023        assert_eq!(block.raw_context().deref(), "paragraph");
1024    }
1025
1026    #[test]
1027    fn dash_line_without_attribution_text_is_not_a_quote() {
1028        // A `-- ` line with no attribution text after it is not an attribution
1029        // line, so this is an ordinary paragraph.
1030        let block = parse_one("\"A quote.\"\n--  ");
1031        assert_eq!(block.raw_context().deref(), "paragraph");
1032    }
1033
1034    #[test]
1035    fn quoted_paragraph_tab_separated_attribution() {
1036        // The `--` attribution marker may be separated from the text by a tab.
1037        let block = parse_one("\"A quote.\"\n--\tSomeone");
1038        let quote = as_quote(&block);
1039        assert_eq!(quote.attribution(), Some("Someone"));
1040    }
1041
1042    #[test]
1043    fn quoted_paragraph_uses_last_attribution_line() {
1044        // A `-- …` that appears earlier in the quoted body does not terminate
1045        // the quote; the last attribution line wins (matching Asciidoctor).
1046        let block =
1047            parse_one("\"line one\n-- not really an attribution\nline two\"\n-- Real Attribution");
1048        let quote = as_quote(&block);
1049        assert_eq!(quote.attribution(), Some("Real Attribution"));
1050        let rendered = quote.content().unwrap().rendered();
1051        assert!(
1052            rendered.contains("line one") && rendered.contains("line two"),
1053            "content was: {rendered}"
1054        );
1055    }
1056
1057    #[test]
1058    fn attribution_with_empty_name_keeps_citation() {
1059        // A `--` line of the form `-- , citation` has no attribution but still
1060        // yields a citation.
1061        let block = parse_one("\"A quote.\"\n-- , Just a citation");
1062        let quote = as_quote(&block);
1063        assert!(quote.attribution().is_none());
1064        assert_eq!(quote.citetitle(), Some("Just a citation"));
1065    }
1066
1067    #[test]
1068    fn styled_paragraph_with_no_content_is_not_a_quote() {
1069        // A `[quote]` style with no following content cannot form a styled
1070        // paragraph; the lone attribute list is treated as an ordinary block.
1071        let mut parser = Parser::default();
1072        let maw = Block::parse(crate::Span::new("[quote]\n"), &mut parser);
1073        let block = maw.item.unwrap().item;
1074        assert_eq!(block.raw_context().deref(), "paragraph");
1075    }
1076
1077    #[test]
1078    fn markdown_blockquote_tab_attribution_after_blank() {
1079        // A trailing blank (`>`) line before the `--` attribution is discarded,
1080        // and the attribution marker may be tab-separated.
1081        let block = parse_one("> A quote.\n>\n> --\tSomeone");
1082        let quote = as_quote(&block);
1083        assert_eq!(quote.attribution(), Some("Someone"));
1084        assert_eq!(quote.blocks().len(), 1);
1085    }
1086
1087    #[test]
1088    fn markdown_blockquote_propagates_nested_warning() {
1089        // A warning produced while parsing the (owned, `>`-stripped) body — here
1090        // an unterminated nested delimited block — is re-anchored at the
1091        // blockquote's own span and surfaced to the caller, rather than being
1092        // dropped (or panicking a debug build).
1093        let mut parser = Parser::default();
1094        let maw = Block::parse(crate::Span::new("> ____\n> unclosed"), &mut parser);
1095
1096        let block = maw.item.unwrap().item;
1097        assert_eq!(block.raw_context().deref(), "quote");
1098        assert_eq!(
1099            maw.warnings.first().unwrap().warning,
1100            WarningType::UnterminatedDelimitedBlock
1101        );
1102        // The warning is anchored at the blockquote's source span.
1103        assert_eq!(maw.warnings.first().unwrap().source, block.span());
1104    }
1105
1106    #[test]
1107    fn markdown_blockquote_double_dash_without_space_is_content() {
1108        // A trailing `--` not followed by whitespace is content, not an
1109        // attribution.
1110        let block = parse_one("> A quote.\n> --nospace");
1111        let quote = as_quote(&block);
1112        assert!(quote.attribution().is_none());
1113    }
1114
1115    #[test]
1116    fn markdown_blockquote_basic() {
1117        let block = parse_one("> A markdown quote.");
1118        let quote = as_quote(&block);
1119
1120        assert_eq!(quote.type_(), QuoteType::Quote);
1121        assert_eq!(quote.content_model(), ContentModel::Compound);
1122        assert_eq!(quote.blocks().len(), 1);
1123        // The nested blocks borrow the block's owned source, so they are not
1124        // exposed through the `'src`-bound trait accessor.
1125        assert!(quote.nested_blocks().next().is_none());
1126    }
1127
1128    #[test]
1129    fn markdown_blockquote_with_attribution() {
1130        let block = parse_one("> A quote.\n> -- Someone");
1131        let quote = as_quote(&block);
1132
1133        assert_eq!(quote.attribution(), Some("Someone"));
1134        assert_eq!(quote.blocks().len(), 1);
1135    }
1136
1137    #[test]
1138    fn markdown_blockquote_lazy_continuation() {
1139        // A line without a `>` marker continues the current paragraph.
1140        let block = parse_one("> line one\nline two");
1141        let quote = as_quote(&block);
1142
1143        assert_eq!(quote.blocks().len(), 1);
1144        let inner = quote.blocks().first().unwrap();
1145        assert_eq!(inner.rendered_content(), Some("line one\nline two"));
1146    }
1147
1148    #[test]
1149    fn markdown_marker_requires_space() {
1150        // A `>` immediately followed by other text is not a blockquote.
1151        let block = parse_one(">foo bar");
1152        assert_eq!(block.raw_context().deref(), "paragraph");
1153    }
1154
1155    #[test]
1156    fn markdown_yields_to_description_list() {
1157        // A `>`-prefixed description-list term is a description list, not a
1158        // Markdown-style blockquote.
1159        let block = parse_one("> term:: definition");
1160        assert_eq!(block.raw_context().deref(), "list");
1161    }
1162
1163    #[test]
1164    fn unterminated_delimited_quote_warns() {
1165        let mut parser = Parser::default();
1166        let maw = Block::parse(crate::Span::new("____\nunclosed"), &mut parser);
1167
1168        let block = maw.item.unwrap().item;
1169        assert_eq!(block.raw_context().deref(), "quote");
1170        assert_eq!(maw.warnings.len(), 1);
1171        assert_eq!(
1172            maw.warnings.first().unwrap().warning,
1173            WarningType::UnterminatedDelimitedBlock
1174        );
1175    }
1176
1177    #[test]
1178    fn citation_receives_inline_substitutions() {
1179        // A link in the citation is rendered to an anchor.
1180        let block = parse_one(
1181            "[quote,Lewis Carroll,'See https://example.com/lc[the bio]']\n____\nAny road.\n____",
1182        );
1183        let quote = as_quote(&block);
1184        let citetitle = quote.citetitle().unwrap();
1185        assert!(
1186            citetitle.contains("<a href=\"https://example.com/lc\">the bio</a>"),
1187            "citation was: {citetitle}"
1188        );
1189    }
1190
1191    #[test]
1192    fn block_enum_delegates_to_quote() {
1193        // Exercise the `Block`-level `IsBlock`/`Debug` arms for a quote block.
1194        let compound = parse_one("____\nx\n____");
1195        assert_eq!(compound.content_model(), ContentModel::Compound);
1196        assert_eq!(compound.raw_context().deref(), "quote");
1197        assert!(compound.title_source().is_none());
1198        assert!(compound.anchor().is_none());
1199        assert!(compound.anchor_reftext().is_none());
1200        assert!(compound.attrlist().is_none());
1201        assert_eq!(compound.substitution_group(), SubstitutionGroup::Normal);
1202        assert_eq!(compound.nested_blocks().count(), 1);
1203        assert!(compound.title().is_none());
1204        assert!(compound.declared_style().is_none());
1205        assert!(format!("{compound:?}").starts_with("Block::Quote"));
1206
1207        let simple = parse_one("[verse]\nverse text");
1208        assert_eq!(simple.rendered_content(), Some("verse text"));
1209        assert_eq!(simple.content_model(), ContentModel::Simple);
1210    }
1211
1212    #[test]
1213    fn impl_debug() {
1214        let block = parse_one("____\nx\n____");
1215        let quote = as_quote(&block);
1216        let debug = format!("{quote:?}");
1217        assert!(debug.starts_with("QuoteBlock {"));
1218        assert!(debug.contains("type_: QuoteType::Quote"));
1219    }
1220
1221    #[test]
1222    fn impl_clone() {
1223        // Silly test to mark the #[derive(...)] line as covered.
1224        let block = parse_one("____\nclone me\n____");
1225        let quote = as_quote(&block).clone();
1226        assert_eq!(quote.type_(), QuoteType::Quote);
1227    }
1228
1229    #[test]
1230    fn title_renders_inside_quote_block() {
1231        let doc = Parser::default()
1232            .parse(".A title\n[quote,Captain Kirk]\nEverybody remember where we parked.");
1233        let block = doc.nested_blocks().next().unwrap();
1234        let quote = as_quote(block);
1235        assert_eq!(quote.title(), Some("A title"));
1236    }
1237}