Skip to main content

asciidoc_parser/blocks/
quote.rs

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