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
19pub 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105#[non_exhaustive]
106pub enum SectionKind {
107 #[default]
109 Normal,
110 Preface,
111 Abstract,
112 Dedication,
113 Colophon,
114 Appendix,
115 Glossary,
116 Bibliography,
117 Index,
118}
119
120impl SectionKind {
121 #[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 #[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 #[must_use]
158 pub fn is_special(self) -> bool {
159 !matches!(self, SectionKind::Normal)
160 }
161}
162
163#[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 pub kind: SectionKind,
173 numbering: SectionNumbering,
174 pub(crate) id: Option<&'a str>,
175 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 #[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 #[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 #[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 #[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 #[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
347pub(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#[derive(Clone, Debug, Default)]
386struct SectionNumbers {
387 shown: Option<SectionNumber>,
389 reference: Option<SectionNumber>,
391}
392
393pub(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 && 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
422pub(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 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 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#[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 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 #[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 fn id_from_title(title: &[InlineNode<'a>]) -> String {
809 let mut out = String::new();
810 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 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 #[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 #[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 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 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 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 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 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 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 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 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}