Skip to main content

acdc_parser/model/
caption.rs

1//! Caption behavior resolved from block and document attributes.
2//!
3//! A caption is a property of a parsed block, so the parser resolves it while it still holds
4//! the document attributes in effect at the block's source position, then assigns the ordinals
5//! in one post-order pass over the finished tree. Converters read the result and never re-derive
6//! the precedence chain.
7
8use std::{borrow::Cow, num::NonZeroU32};
9
10use super::{AttributeValue, Block, BlockMetadata, DelimitedBlockType, DocumentAttributes, Table};
11
12/// The caption a titled block takes.
13#[derive(Clone, Debug, PartialEq)]
14#[non_exhaustive]
15pub enum Caption<'a> {
16    /// A type-specific caption and its document-wide ordinal.
17    ///
18    /// `kind` names the counter the ordinal came from. `number` is `Some` only for a block that
19    /// had a title when ordinals were assigned; such a block consumed one ordinal from that
20    /// counter. It is `None` for a block that had no title, which consumed nothing — a consumer
21    /// that adds a title later either supplies its own ordinal or calls
22    /// [`Document::renumber_captions`](super::Document::renumber_captions).
23    Numbered {
24        /// The counter this caption draws its ordinal from.
25        kind: CaptionKind,
26        /// The caption label, e.g. `Example`. Empty when the attribute is set with no value.
27        label: Cow<'a, str>,
28        /// The document-wide ordinal, or `None` when the block had no title. Kept narrow
29        /// because it sits on every block's metadata.
30        number: Option<NonZeroU32>,
31    },
32    /// An explicit prefix, which takes no ordinal.
33    Custom(Cow<'a, str>),
34    /// Caption-capable, but no prefix applies.
35    Unnumbered,
36}
37
38/// A block category with its own caption attribute and counter.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40#[non_exhaustive]
41pub enum CaptionKind {
42    /// An example block or paragraph controlled by `example-caption`.
43    Example,
44    /// A block image controlled by `figure-caption`.
45    Figure,
46    /// A listing or source block controlled by `listing-caption`.
47    Listing,
48    /// A table controlled by `table-caption`.
49    Table,
50}
51
52impl CaptionKind {
53    const fn attribute_name(self) -> &'static str {
54        match self {
55            Self::Example => "example-caption",
56            Self::Figure => "figure-caption",
57            Self::Listing => "listing-caption",
58            Self::Table => "table-caption",
59        }
60    }
61
62    /// The caption category for a parsed block, or `None` when the block takes no caption.
63    ///
64    /// Classification follows the block's effective context rather than its AST variant alone,
65    /// matching asciidoctor: a delimiter that carries a caption context of its own keeps it, so
66    /// `[listing]` on `====` is still an example. A style only changes the context of the
67    /// verbatim delimiters and of the open block, which carry none.
68    pub(crate) fn for_block(block: &Block<'_>) -> Option<Self> {
69        match block {
70            Block::DelimitedBlock(delimited) => {
71                Self::for_delimited(&delimited.inner, delimited.metadata.style)
72            }
73            Block::Paragraph(paragraph) => Self::for_style(paragraph.metadata.style),
74            Block::Image(_) => Some(Self::Figure),
75            Block::Section(_)
76            | Block::Admonition(_)
77            | Block::DiscreteHeader(_)
78            | Block::PageBreak(_)
79            | Block::ThematicBreak(_)
80            | Block::TableOfContents(_)
81            | Block::UnorderedList(_)
82            | Block::OrderedList(_)
83            | Block::CalloutList(_)
84            | Block::DescriptionList(_)
85            | Block::Audio(_)
86            | Block::Video(_)
87            | Block::DocumentAttribute(_)
88            | Block::Comment(_) => None,
89        }
90    }
91
92    /// The caption category a `[style]` names on a paragraph, or on a delimiter that carries
93    /// no context of its own.
94    ///
95    /// Parsed blocks already carry their caption in [`BlockMetadata::caption`]; this is for a
96    /// consumer classifying a block it built itself.
97    #[must_use]
98    pub fn for_style(style: Option<&str>) -> Option<Self> {
99        match style {
100            Some("example") => Some(Self::Example),
101            Some("listing" | "source") => Some(Self::Listing),
102            _ => None,
103        }
104    }
105
106    /// The caption category of a delimited block, from its delimiter and style.
107    ///
108    /// Parsed blocks already carry their caption in [`BlockMetadata::caption`]; this is for a
109    /// consumer classifying a block it built itself.
110    #[must_use]
111    pub fn for_delimited(inner: &DelimitedBlockType<'_>, style: Option<&str>) -> Option<Self> {
112        match inner {
113            // These delimiters carry their own caption context, whatever the style says.
114            DelimitedBlockType::DelimitedExample(_) => Some(Self::Example),
115            DelimitedBlockType::DelimitedTable(_) => Some(Self::Table),
116            // A verbatim block is a listing unless the style says otherwise: `[literal]` on
117            // `----` takes no caption, and `[listing]`/`[source]` on `....` takes a listing one.
118            DelimitedBlockType::DelimitedListing(_) => {
119                Self::for_verbatim(style, Some(Self::Listing))
120            }
121            DelimitedBlockType::DelimitedLiteral(_) => Self::for_verbatim(style, None),
122            // An open block has no caption context, so the style supplies one.
123            DelimitedBlockType::DelimitedOpen(_) => Self::for_style(style),
124            // Sidebar, quote, verse, passthrough, comment and stem take no caption.
125            DelimitedBlockType::DelimitedSidebar(_)
126            | DelimitedBlockType::DelimitedQuote(_)
127            | DelimitedBlockType::DelimitedVerse(_)
128            | DelimitedBlockType::DelimitedPass(_)
129            | DelimitedBlockType::DelimitedComment(_)
130            | DelimitedBlockType::DelimitedStem(_) => None,
131        }
132    }
133
134    /// The context of a verbatim block, where only `literal`, `listing` and `source` override
135    /// the delimiter's own `default`.
136    fn for_verbatim(style: Option<&str>, default: Option<Self>) -> Option<Self> {
137        match style {
138            Some("listing" | "source") => Some(Self::Listing),
139            Some("literal") => None,
140            _ => default,
141        }
142    }
143}
144
145/// Where a caption prefix comes from.
146///
147/// The inner `None` is an attribute set with no value, whose label is empty.
148enum CaptionSource<'value, 'a> {
149    /// An explicit prefix, from the block's `caption=` or the document's `caption`.
150    Custom(Option<&'value Cow<'a, str>>),
151    /// A type-specific label, from `<kind>-caption`.
152    Numbered(Option<&'value Cow<'a, str>>),
153    /// No caption applies.
154    None,
155}
156
157/// Collapsible examples take an empty custom caption. Other blocks use the block's own
158/// `caption=`, then the document-wide `caption`, then `<kind>-caption`. Any value found in the
159/// first two positions is used verbatim and takes no ordinal, matching asciidoctor — including
160/// an empty one.
161fn caption_source<'value, 'a>(
162    metadata: &'value BlockMetadata<'a>,
163    attributes: &'value DocumentAttributes<'a>,
164    kind: CaptionKind,
165) -> CaptionSource<'value, 'a> {
166    if kind == CaptionKind::Example && metadata.options.contains(&"collapsible") {
167        return CaptionSource::Custom(None);
168    }
169
170    // Only a value counts: `caption=` gives an empty custom caption, but the bare marker in
171    // `[listing,caption]` is a stray positional that asciidoctor ignores, and it reaches here
172    // as `AttributeValue::None`.
173    match metadata.attributes.get("caption") {
174        Some(AttributeValue::String(value)) => return CaptionSource::Custom(Some(value)),
175        Some(AttributeValue::Bool(_) | AttributeValue::None) | None => {}
176    }
177    match attributes.stored_text("caption") {
178        Some(value) => return CaptionSource::Custom(Some(value)),
179        None if attributes.contains_key("caption") => return CaptionSource::Custom(None),
180        None => {}
181    }
182    match attributes.stored_text(kind.attribute_name()) {
183        Some(value) => CaptionSource::Numbered(Some(value)),
184        None if attributes.contains_key(kind.attribute_name()) => CaptionSource::Numbered(None),
185        None => CaptionSource::None,
186    }
187}
188
189/// A label is stored verbatim. A document attribute's value is literal text — asciidoctor
190/// renders `:example-caption: 'Sample'` as `'Sample' 1.` — and the block-attribute parser has
191/// already removed the syntactic quotes around an element value.
192fn borrowed_label<'a>(value: Option<&Cow<'a, str>>) -> Cow<'a, str> {
193    value.cloned().unwrap_or(Cow::Borrowed(""))
194}
195
196fn owned_label(value: Option<&Cow<'_, str>>) -> Cow<'static, str> {
197    value.map_or(Cow::Borrowed(""), |value| Cow::Owned(value.to_string()))
198}
199
200impl<'a> Caption<'a> {
201    /// Resolve caption behavior from the block's own attributes and the document attributes in
202    /// effect at the block's source position. Sets no ordinal.
203    #[must_use]
204    pub fn resolve(
205        metadata: &BlockMetadata<'a>,
206        attributes: &DocumentAttributes<'a>,
207        kind: CaptionKind,
208    ) -> Self {
209        match caption_source(metadata, attributes, kind) {
210            CaptionSource::Custom(value) => Self::Custom(borrowed_label(value)),
211            CaptionSource::Numbered(value) => Self::Numbered {
212                kind,
213                label: borrowed_label(value),
214                number: None,
215            },
216            CaptionSource::None => Self::Unnumbered,
217        }
218    }
219}
220
221impl Caption<'static> {
222    /// [`Caption::resolve`] for metadata and attributes whose lifetimes are unrelated, as a
223    /// converter's are: its stored attributes are independent of the borrowed document. Owns its
224    /// strings, and sets no ordinal.
225    #[must_use]
226    pub fn resolve_owned(
227        metadata: &BlockMetadata<'_>,
228        attributes: &DocumentAttributes<'_>,
229        kind: CaptionKind,
230    ) -> Self {
231        match caption_source(metadata, attributes, kind) {
232            CaptionSource::Custom(value) => Self::Custom(owned_label(value)),
233            CaptionSource::Numbered(value) => Self::Numbered {
234                kind,
235                label: owned_label(value),
236                number: None,
237            },
238            CaptionSource::None => Self::Unnumbered,
239        }
240    }
241}
242
243/// One counter per [`CaptionKind`].
244#[derive(Debug, Default)]
245struct CaptionCounters {
246    example: u32,
247    figure: u32,
248    listing: u32,
249    table: u32,
250}
251
252impl CaptionCounters {
253    fn next(&mut self, kind: CaptionKind) -> Option<NonZeroU32> {
254        let counter = match kind {
255            CaptionKind::Example => &mut self.example,
256            CaptionKind::Figure => &mut self.figure,
257            CaptionKind::Listing => &mut self.listing,
258            CaptionKind::Table => &mut self.table,
259        };
260        *counter = counter.saturating_add(1);
261        NonZeroU32::new(*counter)
262    }
263}
264
265/// Assign document-wide caption ordinals in the order asciidoctor assigns them: a block's
266/// content is numbered before the block itself.
267///
268/// Every automatic ordinal is written on every run — `Some(n)` for a titled block and `None`
269/// otherwise — so the pass is idempotent and clears ordinals left stale by a mutation that
270/// removed a title or a block.
271pub(crate) fn renumber_captions(blocks: &mut [Block<'_>]) {
272    let mut counters = CaptionCounters::default();
273    renumber_blocks(blocks, &mut counters);
274}
275
276/// The highest ordinal assigned to a caption of `kind` anywhere in `blocks`, or 0 when none
277/// carries one. A converter numbering a block the parser could not — a caller-built one, or a
278/// parsed one that gained its title afterwards — starts past this so it cannot collide.
279pub(crate) fn highest_caption_number(blocks: &[Block<'_>], kind: CaptionKind) -> u32 {
280    let mut highest = 0;
281    visit_captions(blocks, &mut |caption| {
282        if let Caption::Numbered {
283            kind: caption_kind,
284            number: Some(number),
285            ..
286        } = caption
287            && *caption_kind == kind
288        {
289            highest = highest.max(number.get());
290        }
291    });
292    highest
293}
294
295fn visit_captions(blocks: &[Block<'_>], visit: &mut impl FnMut(&Caption<'_>)) {
296    for block in blocks {
297        if let Some(caption) = block
298            .metadata()
299            .and_then(|metadata| metadata.caption.as_ref())
300        {
301            visit(caption);
302        }
303        match block {
304            Block::Section(section) => visit_captions(&section.content, visit),
305            Block::Admonition(admonition) => visit_captions(&admonition.blocks, visit),
306            Block::UnorderedList(list) => {
307                for item in &list.items {
308                    visit_captions(&item.blocks, visit);
309                }
310            }
311            Block::OrderedList(list) => {
312                for item in &list.items {
313                    visit_captions(&item.blocks, visit);
314                }
315            }
316            Block::CalloutList(list) => {
317                for item in &list.items {
318                    visit_captions(&item.blocks, visit);
319                }
320            }
321            Block::DescriptionList(list) => {
322                for item in &list.items {
323                    visit_captions(&item.description, visit);
324                }
325            }
326            Block::DelimitedBlock(delimited) => match &delimited.inner {
327                DelimitedBlockType::DelimitedExample(blocks)
328                | DelimitedBlockType::DelimitedOpen(blocks)
329                | DelimitedBlockType::DelimitedSidebar(blocks)
330                | DelimitedBlockType::DelimitedQuote(blocks) => visit_captions(blocks, visit),
331                DelimitedBlockType::DelimitedTable(table) => {
332                    for row in table
333                        .header
334                        .iter()
335                        .chain(table.rows.iter())
336                        .chain(table.footer.iter())
337                    {
338                        for column in &row.columns {
339                            visit_captions(&column.content, visit);
340                        }
341                    }
342                }
343                DelimitedBlockType::DelimitedListing(_)
344                | DelimitedBlockType::DelimitedLiteral(_)
345                | DelimitedBlockType::DelimitedVerse(_)
346                | DelimitedBlockType::DelimitedPass(_)
347                | DelimitedBlockType::DelimitedComment(_)
348                | DelimitedBlockType::DelimitedStem(_) => {}
349            },
350            Block::Paragraph(_)
351            | Block::DiscreteHeader(_)
352            | Block::PageBreak(_)
353            | Block::ThematicBreak(_)
354            | Block::TableOfContents(_)
355            | Block::Image(_)
356            | Block::Audio(_)
357            | Block::Video(_)
358            | Block::DocumentAttribute(_)
359            | Block::Comment(_) => {}
360        }
361    }
362}
363
364fn renumber_blocks(blocks: &mut [Block<'_>], counters: &mut CaptionCounters) {
365    for block in blocks {
366        renumber_block(block, counters);
367    }
368}
369
370fn renumber_block(block: &mut Block<'_>, counters: &mut CaptionCounters) {
371    renumber_children(block, counters);
372
373    let titled = block.title().is_some();
374    if let Some(metadata) = block.metadata_mut()
375        && let Some(Caption::Numbered { kind, number, .. }) = &mut metadata.caption
376    {
377        *number = titled.then(|| counters.next(*kind)).flatten();
378    }
379}
380
381/// Number a block's content before the block itself. The container coverage mirrors
382/// `collect_references`, but the table walk follows source order (header, rows, footer) rather
383/// than that function's header/footer/rows order.
384fn renumber_children(block: &mut Block<'_>, counters: &mut CaptionCounters) {
385    match block {
386        Block::Section(section) => renumber_blocks(&mut section.content, counters),
387        Block::Admonition(admonition) => renumber_blocks(&mut admonition.blocks, counters),
388        Block::UnorderedList(list) => {
389            for item in &mut list.items {
390                renumber_blocks(&mut item.blocks, counters);
391            }
392        }
393        Block::OrderedList(list) => {
394            for item in &mut list.items {
395                renumber_blocks(&mut item.blocks, counters);
396            }
397        }
398        Block::CalloutList(list) => {
399            for item in &mut list.items {
400                renumber_blocks(&mut item.blocks, counters);
401            }
402        }
403        Block::DescriptionList(list) => {
404            for item in &mut list.items {
405                renumber_blocks(&mut item.description, counters);
406            }
407        }
408        Block::DelimitedBlock(delimited) => match &mut delimited.inner {
409            DelimitedBlockType::DelimitedExample(blocks)
410            | DelimitedBlockType::DelimitedOpen(blocks)
411            | DelimitedBlockType::DelimitedSidebar(blocks)
412            | DelimitedBlockType::DelimitedQuote(blocks) => renumber_blocks(blocks, counters),
413            DelimitedBlockType::DelimitedTable(table) => renumber_table(table, counters),
414            DelimitedBlockType::DelimitedListing(_)
415            | DelimitedBlockType::DelimitedLiteral(_)
416            | DelimitedBlockType::DelimitedVerse(_)
417            | DelimitedBlockType::DelimitedPass(_)
418            | DelimitedBlockType::DelimitedComment(_)
419            | DelimitedBlockType::DelimitedStem(_) => {}
420        },
421        Block::Paragraph(_)
422        | Block::DiscreteHeader(_)
423        | Block::PageBreak(_)
424        | Block::ThematicBreak(_)
425        | Block::TableOfContents(_)
426        | Block::Image(_)
427        | Block::Audio(_)
428        | Block::Video(_)
429        | Block::DocumentAttribute(_)
430        | Block::Comment(_) => {}
431    }
432}
433
434fn renumber_table(table: &mut Table<'_>, counters: &mut CaptionCounters) {
435    for row in table
436        .header
437        .iter_mut()
438        .chain(table.rows.iter_mut())
439        .chain(table.footer.iter_mut())
440    {
441        for column in &mut row.columns {
442            renumber_blocks(&mut column.content, counters);
443        }
444    }
445}