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