Skip to main content

acdc_parser/model/
mod.rs

1//! The data models for the `AsciiDoc` document.
2use std::{collections::HashMap, fmt::Display, str::FromStr, string::ToString};
3
4use bumpalo::Bump;
5use serde::{
6    Serialize,
7    ser::{SerializeMap, Serializer},
8};
9
10mod admonition;
11mod anchor;
12pub(crate) use anchor::{BibliographyLabel, SectionReference};
13mod attributes;
14mod attribution;
15pub(crate) mod caption;
16mod inlines;
17mod lists;
18mod location;
19mod media;
20mod metadata;
21pub(crate) mod section;
22pub(crate) mod substitution;
23mod tables;
24mod title;
25
26pub use admonition::{Admonition, AdmonitionVariant};
27pub use anchor::{Anchor, Reference, TocEntry, UNNUMBERED_SECTION_STYLES};
28pub(crate) use attributes::RawAttributes;
29pub(crate) use attributes::{
30    AttributeInlineFragment, DocumentAttributeStatus, default_document_attribute_values,
31};
32pub use attributes::{
33    AttributeName, AttributeValue, DocumentAttributeAssignment, DocumentAttributeValue,
34    DocumentAttributes, ElementAttributes, MAX_SECTION_LEVELS, MAX_TOC_LEVELS, strip_quotes,
35};
36pub use attribution::{Attribution, CiteTitle};
37pub use caption::{Caption, CaptionKind};
38pub use inlines::*;
39pub use lists::{
40    CalloutList, CalloutListItem, DescriptionList, DescriptionListItem, ListItem,
41    ListItemCheckedStatus, ListLevel, OrderedList, UnorderedList,
42};
43pub use location::*;
44pub use media::{Audio, Image, Source, SourceUrl, Video};
45pub(crate) use metadata::PositionalAttribute;
46pub use metadata::{BlockMetadata, Role};
47pub use section::*;
48pub use substitution::*;
49pub use tables::{
50    ColumnFormat, ColumnStyle, ColumnWidth, HorizontalAlignment, Table, TableColumn, TableFrame,
51    TableGrid, TablePresentation, TableRow, TableStripes, VerticalAlignment,
52};
53pub use title::{Subtitle, Title};
54
55/// A `Document` represents the root of an `AsciiDoc` document.
56#[derive(Default, Debug, PartialEq)]
57#[non_exhaustive]
58pub struct Document<'a> {
59    pub header: Option<Header<'a>>,
60    /// Effective end-of-header values. Later accepted changes are ordered
61    /// [`Block::DocumentAttribute`] nodes and do not update this snapshot.
62    pub attributes: DocumentAttributes<'a>,
63    pub blocks: Vec<Block<'a>>,
64    pub footnotes: Vec<Footnote<'a>>,
65    pub toc_entries: Vec<TocEntry<'a>>,
66    /// Cross-reference targets keyed by id, for O(1) `<<id>>` resolution.
67    /// Covers sections, blocks, explicit inline anchors, and IDs on formatted
68    /// inline spans; collected during parsing. `toc_entries` remains the ordered
69    /// list used to render the table of contents. An empty key supplies the
70    /// document title or label when a reference addresses the document top.
71    /// Like `toc_entries` and
72    /// `footnotes`, this is not serialized.
73    pub references: HashMap<&'a str, Reference<'a>>,
74    pub location: Location,
75}
76
77impl Document<'_> {
78    /// Reassign section numbers from the current section tree.
79    ///
80    /// Parsing does this automatically. Call it after changing the section tree or a
81    /// section's numbering policy. Existing cross-reference targets and
82    /// table-of-contents entries receive the new numbers, but this method does not
83    /// add, remove, or reorder those entries.
84    ///
85    /// Automatic cross-references select the signifier for the target's current
86    /// category from their original source-position attributes. Explicit
87    /// [`CrossReference::set_signifier`] overrides are preserved.
88    pub fn renumber_sections(&mut self) {
89        let is_book = matches!(
90            self.attributes.get("doctype"),
91            Some(value) if value.as_str() == Some("book")
92        );
93        section::renumber_sections(
94            &mut self.blocks,
95            &mut self.toc_entries,
96            &mut self.references,
97            is_book,
98        );
99        let section_names = self
100            .references
101            .iter()
102            .filter_map(|(target, reference)| Some((*target, reference.section.as_ref()?.name)))
103            .collect::<HashMap<_, _>>();
104        crate::grammar::walk_document_inline_nodes_mut(self, &mut |inline| {
105            if let InlineNode::Macro(InlineMacro::CrossReference(xref)) = inline {
106                xref.refresh_signifier(section_names.get(xref.target).copied());
107            }
108        });
109    }
110
111    /// Reassign every automatic caption ordinal, numbering a block's content before the block
112    /// itself as asciidoctor does.
113    ///
114    /// Parsing does this already. Call it after reordering or removing blocks, changing titles,
115    /// or inserting a block whose `metadata.caption` has been set. A caller-built block whose
116    /// `metadata.caption` is `None` stays unnumbered: nothing knows the document attributes at
117    /// a source position it never had, so a converter's own fallback owns it.
118    pub fn renumber_captions(&mut self) {
119        caption::renumber_captions(&mut self.blocks);
120    }
121
122    /// The highest caption ordinal assigned to any block of `kind`, or 0 when none carries one.
123    ///
124    /// A converter that renders a title the parser never numbered — one on a caller-built
125    /// block, or added after parsing — should start its own counter here, so its numbers
126    /// cannot collide with the parser's.
127    #[must_use]
128    pub fn highest_caption_number(&self, kind: CaptionKind) -> u32 {
129        caption::highest_caption_number(&self.blocks, kind)
130    }
131}
132
133/// A `Header` represents the header of a document.
134///
135/// The header contains the title, subtitle, authors, and optional metadata
136/// (such as ID and roles) that can be applied to the document title.
137#[derive(Debug, PartialEq, Serialize)]
138#[non_exhaustive]
139pub struct Header<'a> {
140    #[serde(skip_serializing_if = "BlockMetadata::is_default")]
141    pub metadata: BlockMetadata<'a>,
142    #[serde(skip_serializing_if = "Title::is_empty")]
143    pub title: Title<'a>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub subtitle: Option<Subtitle<'a>>,
146    #[serde(skip_serializing_if = "Vec::is_empty")]
147    pub authors: Vec<Author<'a>>,
148    pub location: Location,
149}
150
151/// An `Author` represents the author of a document.
152#[derive(Debug, PartialEq, Serialize)]
153#[non_exhaustive]
154pub struct Author<'a> {
155    #[serde(rename = "firstname")]
156    pub first_name: &'a str,
157    #[serde(skip_serializing_if = "Option::is_none", rename = "middlename")]
158    pub middle_name: Option<&'a str>,
159    #[serde(rename = "lastname")]
160    pub last_name: &'a str,
161    pub initials: &'a str,
162    #[serde(skip_serializing_if = "Option::is_none", rename = "address")]
163    pub email: Option<&'a str>,
164}
165
166impl<'a> Header<'a> {
167    /// Create a new header with the given title and location.
168    #[must_use]
169    pub fn new(title: Title<'a>, location: Location) -> Self {
170        Self {
171            metadata: BlockMetadata::default(),
172            title,
173            subtitle: None,
174            authors: Vec::new(),
175            location,
176        }
177    }
178
179    /// Set the metadata.
180    #[must_use]
181    pub fn with_metadata(mut self, metadata: BlockMetadata<'a>) -> Self {
182        self.metadata = metadata;
183        self
184    }
185
186    /// Set the subtitle.
187    #[must_use]
188    pub fn with_subtitle(mut self, subtitle: Subtitle<'a>) -> Self {
189        self.subtitle = Some(subtitle);
190        self
191    }
192
193    /// Set the authors.
194    #[must_use]
195    pub fn with_authors(mut self, authors: Vec<Author<'a>>) -> Self {
196        self.authors = authors;
197        self
198    }
199}
200
201impl<'a> Author<'a> {
202    /// Assemble an author from already-prepared name parts. No normalization
203    /// or allocation happens — callers (tests, external consumers) are
204    /// responsible for providing the display-ready strings they want.
205    #[must_use]
206    pub fn from_parts(
207        first_name: &'a str,
208        middle_name: Option<&'a str>,
209        last_name: &'a str,
210        initials: &'a str,
211    ) -> Self {
212        Self {
213            first_name,
214            middle_name,
215            last_name,
216            initials,
217            email: None,
218        }
219    }
220
221    /// Create a new author with the given names. Arena-allocates any
222    /// underscore-normalized strings and computed initials.
223    #[must_use]
224    pub(crate) fn new(
225        arena: &'a Bump,
226        first_name: &'a str,
227        middle_name: Option<&'a str>,
228        last_name: Option<&'a str>,
229    ) -> Self {
230        let first_processed = first_name.replace('_', " ");
231        let middle_processed = middle_name.map(|m| m.replace('_', " "));
232        let last_processed = last_name.map(|l| l.replace('_', " "));
233
234        let initials = Self::generate_initials(
235            &first_processed,
236            middle_processed.as_deref(),
237            last_processed.as_deref(),
238        );
239
240        Self {
241            first_name: if first_processed == first_name {
242                first_name
243            } else {
244                arena.alloc_str(&first_processed)
245            },
246            middle_name: middle_name
247                .zip(middle_processed.as_ref())
248                .map(|(orig, proc)| {
249                    if proc == orig {
250                        orig
251                    } else {
252                        &*arena.alloc_str(proc)
253                    }
254                }),
255            last_name: last_name
256                .zip(last_processed.as_ref())
257                .map_or("", |(orig, proc)| {
258                    if proc == orig {
259                        orig
260                    } else {
261                        arena.alloc_str(proc)
262                    }
263                }),
264            initials: arena.alloc_str(&initials),
265            email: None,
266        }
267    }
268
269    /// Set the email address.
270    #[must_use]
271    pub fn with_email(mut self, email: &'a str) -> Self {
272        self.email = Some(email);
273        self
274    }
275
276    /// Generate initials from first, optional middle, and last name parts
277    fn generate_initials(first: &str, middle: Option<&str>, last: Option<&str>) -> String {
278        let first_initial = first.chars().next().unwrap_or_default().to_string();
279        let middle_initial = middle
280            .map(|m| m.chars().next().unwrap_or_default().to_string())
281            .unwrap_or_default();
282        let last_initial = last
283            .map(|m| m.chars().next().unwrap_or_default().to_string())
284            .unwrap_or_default();
285        first_initial + &middle_initial + &last_initial
286    }
287}
288
289/// The syntactic form a [`Comment`] originated from.
290///
291/// A `[comment]`-styled `--` block is represented as a
292/// [`DelimitedBlockType::DelimitedComment`] (distinguished from a `////` block
293/// by its retained `comment` style), so it is not covered here.
294#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
295#[serde(rename_all = "snake_case")]
296#[non_exhaustive]
297pub enum CommentKind {
298    /// A `//` single-line comment.
299    #[default]
300    Line,
301    /// A `[comment]`-styled paragraph.
302    Paragraph,
303}
304
305/// A comment in a document that produces no output.
306///
307/// Either a `//` single-line comment or a `[comment]`-styled paragraph; the
308/// [`kind`](Comment::kind) distinguishes them.
309#[derive(Clone, Debug, PartialEq)]
310#[non_exhaustive]
311pub struct Comment<'a> {
312    pub kind: CommentKind,
313    pub content: &'a str,
314    pub location: Location,
315}
316
317/// A `Block` represents a block in a document.
318///
319/// A block is a structural element in a document that can contain other blocks.
320// The variant sizes differ widely by design — `DelimitedBlock`/`Image` are large
321// value types while `PageBreak`/`Comment` are tiny. Boxing the large variants is a
322// separate refactor; the disparity is structural, not a per-node-allocation concern.
323#[allow(clippy::large_enum_variant)]
324#[non_exhaustive]
325#[derive(Clone, Debug, PartialEq, Serialize)]
326#[serde(untagged)]
327pub enum Block<'a> {
328    TableOfContents(TableOfContents<'a>),
329    // TODO(nlopes): we shouldn't have an admonition type here, instead it should be
330    // picked up from the style attribute from the block metadata.
331    //
332    // The main one that would need changing is the Paragraph and the Delimited Example
333    // blocks, where we currently use this but don't need to.
334    Admonition(Admonition<'a>),
335    DiscreteHeader(DiscreteHeader<'a>),
336    DocumentAttribute(DocumentAttribute<'a>),
337    ThematicBreak(ThematicBreak<'a>),
338    PageBreak(PageBreak<'a>),
339    UnorderedList(UnorderedList<'a>),
340    OrderedList(OrderedList<'a>),
341    CalloutList(CalloutList<'a>),
342    DescriptionList(DescriptionList<'a>),
343    Section(Section<'a>),
344    DelimitedBlock(DelimitedBlock<'a>),
345    Paragraph(Paragraph<'a>),
346    Image(Image<'a>),
347    Audio(Audio<'a>),
348    Video(Video<'a>),
349    Comment(Comment<'a>),
350}
351
352impl<'a> Block<'a> {
353    /// The source location of this block.
354    #[must_use]
355    pub fn location(&self) -> &Location {
356        match self {
357            Block::Section(s) => &s.location,
358            Block::Paragraph(p) => &p.location,
359            Block::UnorderedList(l) => &l.location,
360            Block::OrderedList(l) => &l.location,
361            Block::DescriptionList(l) => &l.location,
362            Block::CalloutList(l) => &l.location,
363            Block::DelimitedBlock(d) => &d.location,
364            Block::Admonition(a) => &a.location,
365            Block::TableOfContents(t) => &t.location,
366            Block::DiscreteHeader(h) => &h.location,
367            Block::DocumentAttribute(a) => &a.location,
368            Block::ThematicBreak(tb) => &tb.location,
369            Block::PageBreak(pb) => &pb.location,
370            Block::Image(i) => &i.location,
371            Block::Audio(a) => &a.location,
372            Block::Video(v) => &v.location,
373            Block::Comment(c) => &c.location,
374        }
375    }
376
377    /// This block's metadata, for the blocks that carry any.
378    #[must_use]
379    pub fn metadata(&self) -> Option<&BlockMetadata<'a>> {
380        match self {
381            Block::Section(block) => Some(&block.metadata),
382            Block::DelimitedBlock(block) => Some(&block.metadata),
383            Block::Admonition(block) => Some(&block.metadata),
384            Block::DiscreteHeader(block) => Some(&block.metadata),
385            Block::PageBreak(block) => Some(&block.metadata),
386            Block::Paragraph(block) => Some(&block.metadata),
387            Block::Image(block) => Some(&block.metadata),
388            Block::Audio(block) => Some(&block.metadata),
389            Block::Video(block) => Some(&block.metadata),
390            Block::UnorderedList(block) => Some(&block.metadata),
391            Block::OrderedList(block) => Some(&block.metadata),
392            Block::CalloutList(block) => Some(&block.metadata),
393            Block::DescriptionList(block) => Some(&block.metadata),
394            Block::TableOfContents(block) => Some(&block.metadata),
395            Block::ThematicBreak(_) | Block::DocumentAttribute(_) | Block::Comment(_) => None,
396        }
397    }
398
399    /// This block's metadata, for the blocks that carry any.
400    pub(crate) fn metadata_mut(&mut self) -> Option<&mut BlockMetadata<'a>> {
401        match self {
402            Block::Section(block) => Some(&mut block.metadata),
403            Block::DelimitedBlock(block) => Some(&mut block.metadata),
404            Block::Admonition(block) => Some(&mut block.metadata),
405            Block::DiscreteHeader(block) => Some(&mut block.metadata),
406            Block::PageBreak(block) => Some(&mut block.metadata),
407            Block::Paragraph(block) => Some(&mut block.metadata),
408            Block::Image(block) => Some(&mut block.metadata),
409            Block::Audio(block) => Some(&mut block.metadata),
410            Block::Video(block) => Some(&mut block.metadata),
411            Block::UnorderedList(block) => Some(&mut block.metadata),
412            Block::OrderedList(block) => Some(&mut block.metadata),
413            Block::CalloutList(block) => Some(&mut block.metadata),
414            Block::DescriptionList(block) => Some(&mut block.metadata),
415            Block::TableOfContents(block) => Some(&mut block.metadata),
416            Block::ThematicBreak(_) | Block::DocumentAttribute(_) | Block::Comment(_) => None,
417        }
418    }
419
420    /// The anchor defining this block's id (its cross-reference target), if any:
421    /// the explicit `[#id]` or the first `[[id]]` anchor.
422    pub(crate) fn anchor(&self) -> Option<&Anchor<'a>> {
423        match self {
424            Block::Section(b) => b.metadata.id_anchor(),
425            Block::DelimitedBlock(b) => b.metadata.id_anchor(),
426            Block::Admonition(b) => b.metadata.id_anchor(),
427            Block::DiscreteHeader(b) => b.metadata.id_anchor(),
428            Block::PageBreak(b) => b.metadata.id_anchor(),
429            Block::Paragraph(b) => b.metadata.id_anchor(),
430            Block::Image(b) => b.metadata.id_anchor(),
431            Block::Audio(b) => b.metadata.id_anchor(),
432            Block::Video(b) => b.metadata.id_anchor(),
433            Block::UnorderedList(b) => b.metadata.id_anchor(),
434            Block::OrderedList(b) => b.metadata.id_anchor(),
435            Block::CalloutList(b) => b.metadata.id_anchor(),
436            Block::DescriptionList(b) => b.metadata.id_anchor(),
437            Block::TableOfContents(b) => b.metadata.id_anchor(),
438            Block::ThematicBreak(b) => b.anchors.first(),
439            Block::DocumentAttribute(_) | Block::Comment(_) => None,
440        }
441    }
442
443    /// This block's title, if it has a non-empty one (used as the cross-reference
444    /// text when an `<<id>>` to this block has no explicit label).
445    pub(crate) fn title(&self) -> Option<&Title<'a>> {
446        let title = match self {
447            Block::Section(b) => &b.title,
448            Block::DelimitedBlock(b) => &b.title,
449            Block::Admonition(b) => &b.title,
450            Block::DiscreteHeader(b) => &b.title,
451            Block::PageBreak(b) => &b.title,
452            Block::Paragraph(b) => &b.title,
453            Block::Image(b) => &b.title,
454            Block::Audio(b) => &b.title,
455            Block::Video(b) => &b.title,
456            Block::UnorderedList(b) => &b.title,
457            Block::OrderedList(b) => &b.title,
458            Block::CalloutList(b) => &b.title,
459            Block::DescriptionList(b) => &b.title,
460            Block::ThematicBreak(b) => &b.title,
461            Block::TableOfContents(_) | Block::DocumentAttribute(_) | Block::Comment(_) => {
462                return None;
463            }
464        };
465        (!title.is_empty()).then_some(title)
466    }
467}
468
469impl Block<'_> {
470    /// Mutable access to this block's own location (the post-parse source remap
471    /// pass rewrites it). Counterpart to [`Self::location`].
472    pub(crate) fn location_mut(&mut self) -> &mut Location {
473        match self {
474            Block::Section(s) => &mut s.location,
475            Block::Paragraph(p) => &mut p.location,
476            Block::UnorderedList(l) => &mut l.location,
477            Block::OrderedList(l) => &mut l.location,
478            Block::DescriptionList(l) => &mut l.location,
479            Block::CalloutList(l) => &mut l.location,
480            Block::DelimitedBlock(d) => &mut d.location,
481            Block::Admonition(a) => &mut a.location,
482            Block::TableOfContents(t) => &mut t.location,
483            Block::DiscreteHeader(h) => &mut h.location,
484            Block::DocumentAttribute(a) => &mut a.location,
485            Block::ThematicBreak(tb) => &mut tb.location,
486            Block::PageBreak(pb) => &mut pb.location,
487            Block::Image(i) => &mut i.location,
488            Block::Audio(a) => &mut a.location,
489            Block::Video(v) => &mut v.location,
490            Block::Comment(c) => &mut c.location,
491        }
492    }
493}
494
495/// An accepted document-attribute assignment in the document body.
496#[derive(Clone, PartialEq)]
497#[non_exhaustive]
498pub struct DocumentAttribute<'a> {
499    pub name: AttributeName<'a>,
500    pub location: Location,
501    assignment: DocumentAttributeAssignment<'a>,
502    accepted: bool,
503}
504
505impl<'a> DocumentAttribute<'a> {
506    pub(crate) fn accepted(
507        name: AttributeName<'a>,
508        applied: DocumentAttributeAssignment<'a>,
509        location: Location,
510    ) -> Self {
511        Self {
512            name,
513            location,
514            assignment: applied,
515            accepted: true,
516        }
517    }
518
519    pub(crate) fn rejected(name: AttributeName<'a>, location: Location) -> Self {
520        Self {
521            name,
522            location,
523            assignment: DocumentAttributeAssignment::Unset,
524            accepted: false,
525        }
526    }
527
528    pub(crate) const fn is_accepted(&self) -> bool {
529        self.accepted
530    }
531
532    /// Borrow the accepted set or unset assignment.
533    #[must_use]
534    pub const fn assignment(&self) -> &DocumentAttributeAssignment<'a> {
535        &self.assignment
536    }
537}
538
539impl std::fmt::Debug for DocumentAttribute<'_> {
540    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541        formatter
542            .debug_struct("DocumentAttribute")
543            .field("name", &self.name)
544            .field("assignment", &self.assignment)
545            .field("location", &self.location)
546            .field("accepted", &self.accepted)
547            .finish()
548    }
549}
550
551impl Serialize for DocumentAttribute<'_> {
552    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
553    where
554        S: Serializer,
555    {
556        let mut state = serializer.serialize_map(None)?;
557        state.serialize_entry("name", &self.name)?;
558        state.serialize_entry("type", "attribute")?;
559        state.serialize_entry("value", &self.assignment.serialized_value(false))?;
560        state.serialize_entry("location", &self.location)?;
561        state.end()
562    }
563}
564
565/// A `DiscreteHeader` represents a discrete header in a document.
566///
567/// Discrete headings are useful for making headings inside of other blocks, like a
568/// sidebar.
569#[derive(Clone, Debug, PartialEq)]
570#[non_exhaustive]
571pub struct DiscreteHeader<'a> {
572    pub metadata: BlockMetadata<'a>,
573    pub title: Title<'a>,
574    pub level: u8,
575    pub location: Location,
576}
577
578/// A `ThematicBreak` represents a thematic break in a document.
579#[derive(Clone, Default, Debug, PartialEq)]
580#[non_exhaustive]
581pub struct ThematicBreak<'a> {
582    pub anchors: Vec<Anchor<'a>>,
583    pub title: Title<'a>,
584    pub location: Location,
585}
586
587impl Serialize for ThematicBreak<'_> {
588    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
589    where
590        S: Serializer,
591    {
592        let mut state = serializer.serialize_map(None)?;
593        state.serialize_entry("name", "break")?;
594        state.serialize_entry("type", "block")?;
595        state.serialize_entry("variant", "thematic")?;
596        if !self.anchors.is_empty() {
597            state.serialize_entry("anchors", &self.anchors)?;
598        }
599        if !self.title.is_empty() {
600            state.serialize_entry("title", &self.title)?;
601        }
602        state.serialize_entry("location", &self.location)?;
603        state.end()
604    }
605}
606
607/// A `PageBreak` represents a page break in a document.
608#[derive(Clone, Debug, PartialEq)]
609#[non_exhaustive]
610pub struct PageBreak<'a> {
611    pub title: Title<'a>,
612    pub metadata: BlockMetadata<'a>,
613    pub location: Location,
614}
615
616impl Serialize for PageBreak<'_> {
617    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
618    where
619        S: Serializer,
620    {
621        let mut state = serializer.serialize_map(None)?;
622        state.serialize_entry("name", "break")?;
623        state.serialize_entry("type", "block")?;
624        state.serialize_entry("variant", "page")?;
625        if !self.title.is_empty() {
626            state.serialize_entry("title", &self.title)?;
627        }
628        if !self.metadata.is_default() {
629            state.serialize_entry("metadata", &self.metadata)?;
630        }
631        state.serialize_entry("location", &self.location)?;
632        state.end()
633    }
634}
635
636impl Serialize for Comment<'_> {
637    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
638    where
639        S: Serializer,
640    {
641        let mut state = serializer.serialize_map(None)?;
642        state.serialize_entry("name", "comment")?;
643        state.serialize_entry("type", "block")?;
644        // Omit the default `Line` kind so plain `//` comments serialize unchanged.
645        if self.kind != CommentKind::Line {
646            state.serialize_entry("variant", &self.kind)?;
647        }
648        if !self.content.is_empty() {
649            state.serialize_entry("content", &self.content)?;
650        }
651        state.serialize_entry("location", &self.location)?;
652        state.end()
653    }
654}
655
656/// A `TableOfContents` represents a table of contents block.
657#[derive(Clone, Debug, PartialEq)]
658#[non_exhaustive]
659pub struct TableOfContents<'a> {
660    pub metadata: BlockMetadata<'a>,
661    pub location: Location,
662}
663
664impl Serialize for TableOfContents<'_> {
665    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
666    where
667        S: Serializer,
668    {
669        let mut state = serializer.serialize_map(None)?;
670        state.serialize_entry("name", "toc")?;
671        state.serialize_entry("type", "block")?;
672        if !self.metadata.is_default() {
673            state.serialize_entry("metadata", &self.metadata)?;
674        }
675        state.serialize_entry("location", &self.location)?;
676        state.end()
677    }
678}
679
680/// A `Paragraph` represents a paragraph in a document.
681#[derive(Clone, Debug)]
682#[non_exhaustive]
683pub struct Paragraph<'a> {
684    pub metadata: BlockMetadata<'a>,
685    pub title: Title<'a>,
686    pub content: Vec<InlineNode<'a>>,
687    pub location: Location,
688    pub(crate) source_text: Option<&'a str>,
689}
690
691impl PartialEq for Paragraph<'_> {
692    fn eq(&self, other: &Self) -> bool {
693        self.metadata == other.metadata
694            && self.title == other.title
695            && self.content == other.content
696            && self.location == other.location
697    }
698}
699
700impl<'a> Paragraph<'a> {
701    /// Create a new paragraph with the given content and location.
702    #[must_use]
703    pub fn new(content: Vec<InlineNode<'a>>, location: Location) -> Self {
704        Self {
705            metadata: BlockMetadata::default(),
706            title: Title::default(),
707            content,
708            location,
709            source_text: None,
710        }
711    }
712
713    /// The paragraph body after document preprocessing, before inline substitutions.
714    ///
715    /// Absent for programmatically constructed paragraphs. This text is not part
716    /// of ASG serialization or semantic equality.
717    #[must_use]
718    pub fn source_text(&self) -> Option<&'a str> {
719        self.source_text
720    }
721
722    /// Set the metadata.
723    #[must_use]
724    pub fn with_metadata(mut self, metadata: BlockMetadata<'a>) -> Self {
725        self.metadata = metadata;
726        self
727    }
728
729    /// Set the title.
730    #[must_use]
731    pub fn with_title(mut self, title: Title<'a>) -> Self {
732        self.title = title;
733        self
734    }
735}
736
737/// A `DelimitedBlock` represents a delimited block in a document.
738#[derive(Clone, Debug)]
739#[non_exhaustive]
740pub struct DelimitedBlock<'a> {
741    pub metadata: BlockMetadata<'a>,
742    pub inner: DelimitedBlockType<'a>,
743    pub delimiter: &'a str,
744    pub title: Title<'a>,
745    pub location: Location,
746    pub open_delimiter_location: Option<Location>,
747    pub close_delimiter_location: Option<Location>,
748    pub(crate) source_text: Option<&'a str>,
749}
750
751impl PartialEq for DelimitedBlock<'_> {
752    fn eq(&self, other: &Self) -> bool {
753        self.metadata == other.metadata
754            && self.inner == other.inner
755            && self.delimiter == other.delimiter
756            && self.title == other.title
757            && self.location == other.location
758            && self.open_delimiter_location == other.open_delimiter_location
759            && self.close_delimiter_location == other.close_delimiter_location
760    }
761}
762
763impl<'a> DelimitedBlock<'a> {
764    /// Create a new delimited block.
765    #[must_use]
766    pub fn new(inner: DelimitedBlockType<'a>, delimiter: &'a str, location: Location) -> Self {
767        Self {
768            metadata: BlockMetadata::default(),
769            inner,
770            delimiter,
771            title: Title::default(),
772            location,
773            open_delimiter_location: None,
774            close_delimiter_location: None,
775            source_text: None,
776        }
777    }
778
779    /// The body after document preprocessing, before inline substitutions.
780    ///
781    /// Delimiters and metadata are excluded; a newline before the closing delimiter
782    /// is retained. Absent for programmatically constructed or synthetic blocks.
783    /// This text is not part of ASG serialization or semantic equality.
784    #[must_use]
785    pub fn source_text(&self) -> Option<&'a str> {
786        self.source_text
787    }
788
789    /// Set the metadata.
790    #[must_use]
791    pub fn with_metadata(mut self, metadata: BlockMetadata<'a>) -> Self {
792        self.metadata = metadata;
793        self
794    }
795
796    /// Set the title.
797    #[must_use]
798    pub fn with_title(mut self, title: Title<'a>) -> Self {
799        self.title = title;
800        self
801    }
802}
803
804/// Notation type for mathematical expressions.
805#[derive(Clone, Debug, PartialEq, Serialize)]
806#[serde(rename_all = "lowercase")]
807pub enum StemNotation {
808    Latexmath,
809    Asciimath,
810}
811
812impl Display for StemNotation {
813    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
814        match self {
815            StemNotation::Latexmath => write!(f, "latexmath"),
816            StemNotation::Asciimath => write!(f, "asciimath"),
817        }
818    }
819}
820
821impl FromStr for StemNotation {
822    type Err = String;
823
824    fn from_str(s: &str) -> Result<Self, Self::Err> {
825        match s {
826            "latexmath" => Ok(Self::Latexmath),
827            "asciimath" => Ok(Self::Asciimath),
828            _ => Err(format!("unknown stem notation: {s}")),
829        }
830    }
831}
832
833/// Content of a stem block with math notation.
834#[derive(Clone, Debug, PartialEq, Serialize)]
835#[non_exhaustive]
836pub struct StemContent<'a> {
837    pub content: &'a str,
838    pub notation: StemNotation,
839}
840
841impl<'a> StemContent<'a> {
842    /// Create a new stem content with the given content and notation.
843    #[must_use]
844    pub fn new(content: &'a str, notation: StemNotation) -> Self {
845        Self { content, notation }
846    }
847}
848
849/// The inner content type of a delimited block.
850///
851/// Each variant wraps the content appropriate for that block type:
852/// - **Verbatim content** (`Vec<InlineNode>`): `DelimitedListing`, `DelimitedLiteral`,
853///   `DelimitedPass`, `DelimitedVerse`, `DelimitedComment` - preserves whitespace/formatting
854/// - **Compound content** (`Vec<Block>`): `DelimitedExample`, `DelimitedOpen`,
855///   `DelimitedSidebar`, `DelimitedQuote` - can contain nested blocks
856/// - **Structured content**: `DelimitedTable(Table)`, `DelimitedStem(StemContent)`
857///
858/// # Accessing Content
859///
860/// Use pattern matching to extract the inner content:
861///
862/// ```
863/// # use acdc_parser::{DelimitedBlockType, Block, InlineNode};
864/// fn process_block(block_type: &DelimitedBlockType) {
865///     match block_type {
866///         DelimitedBlockType::DelimitedListing(inlines) => {
867///             // Handle listing content (source code, etc.)
868///         }
869///         DelimitedBlockType::DelimitedExample(blocks) => {
870///             // Handle example with nested blocks
871///         }
872///         DelimitedBlockType::DelimitedTable(table) => {
873///             // Access table.rows, table.header, etc.
874///         }
875///         // ... other variants
876///         _ => {}
877///     }
878/// }
879/// ```
880///
881/// # Note on Variant Names
882///
883/// Variants are prefixed with `Delimited` to disambiguate from potential future
884/// non-delimited block types and to make pattern matching more explicit.
885#[non_exhaustive]
886#[derive(Clone, Debug, PartialEq, Serialize)]
887#[serde(untagged)]
888pub enum DelimitedBlockType<'a> {
889    /// Comment block content (not rendered in output).
890    DelimitedComment(Vec<InlineNode<'a>>),
891    /// Example block - can contain nested blocks, admonitions, etc.
892    DelimitedExample(Vec<Block<'a>>),
893    /// Listing block - typically source code with syntax highlighting.
894    DelimitedListing(Vec<InlineNode<'a>>),
895    /// Literal block - preformatted text rendered verbatim.
896    DelimitedLiteral(Vec<InlineNode<'a>>),
897    /// Open block - generic container for nested blocks.
898    DelimitedOpen(Vec<Block<'a>>),
899    /// Sidebar block - supplementary content in a styled container.
900    DelimitedSidebar(Vec<Block<'a>>),
901    /// Table block - structured tabular data.
902    DelimitedTable(Table<'a>),
903    /// Passthrough block - content passed directly to output without processing.
904    DelimitedPass(Vec<InlineNode<'a>>),
905    /// Quote block - blockquote with optional attribution.
906    DelimitedQuote(Vec<Block<'a>>),
907    /// Verse block - poetry/lyrics preserving line breaks.
908    DelimitedVerse(Vec<InlineNode<'a>>),
909    /// STEM (math) block - LaTeX or `AsciiMath` notation.
910    DelimitedStem(StemContent<'a>),
911}
912
913impl DelimitedBlockType<'_> {
914    fn name(&self) -> &'static str {
915        match self {
916            DelimitedBlockType::DelimitedComment(_) => "comment",
917            DelimitedBlockType::DelimitedExample(_) => "example",
918            DelimitedBlockType::DelimitedListing(_) => "listing",
919            DelimitedBlockType::DelimitedLiteral(_) => "literal",
920            DelimitedBlockType::DelimitedOpen(_) => "open",
921            DelimitedBlockType::DelimitedSidebar(_) => "sidebar",
922            DelimitedBlockType::DelimitedTable(_) => "table",
923            DelimitedBlockType::DelimitedPass(_) => "pass",
924            DelimitedBlockType::DelimitedQuote(_) => "quote",
925            DelimitedBlockType::DelimitedVerse(_) => "verse",
926            DelimitedBlockType::DelimitedStem(_) => "stem",
927        }
928    }
929}
930
931impl Serialize for Document<'_> {
932    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
933    where
934        S: Serializer,
935    {
936        let mut state = serializer.serialize_map(None)?;
937        state.serialize_entry("name", "document")?;
938        state.serialize_entry("type", "block")?;
939        if let Some(header) = &self.header {
940            state.serialize_entry("header", header)?;
941            // We serialize the attributes even if they're empty because that's what the
942            // TCK expects (odd but true)
943            state.serialize_entry("attributes", &self.attributes)?;
944        } else if !self.attributes.serialization_is_empty() {
945            state.serialize_entry("attributes", &self.attributes)?;
946        }
947        if !self.blocks.is_empty() {
948            state.serialize_entry("blocks", &self.blocks)?;
949        }
950        state.serialize_entry("location", &self.location)?;
951        state.end()
952    }
953}
954
955impl Serialize for DelimitedBlock<'_> {
956    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
957    where
958        S: Serializer,
959    {
960        let mut state = serializer.serialize_map(None)?;
961        state.serialize_entry("name", self.inner.name())?;
962        state.serialize_entry("type", "block")?;
963        state.serialize_entry("form", "delimited")?;
964        state.serialize_entry("delimiter", &self.delimiter)?;
965        if !self.metadata.is_default() {
966            state.serialize_entry("metadata", &self.metadata)?;
967        }
968
969        match &self.inner {
970            DelimitedBlockType::DelimitedStem(stem) => {
971                state.serialize_entry("content", &stem.content)?;
972                state.serialize_entry("notation", &stem.notation)?;
973            }
974            DelimitedBlockType::DelimitedListing(inner)
975            | DelimitedBlockType::DelimitedLiteral(inner)
976            | DelimitedBlockType::DelimitedPass(inner)
977            | DelimitedBlockType::DelimitedVerse(inner) => {
978                state.serialize_entry("inlines", &inner)?;
979            }
980            DelimitedBlockType::DelimitedTable(inner) => {
981                state.serialize_entry("content", &inner)?;
982            }
983            inner @ (DelimitedBlockType::DelimitedComment(_)
984            | DelimitedBlockType::DelimitedExample(_)
985            | DelimitedBlockType::DelimitedOpen(_)
986            | DelimitedBlockType::DelimitedQuote(_)
987            | DelimitedBlockType::DelimitedSidebar(_)) => {
988                state.serialize_entry("blocks", &inner)?;
989            }
990        }
991        if !self.title.is_empty() {
992            state.serialize_entry("title", &self.title)?;
993        }
994        state.serialize_entry("location", &self.location)?;
995        state.end()
996    }
997}
998
999impl Serialize for DiscreteHeader<'_> {
1000    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1001    where
1002        S: Serializer,
1003    {
1004        let mut state = serializer.serialize_map(None)?;
1005        state.serialize_entry("name", "heading")?;
1006        state.serialize_entry("type", "block")?;
1007        if !self.title.is_empty() {
1008            state.serialize_entry("title", &self.title)?;
1009        }
1010        state.serialize_entry("level", &self.level)?;
1011        if !self.metadata.is_default() {
1012            state.serialize_entry("metadata", &self.metadata)?;
1013        }
1014        state.serialize_entry("location", &self.location)?;
1015        state.end()
1016    }
1017}
1018
1019impl Serialize for Paragraph<'_> {
1020    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1021    where
1022        S: Serializer,
1023    {
1024        let mut state = serializer.serialize_map(None)?;
1025        state.serialize_entry("name", "paragraph")?;
1026        state.serialize_entry("type", "block")?;
1027        if !self.title.is_empty() {
1028            state.serialize_entry("title", &self.title)?;
1029        }
1030        state.serialize_entry("inlines", &self.content)?;
1031        if !self.metadata.is_default() {
1032            state.serialize_entry("metadata", &self.metadata)?;
1033        }
1034        state.serialize_entry("location", &self.location)?;
1035        state.end()
1036    }
1037}