Skip to main content

acdc_parser/model/
section.rs

1use std::{
2    borrow::Cow,
3    collections::{HashMap, VecDeque},
4    fmt::Display,
5    sync::Arc,
6};
7
8use bumpalo::Bump;
9use serde::ser::{Serialize, SerializeMap, Serializer};
10
11use crate::{
12    Block, BlockMetadata, ColumnStyle, DelimitedBlockType, DocumentAttributes, InlineMacro,
13    InlineNode, Location, MAX_SECTION_LEVELS, Reference, Table, TocEntry,
14    model::{DocumentAttributeStatus, SectionReference},
15};
16
17use super::title::Title;
18
19/// A `SectionLevel` represents a section depth in a document.
20pub type SectionLevel = u8;
21const DEFAULT_NUMBERED_SECTION_LEVELS: SectionLevel = 3;
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub(super) struct SectionNumber(Arc<str>);
25
26impl SectionNumber {
27    fn new(number: String) -> Self {
28        Self(number.into())
29    }
30
31    pub(super) fn as_str(&self) -> &str {
32        &self.0
33    }
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub(crate) struct SectionNumbering {
38    enabled: bool,
39    max_level: SectionLevel,
40    number_all: bool,
41    partnums: bool,
42    number: Option<SectionNumber>,
43}
44
45impl SectionNumbering {
46    pub(crate) fn from_attributes(attributes: &DocumentAttributes<'_>) -> Self {
47        let numbering = match attributes.status("sectnums") {
48            DocumentAttributeStatus::Absent => attributes.status("numbered"),
49            status @ (DocumentAttributeStatus::Set(_) | DocumentAttributeStatus::Unset) => status,
50        };
51        let enabled = matches!(&numbering, DocumentAttributeStatus::Set(_));
52        let max_level = attributes
53            .text("sectnumlevels")
54            .and_then(|value| value.parse().ok())
55            .unwrap_or(DEFAULT_NUMBERED_SECTION_LEVELS)
56            .min(MAX_SECTION_LEVELS);
57        let number_all = matches!(
58            &numbering,
59            DocumentAttributeStatus::Set(value) if value.as_str() == Some("all")
60        );
61        let partnums = attributes.contains_key("partnums");
62        Self {
63            enabled,
64            max_level,
65            number_all,
66            partnums,
67            number: None,
68        }
69    }
70
71    const fn disabled() -> Self {
72        Self {
73            enabled: false,
74            max_level: DEFAULT_NUMBERED_SECTION_LEVELS,
75            number_all: false,
76            partnums: false,
77            number: None,
78        }
79    }
80
81    const fn explicitly_enabled() -> Self {
82        Self {
83            enabled: true,
84            max_level: MAX_SECTION_LEVELS,
85            number_all: true,
86            partnums: true,
87            number: None,
88        }
89    }
90}
91
92/// The structural category of a section.
93///
94/// `AsciiDoc` designates certain section styles as *special sections* — built-in
95/// styles for specialized front matter and back matter (preface, glossary, …).
96/// `SectionKind` captures that category, derived from the section's style;
97/// `Normal` is any ordinary (non-special) section.
98///
99/// This is a structural classification. Section numbering uses it to apply the
100/// special-section rules, and converters use it for presentation.
101///
102/// `#[non_exhaustive]` so further kinds (e.g. `partintro`, `acknowledgments`)
103/// can be added without breaking downstream matches.
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105#[non_exhaustive]
106pub enum SectionKind {
107    /// An ordinary section (the default).
108    #[default]
109    Normal,
110    Preface,
111    Abstract,
112    Dedication,
113    Colophon,
114    Appendix,
115    Glossary,
116    Bibliography,
117    Index,
118}
119
120impl SectionKind {
121    /// Classify a section from its block style (e.g. `[preface]`). Unknown or
122    /// absent styles are `Normal`.
123    #[must_use]
124    pub fn from_style(style: Option<&str>) -> Self {
125        match style {
126            Some("preface") => SectionKind::Preface,
127            Some("abstract") => SectionKind::Abstract,
128            Some("dedication") => SectionKind::Dedication,
129            Some("colophon") => SectionKind::Colophon,
130            Some("appendix") => SectionKind::Appendix,
131            Some("glossary") => SectionKind::Glossary,
132            Some("bibliography") => SectionKind::Bibliography,
133            Some("index") => SectionKind::Index,
134            _ => SectionKind::Normal,
135        }
136    }
137
138    /// The block style string this kind corresponds to (e.g. `"preface"`), or
139    /// `None` for `Normal`. Inverse of [`from_style`](Self::from_style).
140    #[must_use]
141    pub fn as_style(self) -> Option<&'static str> {
142        match self {
143            SectionKind::Normal => None,
144            SectionKind::Preface => Some("preface"),
145            SectionKind::Abstract => Some("abstract"),
146            SectionKind::Dedication => Some("dedication"),
147            SectionKind::Colophon => Some("colophon"),
148            SectionKind::Appendix => Some("appendix"),
149            SectionKind::Glossary => Some("glossary"),
150            SectionKind::Bibliography => Some("bibliography"),
151            SectionKind::Index => Some("index"),
152        }
153    }
154
155    /// Whether this is a special (front/back-matter) section. True for every
156    /// kind except `Normal`. Structural only — implies nothing about rendering.
157    #[must_use]
158    pub fn is_special(self) -> bool {
159        !matches!(self, SectionKind::Normal)
160    }
161}
162
163/// A `Section` represents a section in a document.
164#[derive(Clone, Debug, PartialEq)]
165#[non_exhaustive]
166pub struct Section<'a> {
167    pub metadata: BlockMetadata<'a>,
168    pub title: Title<'a>,
169    pub level: SectionLevel,
170    pub content: Vec<Block<'a>>,
171    /// The section's structural category (special-section style, or `Normal`).
172    pub kind: SectionKind,
173    numbering: SectionNumbering,
174    pub(crate) id: Option<&'a str>,
175    // Retained until the reference catalog assigns the section's ID.
176    pub(crate) reference_text: Option<&'a str>,
177    pub location: Location,
178}
179
180impl<'a> Section<'a> {
181    pub(crate) fn parsed(
182        metadata: BlockMetadata<'a>,
183        title: Title<'a>,
184        level: SectionLevel,
185        content: Vec<Block<'a>>,
186        kind: SectionKind,
187        numbering: SectionNumbering,
188        location: Location,
189    ) -> Self {
190        Self {
191            metadata,
192            title,
193            level,
194            content,
195            kind,
196            numbering,
197            id: None,
198            reference_text: None,
199            location,
200        }
201    }
202
203    /// Create a new section with the given title, level, content, and location.
204    #[must_use]
205    pub fn new(
206        title: Title<'a>,
207        level: SectionLevel,
208        content: Vec<Block<'a>>,
209        location: Location,
210    ) -> Self {
211        Self {
212            metadata: BlockMetadata::default(),
213            title,
214            level,
215            content,
216            kind: SectionKind::Normal,
217            numbering: SectionNumbering::disabled(),
218            id: None,
219            reference_text: None,
220            location,
221        }
222    }
223
224    /// Set the metadata.
225    #[must_use]
226    pub fn with_metadata(mut self, metadata: BlockMetadata<'a>) -> Self {
227        self.metadata = metadata;
228        self.id = None;
229        self
230    }
231
232    /// Return the explicit or parser-assigned section ID.
233    ///
234    /// Parsed sections avoid collisions with preceding targets when generating IDs.
235    /// Caller-created sections use the title-derived ID without collision checks.
236    #[must_use]
237    pub fn id(&self) -> Cow<'a, str> {
238        Self::explicit_id(&self.metadata).or(self.id).map_or_else(
239            || Cow::Owned(Self::generate_id_string(&self.metadata, &self.title)),
240            Cow::Borrowed,
241        )
242    }
243
244    /// Include or exclude this section from automatic numbering.
245    ///
246    /// [`Section::new`] disables numbering. Call
247    /// [`Document::renumber_sections`](super::Document::renumber_sections) after changing
248    /// this policy on a section in a document.
249    #[must_use]
250    pub fn with_numbering(mut self, enabled: bool) -> Self {
251        self.numbering = if enabled {
252            SectionNumbering::explicitly_enabled()
253        } else {
254            SectionNumbering::disabled()
255        };
256        self
257    }
258
259    /// Return the assigned number without presentation punctuation or a signifier.
260    ///
261    /// Examples are `1`, `1.2`, `IV`, and `A.1`.
262    #[must_use]
263    pub fn number(&self) -> Option<&str> {
264        self.numbering.number.as_ref().map(SectionNumber::as_str)
265    }
266}
267
268#[derive(Default)]
269struct NumberingState {
270    counters: [usize; MAX_SECTION_LEVELS as usize + 1],
271    part_counter: usize,
272    appendix_counter: usize,
273    appendix_letter: Option<char>,
274    base_level: Option<SectionLevel>,
275}
276
277impl NumberingState {
278    fn nested_document() -> Self {
279        Self {
280            base_level: Some(0),
281            ..Self::default()
282        }
283    }
284
285    fn numbering_level(&mut self, source_level: SectionLevel, kind: SectionKind) -> SectionLevel {
286        let level = effective_numbering_level(source_level, kind);
287        let Some(base) = &mut self.base_level else {
288            return level;
289        };
290        if *base == 0 {
291            *base = level;
292        }
293        level.saturating_sub(*base).saturating_add(1)
294    }
295
296    fn next_part(&mut self) -> SectionNumber {
297        self.part_counter = self.part_counter.saturating_add(1);
298        SectionNumber::new(to_upper_roman(self.part_counter))
299    }
300
301    fn next_appendix(&mut self) -> SectionNumber {
302        let index = self.appendix_counter;
303        self.appendix_counter = self.appendix_counter.saturating_add(1);
304        let letter = char::from(b'A' + u8::try_from(index).unwrap_or(25).min(25));
305        self.appendix_letter = Some(letter);
306        for counter in self.counters.iter_mut().skip(1) {
307            *counter = 0;
308        }
309        SectionNumber::new(letter.to_string())
310    }
311
312    fn next_section(&mut self, level: SectionLevel) -> Option<SectionNumber> {
313        if level == 0 || level > MAX_SECTION_LEVELS {
314            return None;
315        }
316        if level == 1 {
317            self.appendix_letter = None;
318        }
319
320        let level_index = usize::from(level - 1);
321        let counter = self.counters.get_mut(level_index)?;
322        *counter = counter.saturating_add(1);
323        for counter in self.counters.iter_mut().skip(level_index + 1) {
324            *counter = 0;
325        }
326
327        let mut number = String::new();
328        if let Some(letter) = self.appendix_letter {
329            number.push(letter);
330            for component in self.counters.get(1..=level_index)? {
331                use std::fmt::Write as _;
332                let _ = write!(number, ".{component}");
333            }
334        } else {
335            for (index, component) in self.counters.get(..=level_index)?.iter().enumerate() {
336                use std::fmt::Write as _;
337                if index > 0 {
338                    number.push('.');
339                }
340                let _ = write!(number, "{component}");
341            }
342        }
343        Some(SectionNumber::new(number))
344    }
345}
346
347/// Reassign numbers after AST mutation, matching existing TOC entries by section ID.
348pub(crate) fn renumber_sections<'a>(
349    blocks: &mut [Block<'a>],
350    toc_entries: &mut [TocEntry<'a>],
351    references: &mut HashMap<&'a str, Reference<'a>>,
352    is_book: bool,
353) {
354    let mut state = NumberingState::default();
355    let mut toc_numbers: HashMap<String, VecDeque<Option<SectionNumber>>> =
356        HashMap::with_capacity(toc_entries.len());
357    let mut record_number =
358        |section: &Section<'a>, numbers: SectionNumbers, name, record_toc: bool| {
359            update_section_reference(section, name, &numbers, references);
360            if record_toc {
361                toc_numbers
362                    .entry(section.id().into_owned())
363                    .or_default()
364                    .push_back(numbers.shown);
365            }
366        };
367    renumber_blocks(blocks, &mut state, is_book, false, true, &mut record_number);
368    for entry in toc_entries {
369        let number = toc_numbers
370            .get_mut(entry.id)
371            .and_then(VecDeque::pop_front)
372            .unwrap_or_default();
373        entry.set_number(number);
374    }
375}
376
377/// The two numbers a section can have.
378///
379/// They are the same number except past `sectnumlevels`, where Asciidoctor
380/// prints the heading with no number but still numbers a cross-reference to
381/// it: with `:sectnumlevels: 1`, a level-2 heading reads `Sub part` while
382/// `<<sub>>` reads `Section 1.1`. Keeping them apart, and named, is what lets
383/// the table of contents follow the heading and the reference catalog follow
384/// Asciidoctor.
385#[derive(Clone, Debug, Default)]
386struct SectionNumbers {
387    /// The number printed in the heading and the table of contents.
388    shown: Option<SectionNumber>,
389    /// The number a cross-reference to the section quotes.
390    reference: Option<SectionNumber>,
391}
392
393/// The name Asciidoctor gives a section in a cross-reference: the special
394/// section's style, or `part`, `chapter` or `section` by level. It selects the
395/// `<name>-refsig` attribute and whether a `full` reference emphasises the title.
396pub(crate) fn reference_name(kind: SectionKind, level: u8, is_book: bool) -> &'static str {
397    kind.as_style().unwrap_or(match level {
398        0 => "part",
399        1 if is_book => "chapter",
400        _ => "section",
401    })
402}
403
404fn update_section_reference(
405    section: &Section<'_>,
406    name: &'static str,
407    numbers: &SectionNumbers,
408    references: &mut HashMap<&str, Reference<'_>>,
409) {
410    if let Some(reference) = references.get_mut(section.id().as_ref())
411        // A duplicate ID must not replace the first target's section metadata.
412        && reference.location.absolute_start == section.location.absolute_start
413        && reference.location.start == section.location.start
414    {
415        reference.section = Some(SectionReference {
416            name,
417            number: numbers.reference.clone(),
418        });
419    }
420}
421
422/// Assign numbers after parsing, matching ordered TOC entries by source location.
423pub(crate) fn number_parsed_sections<'a>(
424    blocks: &mut [Block<'a>],
425    toc_entries: &mut [TocEntry<'a>],
426    references: &mut HashMap<&'a str, Reference<'a>>,
427    is_book: bool,
428) {
429    let mut state = NumberingState::default();
430    let mut toc_index = 0;
431    let mut record_number =
432        |section: &Section<'a>, numbers: SectionNumbers, name, record_toc: bool| {
433            update_section_reference(section, name, &numbers, references);
434            if !record_toc {
435                return;
436            }
437            let Some(remaining_entries) = toc_entries.get(toc_index..) else {
438                return;
439            };
440            let Some(relative_index) = remaining_entries
441                .iter()
442                .position(|entry| toc_entry_matches(section, entry))
443            else {
444                return;
445            };
446            toc_index += relative_index;
447            if let Some(entry) = toc_entries.get_mut(toc_index) {
448                if let Some(id) = section.id {
449                    entry.id = id;
450                }
451                entry.set_number(numbers.shown);
452                toc_index += 1;
453            }
454        };
455    renumber_blocks(blocks, &mut state, is_book, false, true, &mut record_number);
456}
457
458fn toc_entry_matches(section: &Section<'_>, entry: &TocEntry<'_>) -> bool {
459    entry.level == section.level
460        && entry.kind == section.kind
461        && entry.title == section.title
462        && section.location.absolute_start <= entry.location.absolute_start
463        && section.location.absolute_end >= entry.location.absolute_end
464}
465
466fn renumber_blocks<'a, F>(
467    blocks: &mut [Block<'a>],
468    state: &mut NumberingState,
469    mut is_book: bool,
470    suppressed: bool,
471    record_toc: bool,
472    record_number: &mut F,
473) where
474    F: FnMut(&Section<'a>, SectionNumbers, &'static str, bool),
475{
476    for block in blocks {
477        match block {
478            Block::Section(section) => {
479                renumber_section(
480                    section,
481                    state,
482                    is_book,
483                    suppressed,
484                    record_toc,
485                    record_number,
486                );
487            }
488            Block::Admonition(admonition) => renumber_blocks(
489                &mut admonition.blocks,
490                state,
491                is_book,
492                suppressed,
493                record_toc,
494                record_number,
495            ),
496            Block::UnorderedList(list) => {
497                for item in &mut list.items {
498                    renumber_blocks(
499                        &mut item.blocks,
500                        state,
501                        is_book,
502                        suppressed,
503                        record_toc,
504                        record_number,
505                    );
506                }
507            }
508            Block::OrderedList(list) => {
509                for item in &mut list.items {
510                    renumber_blocks(
511                        &mut item.blocks,
512                        state,
513                        is_book,
514                        suppressed,
515                        record_toc,
516                        record_number,
517                    );
518                }
519            }
520            Block::CalloutList(list) => {
521                for item in &mut list.items {
522                    renumber_blocks(
523                        &mut item.blocks,
524                        state,
525                        is_book,
526                        suppressed,
527                        record_toc,
528                        record_number,
529                    );
530                }
531            }
532            Block::DescriptionList(list) => {
533                for item in &mut list.items {
534                    renumber_blocks(
535                        &mut item.description,
536                        state,
537                        is_book,
538                        suppressed,
539                        record_toc,
540                        record_number,
541                    );
542                }
543            }
544            Block::DelimitedBlock(delimited) => renumber_delimited(
545                &mut delimited.inner,
546                state,
547                is_book,
548                suppressed,
549                record_toc,
550                record_number,
551            ),
552            Block::DocumentAttribute(attribute) => {
553                if attribute.is_accepted() && attribute.name == "doctype" {
554                    is_book = attribute
555                        .assignment()
556                        .value()
557                        .and_then(|value| value.as_str())
558                        == Some("book");
559                }
560            }
561            Block::Paragraph(_)
562            | Block::DiscreteHeader(_)
563            | Block::PageBreak(_)
564            | Block::ThematicBreak(_)
565            | Block::TableOfContents(_)
566            | Block::Image(_)
567            | Block::Audio(_)
568            | Block::Video(_)
569            | Block::Comment(_) => {}
570        }
571    }
572}
573
574fn renumber_delimited<'a, F>(
575    inner: &mut DelimitedBlockType<'a>,
576    state: &mut NumberingState,
577    is_book: bool,
578    suppressed: bool,
579    record_toc: bool,
580    record_number: &mut F,
581) where
582    F: FnMut(&Section<'a>, SectionNumbers, &'static str, bool),
583{
584    match inner {
585        DelimitedBlockType::DelimitedExample(blocks)
586        | DelimitedBlockType::DelimitedOpen(blocks)
587        | DelimitedBlockType::DelimitedSidebar(blocks)
588        | DelimitedBlockType::DelimitedQuote(blocks) => renumber_blocks(
589            blocks,
590            state,
591            is_book,
592            suppressed,
593            record_toc,
594            record_number,
595        ),
596        DelimitedBlockType::DelimitedTable(table) => {
597            renumber_table(table, state, is_book, suppressed, record_toc, record_number);
598        }
599        DelimitedBlockType::DelimitedListing(_)
600        | DelimitedBlockType::DelimitedLiteral(_)
601        | DelimitedBlockType::DelimitedVerse(_)
602        | DelimitedBlockType::DelimitedPass(_)
603        | DelimitedBlockType::DelimitedComment(_)
604        | DelimitedBlockType::DelimitedStem(_) => {}
605    }
606}
607
608fn renumber_section<'a, F>(
609    section: &mut Section<'a>,
610    state: &mut NumberingState,
611    is_book: bool,
612    suppressed: bool,
613    record_toc: bool,
614    record_number: &mut F,
615) where
616    F: FnMut(&Section<'a>, SectionNumbers, &'static str, bool),
617{
618    let level = state.numbering_level(section.level, section.kind);
619    let participates = section_participates(section, is_book);
620    let eligible = participates && !suppressed;
621    let both = |number: SectionNumber| SectionNumbers {
622        shown: Some(number.clone()),
623        reference: Some(number),
624    };
625    let numbers = if !eligible {
626        SectionNumbers::default()
627    } else if section.kind == SectionKind::Appendix {
628        both(state.next_appendix())
629    } else if section.level == 0 && section.kind == SectionKind::Normal {
630        both(state.next_part())
631    } else {
632        let number = state.next_section(level);
633        // Past `sectnumlevels` the heading shows no number, but the counter
634        // has advanced and a cross-reference still quotes it; see
635        // `SectionNumbers`.
636        SectionNumbers {
637            shown: (level <= section.numbering.max_level)
638                .then(|| number.clone())
639                .flatten(),
640            reference: number,
641        }
642    };
643    section.numbering.number.clone_from(&numbers.shown);
644    record_number(
645        section,
646        numbers,
647        reference_name(section.kind, section.level, is_book),
648        record_toc,
649    );
650
651    let suppress_children = suppressed
652        || (section.kind.is_special() && section.kind != SectionKind::Appendix && !participates);
653    renumber_blocks(
654        &mut section.content,
655        state,
656        is_book,
657        suppress_children,
658        record_toc,
659        record_number,
660    );
661}
662
663fn section_participates(section: &Section<'_>, is_book: bool) -> bool {
664    if section.kind == SectionKind::Appendix {
665        return true;
666    }
667    if section.level == 0 && section.kind == SectionKind::Normal {
668        return is_book && section.numbering.partnums;
669    }
670    if !section.numbering.enabled {
671        return false;
672    }
673    match section.kind {
674        SectionKind::Normal | SectionKind::Appendix => true,
675        SectionKind::Abstract if is_book => true,
676        SectionKind::Preface
677        | SectionKind::Abstract
678        | SectionKind::Dedication
679        | SectionKind::Colophon
680        | SectionKind::Glossary
681        | SectionKind::Bibliography
682        | SectionKind::Index => section.numbering.number_all,
683    }
684}
685
686fn renumber_table<'a, F>(
687    table: &mut Table<'a>,
688    state: &mut NumberingState,
689    is_book: bool,
690    suppressed: bool,
691    record_toc: bool,
692    record_number: &mut F,
693) where
694    F: FnMut(&Section<'a>, SectionNumbers, &'static str, bool),
695{
696    for row in table
697        .header
698        .iter_mut()
699        .chain(table.rows.iter_mut())
700        .chain(table.footer.iter_mut())
701    {
702        for column in &mut row.columns {
703            if column.style == Some(ColumnStyle::AsciiDoc) {
704                let mut nested_state = NumberingState::nested_document();
705                renumber_blocks(
706                    &mut column.content,
707                    &mut nested_state,
708                    // AsciiDoc cells start as articles, even inside a book.
709                    false,
710                    false,
711                    false,
712                    record_number,
713                );
714            } else {
715                renumber_blocks(
716                    &mut column.content,
717                    state,
718                    is_book,
719                    suppressed,
720                    record_toc,
721                    record_number,
722                );
723            }
724        }
725    }
726}
727
728fn effective_numbering_level(level: SectionLevel, kind: SectionKind) -> SectionLevel {
729    if level == 0 && kind.is_special() {
730        1
731    } else {
732        level
733    }
734}
735
736fn to_upper_roman(mut number: usize) -> String {
737    const NUMERALS: &[(usize, &str)] = &[
738        (1000, "M"),
739        (900, "CM"),
740        (500, "D"),
741        (400, "CD"),
742        (100, "C"),
743        (90, "XC"),
744        (50, "L"),
745        (40, "XL"),
746        (10, "X"),
747        (9, "IX"),
748        (5, "V"),
749        (4, "IV"),
750        (1, "I"),
751    ];
752    let mut result = String::new();
753    for &(value, numeral) in NUMERALS {
754        while number >= value {
755            result.push_str(numeral);
756            number -= value;
757        }
758    }
759    result
760}
761
762/// A `SafeId` represents a sanitised ID.
763#[derive(Clone, Debug, PartialEq)]
764#[non_exhaustive]
765pub enum SafeId<'a> {
766    Generated(&'a str),
767    Explicit(&'a str),
768}
769
770impl Display for SafeId<'_> {
771    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
772        match self {
773            // An empty generated slug (a title with no id-able characters)
774            // yields an empty id rather than a bare `_`, matching asciidoctor.
775            SafeId::Generated("") => Ok(()),
776            SafeId::Generated(id) => write!(f, "_{id}"),
777            SafeId::Explicit(id) => write!(f, "{id}"),
778        }
779    }
780}
781
782impl<'a> SafeId<'a> {
783    /// Return the display-equivalent `&'a str` for this safe id without going
784    /// through `format!`/`to_string()`. `Generated` variants are prepended
785    /// with `_` into the arena; an empty generated slug stays empty; `Explicit`
786    /// is returned unchanged.
787    #[must_use]
788    pub(crate) fn as_arena_str(&self, arena: &'a Bump) -> &'a str {
789        match self {
790            SafeId::Generated("") => "",
791            SafeId::Generated(id) => {
792                let mut s = bumpalo::collections::String::new_in(arena);
793                s.push('_');
794                s.push_str(id);
795                s.into_bump_str()
796            }
797            SafeId::Explicit(id) => id,
798        }
799    }
800}
801
802impl<'a> Section<'a> {
803    /// Build a section id from title text: lowercase, non-alphanumerics
804    /// (except whitespace, `-`, `.`, `_`) dropped, survivors joined with `_`,
805    /// consecutive `_` collapsed, leading and trailing `_` trimmed. Single
806    /// pass. The `_` prefix on a generated id is added by `SafeId`, so leading
807    /// separators here must be squeezed away to avoid a doubled `__`.
808    fn id_from_title(title: &[InlineNode<'a>]) -> String {
809        let mut out = String::new();
810        // Start as if a `_` was just emitted so leading separators are dropped.
811        let mut last_was_underscore = true;
812        Self::append_id_from_inlines(title, &mut out, &mut last_was_underscore);
813        while out.ends_with('_') {
814            out.pop();
815        }
816        out
817    }
818
819    fn append_id_from_inlines(
820        inlines: &[InlineNode<'a>],
821        out: &mut String,
822        last_was_underscore: &mut bool,
823    ) {
824        for node in inlines {
825            match node {
826                InlineNode::PlainText(text) => {
827                    Self::append_id_text(text.content, out, last_was_underscore);
828                }
829                InlineNode::RawText(text) => {
830                    Self::append_id_text(text.content, out, last_was_underscore);
831                }
832                InlineNode::VerbatimText(text) => {
833                    Self::append_id_text(text.content, out, last_was_underscore);
834                }
835                InlineNode::BoldText(text) => {
836                    Self::append_id_from_inlines(&text.content, out, last_was_underscore);
837                }
838                InlineNode::ItalicText(text) => {
839                    Self::append_id_from_inlines(&text.content, out, last_was_underscore);
840                }
841                InlineNode::MonospaceText(text) => {
842                    Self::append_id_from_inlines(&text.content, out, last_was_underscore);
843                }
844                InlineNode::HighlightText(text) => {
845                    Self::append_id_from_inlines(&text.content, out, last_was_underscore);
846                }
847                InlineNode::SubscriptText(text) => {
848                    Self::append_id_from_inlines(&text.content, out, last_was_underscore);
849                }
850                InlineNode::SuperscriptText(text) => {
851                    Self::append_id_from_inlines(&text.content, out, last_was_underscore);
852                }
853                InlineNode::CurvedQuotationText(text) => {
854                    Self::append_id_from_inlines(&text.content, out, last_was_underscore);
855                }
856                InlineNode::CurvedApostropheText(text) => {
857                    Self::append_id_from_inlines(&text.content, out, last_was_underscore);
858                }
859                InlineNode::StandaloneCurvedApostrophe(_) => {
860                    Self::append_id_text("'", out, last_was_underscore);
861                }
862                InlineNode::LineBreak(_) => {
863                    Self::append_id_text(" ", out, last_was_underscore);
864                }
865                InlineNode::InlineAnchor(_) => {}
866                InlineNode::Macro(macro_node) => {
867                    Self::append_id_from_macro(macro_node, out, last_was_underscore);
868                }
869                InlineNode::CalloutRef(callout) => {
870                    Self::append_id_text(
871                        &format!("<{}>", callout.number),
872                        out,
873                        last_was_underscore,
874                    );
875                }
876            }
877        }
878    }
879
880    fn append_id_from_macro(
881        macro_node: &InlineMacro<'a>,
882        out: &mut String,
883        last_was_underscore: &mut bool,
884    ) {
885        match macro_node {
886            InlineMacro::Link(link) => {
887                if link.text.is_empty() {
888                    Self::append_id_text(&link.target.to_string(), out, last_was_underscore);
889                } else {
890                    Self::append_id_from_inlines(&link.text, out, last_was_underscore);
891                }
892            }
893            InlineMacro::Url(url) => {
894                if url.text.is_empty() {
895                    Self::append_id_text(&url.target.to_string(), out, last_was_underscore);
896                } else {
897                    Self::append_id_from_inlines(&url.text, out, last_was_underscore);
898                }
899            }
900            InlineMacro::Mailto(mailto) => {
901                if mailto.text.is_empty() {
902                    Self::append_id_text(&mailto.target.to_string(), out, last_was_underscore);
903                } else {
904                    Self::append_id_from_inlines(&mailto.text, out, last_was_underscore);
905                }
906            }
907            InlineMacro::Autolink(autolink) => {
908                Self::append_id_text(&autolink.url.to_string(), out, last_was_underscore);
909            }
910            InlineMacro::CrossReference(xref) => {
911                if xref.text.is_empty() {
912                    Self::append_id_text(xref.target, out, last_was_underscore);
913                } else {
914                    Self::append_id_from_inlines(&xref.text, out, last_was_underscore);
915                }
916            }
917            InlineMacro::IndexTerm(index_term) if index_term.is_visible() => {
918                Self::append_id_from_inlines(index_term.term(), out, last_was_underscore);
919            }
920            InlineMacro::Pass(pass) => {
921                Self::append_id_text(pass.text.unwrap_or_default(), out, last_was_underscore);
922            }
923            InlineMacro::Keyboard(keyboard) => {
924                Self::append_id_text(&keyboard.keys.join("+"), out, last_was_underscore);
925            }
926            InlineMacro::Button(button) => {
927                Self::append_id_text(button.label, out, last_was_underscore);
928            }
929            InlineMacro::Menu(menu) => {
930                Self::append_id_text(&menu.items.join(" > "), out, last_was_underscore);
931            }
932            InlineMacro::Image(_)
933            | InlineMacro::Footnote(_)
934            | InlineMacro::Stem(_)
935            | InlineMacro::Icon(_)
936            | InlineMacro::IndexTerm(_) => {}
937        }
938    }
939
940    fn append_id_text(text: &str, out: &mut String, last_was_underscore: &mut bool) {
941        for c in text.chars() {
942            for c in c.to_lowercase() {
943                let mapped = if c.is_alphanumeric() {
944                    Some(c)
945                } else if c.is_whitespace() || c == '-' || c == '.' || c == '_' {
946                    Some('_')
947                } else {
948                    None
949                };
950                let Some(ch) = mapped else { continue };
951                if ch == '_' {
952                    if !*last_was_underscore {
953                        out.push('_');
954                    }
955                    *last_was_underscore = true;
956                } else {
957                    out.push(ch);
958                    *last_was_underscore = false;
959                }
960            }
961        }
962    }
963
964    /// Pick the explicit id if metadata provides one, else None. Shared by
965    /// the arena-returning and `String`-returning variants below.
966    pub(crate) fn explicit_id(metadata: &BlockMetadata<'a>) -> Option<&'a str> {
967        if let Some(anchor) = &metadata.id {
968            return Some(anchor.id);
969        }
970        metadata.anchors.last().map(|a| a.id)
971    }
972
973    /// Generate a section ID based on its title and metadata.
974    ///
975    /// Checks in order: explicit `metadata.id` (e.g. `[id=foo]`), then the last entry in
976    /// `metadata.anchors` (e.g. `[[foo]]`), otherwise auto-generates one from the title
977    /// and interns it into the supplied arena.
978    #[must_use]
979    pub(crate) fn generate_id(
980        arena: &'a Bump,
981        metadata: &BlockMetadata<'a>,
982        title: &[InlineNode<'a>],
983    ) -> SafeId<'a> {
984        match Self::explicit_id(metadata) {
985            Some(id) => SafeId::Explicit(id),
986            None => SafeId::Generated(arena.alloc_str(&Self::id_from_title(title))),
987        }
988    }
989
990    /// Generate a section ID based on its title and metadata, returning a `String`
991    /// directly.
992    ///
993    /// Returns the `Display`-formatted form (prefixed with `_` for generated IDs)
994    /// without checking for collisions. Use [`Section::id`] for parsed sections.
995    #[must_use]
996    pub fn generate_id_string(metadata: &BlockMetadata<'a>, title: &[InlineNode<'a>]) -> String {
997        if let Some(id) = Self::explicit_id(metadata) {
998            return id.to_string();
999        }
1000        let slug = Self::id_from_title(title);
1001        if slug.is_empty() {
1002            slug
1003        } else {
1004            format!("_{slug}")
1005        }
1006    }
1007}
1008
1009impl Serialize for Section<'_> {
1010    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1011    where
1012        S: Serializer,
1013    {
1014        let mut state = serializer.serialize_map(None)?;
1015        state.serialize_entry("name", "section")?;
1016        state.serialize_entry("type", "block")?;
1017        state.serialize_entry("title", &self.title)?;
1018        state.serialize_entry("level", &self.level)?;
1019        if !self.metadata.is_default() {
1020            state.serialize_entry("metadata", &self.metadata)?;
1021        }
1022        if !self.content.is_empty() {
1023            state.serialize_entry("blocks", &self.content)?;
1024        }
1025        state.serialize_entry("location", &self.location)?;
1026        state.end()
1027    }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use crate::{Anchor, Document, Options, Plain, parse};
1033
1034    use super::*;
1035
1036    #[test]
1037    fn nested_section_references_follow_ast_edits_without_a_toc()
1038    -> Result<(), Box<dyn std::error::Error>> {
1039        let parsed = parse(
1040            include_str!("../../fixtures/tests/xref_nested_sections.adoc"),
1041            &Options::default(),
1042        )?;
1043        let mut document = Document {
1044            attributes: parsed.document().attributes.clone(),
1045            blocks: parsed.document().blocks.clone(),
1046            references: parsed.document().references.clone(),
1047            ..Document::default()
1048        };
1049        let outer = document
1050            .blocks
1051            .iter_mut()
1052            .find_map(|block| {
1053                if let Block::Section(section) = block {
1054                    (section.id() == "outer").then_some(section)
1055                } else {
1056                    None
1057                }
1058            })
1059            .ok_or("missing outer section")?;
1060        let table = outer
1061            .content
1062            .iter_mut()
1063            .find_map(|block| {
1064                if let Block::DelimitedBlock(delimited) = block
1065                    && let DelimitedBlockType::DelimitedTable(table) = &mut delimited.inner
1066                {
1067                    Some(table)
1068                } else {
1069                    None
1070                }
1071            })
1072            .ok_or("missing table")?;
1073        let cell = table
1074            .rows
1075            .first_mut()
1076            .and_then(|row| row.columns.first_mut())
1077            .ok_or("missing cell")?;
1078        let inner = cell
1079            .content
1080            .iter()
1081            .position(|block| matches!(block, Block::Section(section) if section.id() == "inner"))
1082            .ok_or("missing inner section")?;
1083        let second = cell
1084            .content
1085            .iter()
1086            .position(|block| matches!(block, Block::Section(section) if section.id() == "second"))
1087            .ok_or("missing second section")?;
1088        cell.content.swap(inner, second);
1089
1090        // Fixtures cannot exercise AST edits or the nonserialized reference catalog.
1091        document.renumber_sections();
1092        assert_eq!(document.toc_entries, []);
1093        for (id, name, number) in [
1094            ("outer", "chapter", "1"),
1095            ("inner", "section", "2"),
1096            ("deep", "section", "2.1"),
1097            ("second", "section", "1"),
1098            ("sibling", "section", "1"),
1099            ("after", "chapter", "2"),
1100        ] {
1101            let reference = document.references.get(id).ok_or("missing reference")?;
1102            assert_eq!(reference.section_name(), Some(name), "{id}");
1103            assert_eq!(reference.section_number(), Some(number), "{id}");
1104        }
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn nested_duplicate_references_keep_the_first_target_when_renumbered()
1110    -> Result<(), Box<dyn std::error::Error>> {
1111        let parsed = parse(
1112            include_str!("../../fixtures/tests/xref_nested_duplicate_ids.adoc"),
1113            &Options::default(),
1114        )?;
1115        let mut document = Document {
1116            attributes: parsed.document().attributes.clone(),
1117            blocks: parsed.document().blocks.clone(),
1118            toc_entries: parsed.document().toc_entries.clone(),
1119            references: parsed.document().references.clone(),
1120            ..Document::default()
1121        };
1122        document.renumber_sections();
1123        for (id, reference) in &document.references {
1124            let original = parsed
1125                .document()
1126                .references
1127                .get(id)
1128                .ok_or("missing reference")?;
1129            assert_eq!(reference.section_name(), original.section_name(), "{id}");
1130            assert_eq!(
1131                reference.section_number(),
1132                original.section_number(),
1133                "{id}"
1134            );
1135        }
1136        Ok(())
1137    }
1138
1139    #[test]
1140    fn test_id_from_title() {
1141        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
1142            content: "This is a title.",
1143            location: Location::default(),
1144            escaped: false,
1145        })];
1146        assert_eq!(
1147            Section::id_from_title(inlines),
1148            "this_is_a_title".to_string()
1149        );
1150        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
1151            content: "This is a----title.",
1152            location: Location::default(),
1153            escaped: false,
1154        })];
1155        assert_eq!(
1156            Section::id_from_title(inlines),
1157            "this_is_a_title".to_string()
1158        );
1159    }
1160
1161    #[test]
1162    fn test_id_from_title_strips_leading_separators() {
1163        // Leading non-alphanumerics must be squeezed away so the `_` prefix
1164        // added by `SafeId::Generated` does not produce a doubled `__`.
1165        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
1166            content: "--  Specialized Environments",
1167            location: Location::default(),
1168            escaped: false,
1169        })];
1170        assert_eq!(
1171            Section::id_from_title(inlines),
1172            "specialized_environments".to_string()
1173        );
1174        assert_eq!(
1175            Section::generate_id_string(&BlockMetadata::default(), inlines),
1176            "_specialized_environments".to_string()
1177        );
1178    }
1179
1180    #[test]
1181    fn test_id_from_title_all_separators_is_empty() {
1182        // A title with no id-able characters yields an empty id (not a bare
1183        // `_`), matching asciidoctor.
1184        let arena = Bump::new();
1185        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
1186            content: "---",
1187            location: Location::default(),
1188            escaped: false,
1189        })];
1190        assert_eq!(Section::id_from_title(inlines), String::new());
1191        let metadata = BlockMetadata::default();
1192        assert_eq!(
1193            Section::generate_id_string(&metadata, inlines),
1194            String::new()
1195        );
1196        let safe_id = Section::generate_id(&arena, &metadata, inlines);
1197        assert_eq!(safe_id, SafeId::Generated(""));
1198        assert_eq!(safe_id.to_string(), String::new());
1199        assert_eq!(safe_id.as_arena_str(&arena), "");
1200    }
1201
1202    #[test]
1203    fn test_id_from_title_preserves_underscores() {
1204        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
1205            content: "CHART_BOT",
1206            location: Location::default(),
1207            escaped: false,
1208        })];
1209        assert_eq!(Section::id_from_title(inlines), "chart_bot".to_string());
1210        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
1211            content: "haiku_robot",
1212            location: Location::default(),
1213            escaped: false,
1214        })];
1215        assert_eq!(Section::id_from_title(inlines), "haiku_robot".to_string());
1216        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
1217            content: "meme_transcriber",
1218            location: Location::default(),
1219            escaped: false,
1220        })];
1221        assert_eq!(
1222            Section::id_from_title(inlines),
1223            "meme_transcriber".to_string()
1224        );
1225    }
1226
1227    #[test]
1228    fn test_section_generate_id() {
1229        let arena = Bump::new();
1230        let inlines: &[InlineNode] = &[InlineNode::PlainText(Plain {
1231            content: "This is a b__i__g title.",
1232            location: Location::default(),
1233            escaped: false,
1234        })];
1235        // metadata has an empty id
1236        let metadata = BlockMetadata::default();
1237        assert_eq!(
1238            Section::generate_id(&arena, &metadata, inlines),
1239            SafeId::Generated("this_is_a_b_i_g_title")
1240        );
1241
1242        // metadata has a specific id in metadata.id
1243        let metadata = BlockMetadata {
1244            id: Some(Anchor {
1245                id: "custom_id",
1246                xreflabel: None,
1247                location: Location::default(),
1248                bibliography: false,
1249                bibliography_label: None,
1250            }),
1251            ..Default::default()
1252        };
1253        assert_eq!(
1254            Section::generate_id(&arena, &metadata, inlines),
1255            SafeId::Explicit("custom_id")
1256        );
1257
1258        // metadata has anchor in metadata.anchors (from [[id]] or [#id] syntax)
1259        let metadata = BlockMetadata {
1260            anchors: vec![Anchor {
1261                id: "anchor_id",
1262                xreflabel: None,
1263                location: Location::default(),
1264                bibliography: false,
1265                bibliography_label: None,
1266            }],
1267            ..Default::default()
1268        };
1269        assert_eq!(
1270            Section::generate_id(&arena, &metadata, inlines),
1271            SafeId::Explicit("anchor_id")
1272        );
1273
1274        // with multiple anchors, the last one is used (matches asciidoctor behavior)
1275        let metadata = BlockMetadata {
1276            anchors: vec![
1277                Anchor {
1278                    id: "first_anchor",
1279                    xreflabel: None,
1280                    location: Location::default(),
1281                    bibliography: false,
1282                    bibliography_label: None,
1283                },
1284                Anchor {
1285                    id: "last_anchor",
1286                    xreflabel: None,
1287                    location: Location::default(),
1288                    bibliography: false,
1289                    bibliography_label: None,
1290                },
1291            ],
1292            ..Default::default()
1293        };
1294        assert_eq!(
1295            Section::generate_id(&arena, &metadata, inlines),
1296            SafeId::Explicit("last_anchor")
1297        );
1298
1299        // metadata.id takes precedence over metadata.anchors
1300        let metadata = BlockMetadata {
1301            id: Some(Anchor {
1302                id: "from_id",
1303                xreflabel: None,
1304                location: Location::default(),
1305                bibliography: false,
1306                bibliography_label: None,
1307            }),
1308            anchors: vec![Anchor {
1309                id: "from_anchors",
1310                xreflabel: None,
1311                location: Location::default(),
1312                bibliography: false,
1313                bibliography_label: None,
1314            }],
1315            ..Default::default()
1316        };
1317        assert_eq!(
1318            Section::generate_id(&arena, &metadata, inlines),
1319            SafeId::Explicit("from_id")
1320        );
1321    }
1322}