Skip to main content

acdc_parser/model/
metadata.rs

1//! Block metadata types for `AsciiDoc` documents.
2
3use serde::Serialize;
4
5#[cfg(feature = "pre-spec-subs")]
6use super::substitution::SubstitutionSpec;
7use super::{
8    DocumentAttribute,
9    anchor::Anchor,
10    attributes::{AttributeValue, ElementAttributes},
11    attribution::{Attribution, CiteTitle},
12    caption::Caption,
13    location::Location,
14    substitution::Substitution,
15};
16
17pub type Role<'a> = &'a str;
18
19#[derive(Clone, Debug, Default, PartialEq)]
20pub(crate) struct PositionalAttribute<'a> {
21    pub(crate) value: &'a str,
22    pub(crate) substitutions: bool,
23    pub(crate) location: Option<Location>,
24}
25
26#[derive(Clone, Debug, Default, PartialEq)]
27pub(crate) struct DocumentAttributeEvents<'a>(Vec<DocumentAttribute<'a>>);
28
29/// A `BlockMetadata` represents the metadata of a block in a document.
30///
31/// Equality includes the values and empty slots from [`Self::positional_attributes`],
32/// but not their source locations. The block's own [`Self::location`] still participates.
33#[derive(Clone, Debug, Default, Serialize)]
34#[non_exhaustive]
35pub struct BlockMetadata<'a> {
36    // Store uncommon parser events separately to keep each block's metadata small.
37    #[serde(skip)]
38    pub(crate) document_attributes: Option<Box<DocumentAttributeEvents<'a>>>,
39    /// Caption behavior resolved from the document attributes in effect at this block's
40    /// source position, and the ordinal assigned to it. `None` means caller-built metadata
41    /// or a block that takes no caption.
42    #[serde(skip)]
43    pub caption: Option<Caption<'a>>,
44    #[serde(default, skip_serializing_if = "ElementAttributes::is_empty")]
45    pub attributes: ElementAttributes<'a>,
46    /// Parser working slots, consumed as attributes are routed to their block context.
47    #[serde(default, skip_serializing)]
48    pub(crate) positional_attributes: Vec<PositionalAttribute<'a>>,
49    // Preserve the original slots before routing consumes or inserts working slots.
50    #[serde(skip)]
51    pub(crate) retained_positional_attributes: Option<Vec<PositionalAttribute<'a>>>,
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub roles: Vec<Role<'a>>,
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub options: Vec<&'a str>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub style: Option<&'a str>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub id: Option<Anchor<'a>>,
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub anchors: Vec<Anchor<'a>>,
62    /// Substitutions to apply to block content. Only present when the
63    /// `pre-spec-subs` feature is enabled; the draft `AsciiDoc` spec is
64    /// dropping the substitution model in favour of an inline parsing
65    /// grammar, so this field is feature-gated to reflect that.
66    ///
67    /// - `None`: Use block-type defaults (VERBATIM for listing/literal, NORMAL for paragraphs)
68    /// - `Some(Source(entries))`: Preserve authored entries; resolve against block defaults as needed
69    /// - `Some(Explicit([]))`: No substitutions (equivalent to `subs=none`)
70    /// - `Some(Explicit(list))`: Use the explicit list of substitutions
71    /// - `Some(Modifiers(ops))`: Apply modifier operations to block-type defaults
72    #[cfg(feature = "pre-spec-subs")]
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub substitutions: Option<SubstitutionSpec>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub attribution: Option<Attribution<'a>>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub citetitle: Option<CiteTitle<'a>>,
79    #[serde(default, skip_serializing)]
80    pub(crate) attribution_substitutions: bool,
81    #[serde(default, skip_serializing)]
82    pub(crate) citetitle_substitutions: bool,
83    #[serde(skip)]
84    pub location: Option<Location>,
85}
86
87impl PartialEq for BlockMetadata<'_> {
88    fn eq(&self, other: &Self) -> bool {
89        let Self {
90            document_attributes,
91            caption,
92            attributes,
93            positional_attributes: _,
94            retained_positional_attributes: _,
95            roles,
96            options,
97            style,
98            id,
99            anchors,
100            #[cfg(feature = "pre-spec-subs")]
101            substitutions,
102            attribution,
103            citetitle,
104            attribution_substitutions,
105            citetitle_substitutions,
106            location,
107        } = self;
108        #[cfg(feature = "pre-spec-subs")]
109        if substitutions != &other.substitutions {
110            return false;
111        }
112        document_attributes == &other.document_attributes
113            && caption == &other.caption
114            && attributes == &other.attributes
115            && roles == &other.roles
116            && options == &other.options
117            && style == &other.style
118            && id == &other.id
119            && anchors == &other.anchors
120            && attribution == &other.attribution
121            && citetitle == &other.citetitle
122            && attribution_substitutions == &other.attribution_substitutions
123            && citetitle_substitutions == &other.citetitle_substitutions
124            && location == &other.location
125            && self
126                .positional_attributes()
127                .eq(other.positional_attributes())
128    }
129}
130
131impl<'a> BlockMetadata<'a> {
132    /// Create a new block metadata with default values.
133    #[must_use]
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Whether a substitution is enabled after applying this block's `subs` setting.
139    ///
140    /// `defaults` contains individual substitutions, such as [`crate::VERBATIM`]
141    /// for source blocks. Without `pre-spec-subs`, only these defaults apply.
142    #[must_use]
143    pub fn uses_substitution(
144        &self,
145        substitution: &Substitution,
146        defaults: &[Substitution],
147    ) -> bool {
148        #[cfg(feature = "pre-spec-subs")]
149        if let Some(spec) = &self.substitutions {
150            return spec.contains(substitution, defaults);
151        }
152        defaults.contains(substitution)
153    }
154
155    /// The anchor that defines this block's id: the explicit `id` (`[#id]`),
156    /// otherwise the first `[[id]]` anchor. `None` when the block has no id.
157    pub(crate) fn id_anchor(&self) -> Option<&Anchor<'a>> {
158        self.id.as_ref().or_else(|| self.anchors.first())
159    }
160
161    /// Set the attributes.
162    #[must_use]
163    pub fn with_attributes(mut self, attributes: ElementAttributes<'a>) -> Self {
164        self.attributes = attributes;
165        self
166    }
167
168    /// Set the options.
169    #[must_use]
170    pub fn with_options(mut self, options: Vec<&'a str>) -> Self {
171        self.options = options;
172        self
173    }
174
175    /// Set the roles.
176    #[must_use]
177    pub fn with_roles(mut self, roles: Vec<Role<'a>>) -> Self {
178        self.roles = roles;
179        self
180    }
181
182    /// Set the style.
183    #[must_use]
184    pub fn with_style(mut self, style: Option<&'a str>) -> Self {
185        self.style = style;
186        self
187    }
188
189    /// Set the ID.
190    #[must_use]
191    pub fn with_id(mut self, id: Option<Anchor<'a>>) -> Self {
192        self.id = id;
193        self
194    }
195
196    /// Return a non-empty retained positional attribute by its zero-based slot.
197    ///
198    /// Returns `None` when the slot is empty or absent. See
199    /// [`Self::positional_attributes`] for ordering, substitution, and merge behavior.
200    #[must_use]
201    pub fn positional_attribute(&self, index: usize) -> Option<&'a str> {
202        self.raw_positional_attributes()
203            .get(index)
204            .map(|attribute| attribute.value)
205            .filter(|value| !value.is_empty())
206    }
207
208    /// Iterate over retained positional slots after the first attribute-list entry.
209    ///
210    /// The first entry is usually a block style, or alt text or a poster in a media
211    /// macro. It is excluded even when parsing consumes or clears [`Self::style`].
212    ///
213    /// For block attribute lists, surrounding quotes are removed and document-attribute
214    /// references are expanded; inline markup remains literal. Empty strings (quoted
215    /// or unquoted) and gaps before later positional values yield `None`. For example,
216    /// `[tikz,target=x,svg]` yields `[None, Some("svg")]`; trailing named attributes do
217    /// not extend the iterator.
218    /// Consecutive lists merge by slot: later slots replace earlier ones, including
219    /// empty slots, while earlier trailing slots remain.
220    ///
221    /// Values remain available after context-specific routing. This positional view
222    /// is not added to JSON. Markdown fence languages do not add positional slots.
223    ///
224    /// This is a snapshot of the parsed, merged slots. Changing [`Self::attributes`]
225    /// or [`Self::style`] afterward does not update it. For example, after parsing
226    /// `[source,rust]`, setting the named `language` attribute to `python` leaves
227    /// positional slot 0 as `Some("rust")`. Metadata created with [`Self::new`] or
228    /// [`Self::default`] has no positional slots; setting attributes does not add them.
229    pub fn positional_attributes(&self) -> impl ExactSizeIterator<Item = Option<&'a str>> + '_ {
230        self.raw_positional_attributes()
231            .iter()
232            .map(|attribute| (!attribute.value.is_empty()).then_some(attribute.value))
233    }
234
235    pub(crate) fn raw_positional_attributes(&self) -> &[PositionalAttribute<'a>] {
236        self.retained_positional_attributes
237            .as_deref()
238            .unwrap_or(&self.positional_attributes)
239    }
240
241    fn retain_positional_attributes(&mut self) {
242        self.retained_positional_attributes
243            .get_or_insert_with(|| self.positional_attributes.clone());
244    }
245
246    pub(crate) fn take_positional_attributes<const N: usize>(
247        &mut self,
248    ) -> [Option<PositionalAttribute<'a>>; N] {
249        self.retain_positional_attributes();
250        let count = N.min(self.positional_attributes.len());
251        let mut slots = self.positional_attributes.drain(..count);
252        std::array::from_fn(|_| slots.next())
253    }
254
255    pub(crate) fn prepend_synthetic_positional_attribute(
256        &mut self,
257        attribute: PositionalAttribute<'a>,
258    ) {
259        self.retain_positional_attributes();
260        self.positional_attributes.insert(0, attribute);
261    }
262
263    pub(crate) fn move_positional_attributes_to_attributes(&mut self) {
264        let positional_attributes = std::mem::take(&mut self.positional_attributes);
265        if self.style != Some("source") {
266            for attribute in &positional_attributes {
267                if !attribute.value.is_empty() {
268                    self.attributes.insert(
269                        std::borrow::Cow::Borrowed(attribute.value),
270                        AttributeValue::None,
271                    );
272                }
273            }
274        }
275        // Moving at finalization avoids cloning slots that routing did not consume.
276        if self.retained_positional_attributes.is_none() {
277            self.retained_positional_attributes = Some(positional_attributes);
278        }
279    }
280
281    pub(crate) fn has_document_attributes(&self) -> bool {
282        self.document_attributes.is_some()
283    }
284
285    pub(crate) fn push_document_attribute(&mut self, attribute: DocumentAttribute<'a>) {
286        self.document_attributes
287            .get_or_insert_with(Default::default)
288            .0
289            .push(attribute);
290    }
291
292    pub(crate) fn take_document_attributes(&mut self) -> Vec<DocumentAttribute<'a>> {
293        self.document_attributes
294            .take()
295            .map_or_else(Vec::new, |attributes| attributes.0)
296    }
297
298    pub(crate) fn append_document_attributes(
299        &mut self,
300        mut attributes: Vec<DocumentAttribute<'a>>,
301    ) {
302        if attributes.is_empty() {
303            return;
304        }
305        self.document_attributes
306            .get_or_insert_with(Default::default)
307            .0
308            .append(&mut attributes);
309    }
310
311    pub(crate) fn overlay_positional_attributes(&mut self, other: &[PositionalAttribute<'a>]) {
312        overlay_positional_slots(&mut self.positional_attributes, other);
313    }
314
315    /// Whether this metadata serializes to nothing.
316    ///
317    /// This gates `skip_serializing_if` on every block's `metadata` field, so it covers only
318    /// the serialized fields. `caption` is deliberately excluded: it carries `#[serde(skip)]`,
319    /// so counting it would make a block emit an empty `metadata` object.
320    #[must_use]
321    pub fn is_default(&self) -> bool {
322        #[cfg(feature = "pre-spec-subs")]
323        let subs_default = self.substitutions.is_none();
324        #[cfg(not(feature = "pre-spec-subs"))]
325        let subs_default = true;
326        self.roles.is_empty()
327            && self.options.is_empty()
328            && self.style.is_none()
329            && self.id.is_none()
330            && self.anchors.is_empty()
331            && self.attributes.is_empty()
332            && self.positional_attributes.is_empty()
333            && subs_default
334            && self.attribution.is_none()
335            && self.citetitle.is_none()
336    }
337
338    #[tracing::instrument(level = "debug", skip_all)]
339    pub(crate) fn merge(&mut self, other: &BlockMetadata<'a>) {
340        if let Some(attributes) = &other.document_attributes {
341            self.document_attributes
342                .get_or_insert_with(Default::default)
343                .0
344                .extend(attributes.0.iter().cloned());
345        }
346        for (name, value) in other.attributes.iter() {
347            self.attributes.set(name.clone(), value.clone());
348        }
349        if self.retained_positional_attributes.is_some()
350            || other.retained_positional_attributes.is_some()
351        {
352            self.retain_positional_attributes();
353            if let Some(retained) = &mut self.retained_positional_attributes {
354                overlay_positional_slots(retained, other.raw_positional_attributes());
355            }
356        }
357        self.overlay_positional_attributes(&other.positional_attributes);
358        if !other.roles.is_empty() {
359            self.roles.clone_from(&other.roles);
360        }
361        if !other.options.is_empty() {
362            self.options.clone_from(&other.options);
363        }
364        if other.style.is_some() {
365            self.style.clone_from(&other.style);
366        }
367        if other.id.is_some() {
368            self.id.clone_from(&other.id);
369        }
370        self.anchors.extend(other.anchors.clone());
371        #[cfg(feature = "pre-spec-subs")]
372        if other.substitutions.is_some() {
373            self.substitutions.clone_from(&other.substitutions);
374        }
375        if other.attribution.is_some() {
376            self.attribution.clone_from(&other.attribution);
377            self.attribution_substitutions = other.attribution_substitutions;
378        }
379        if other.citetitle.is_some() {
380            self.citetitle.clone_from(&other.citetitle);
381            self.citetitle_substitutions = other.citetitle_substitutions;
382        }
383    }
384}
385
386fn overlay_positional_slots<'a>(
387    destination: &mut Vec<PositionalAttribute<'a>>,
388    source: &[PositionalAttribute<'a>],
389) {
390    if destination.len() < source.len() {
391        destination.resize(source.len(), PositionalAttribute::default());
392    }
393    for (destination, attribute) in destination.iter_mut().zip(source) {
394        destination.clone_from(attribute);
395    }
396}