Skip to main content

asciidoc_parser/blocks/
section.rs

1use std::{fmt, sync::LazyLock};
2
3use regex::Regex;
4
5use crate::{
6    HasSpan, Parser, Span,
7    attributes::Attrlist,
8    blocks::{
9        Block, ChildBlocks, ContentModel, IsBlock, metadata::BlockMetadata,
10        parse_utils::parse_blocks_until,
11    },
12    content::{
13        Content, SubstitutionGroup, XrefSegment, strip_footnote_marker_spans,
14        substitute_attributes_in_reftext,
15    },
16    document::{InterpretedValue, RefType},
17    internal::debug::DebugSliceReference,
18    parser::XrefSignifier,
19    span::MatchedItem,
20    strings::CowStr,
21    warnings::{Warning, WarningType},
22};
23
24/// Sections partition the document into a content hierarchy. A section is an
25/// implicit enclosure. Each section begins with a title and ends at the next
26/// sibling section, ancestor section, or end of document. Nested section levels
27/// must be sequential.
28#[derive(Clone, Eq, Hash, PartialEq)]
29pub struct SectionBlock<'src> {
30    level: usize,
31    section_title: Content<'src>,
32    blocks: Vec<Block<'src>>,
33    source: Span<'src>,
34    title_source: Option<Span<'src>>,
35    title: Option<Content<'src>>,
36    anchor: Option<Span<'src>>,
37    anchor_reftext: Option<Span<'src>>,
38    attrlist: Option<Attrlist<'src>>,
39    section_type: SectionType,
40    section_id: Option<String>,
41    caption: Option<String>,
42    section_number: Option<SectionNumber>,
43}
44
45impl<'src> SectionBlock<'src> {
46    /// Returns a document-order iterator over this section's direct child
47    /// blocks.
48    ///
49    /// For the full subtree, or to search from a [`Block`] or [`Document`], use
50    /// [`FindBlocks`](crate::blocks::FindBlocks).
51    ///
52    /// [`Document`]: crate::Document
53    pub fn child_blocks(&'src self) -> ChildBlocks<'src> {
54        ChildBlocks::from_slice(&self.blocks)
55    }
56
57    pub(crate) fn parse(
58        metadata: &BlockMetadata<'src>,
59        parser: &mut Parser,
60        warnings: &mut Vec<Warning<'src>>,
61    ) -> Option<MatchedItem<'src, Self>> {
62        let discrete = metadata.is_discrete();
63
64        let source = metadata.block_start.discard_empty_lines();
65
66        // The heading's effective level folds in the running `leveloffset`
67        // document attribute. A positive offset (the usual case, from
68        // `include::[leveloffset=+1]`) pushes headings down – notably promoting
69        // an included file's level-0 document title (`=`) into a real section –
70        // while a negative offset pulls them up. A heading whose effective level
71        // is below 1 is rejected as an unsupported level-0 heading (the warning
72        // is raised inside `parse_title_line`).
73        let level_and_title = parse_title_line(source, parser.level_offset(), warnings)?;
74
75        // Take a snapshot of `sectids` value before reading child blocks because
76        // the value might be altered while parsing.
77        let sectids = parser.is_attribute_set("sectids");
78
79        let level = level_and_title.item.0;
80
81        // An explicit ID supplied *above* the heading (a `[#id]`/`[id=…]` block
82        // attribute or a `[[id]]` block anchor) always wins. It also suppresses
83        // embedded-anchor processing below, so a `[[id]]` embedded in the title
84        // is then left in place and rendered as an ordinary inline anchor.
85        let attr_or_anchor_id = metadata
86            .attrlist
87            .as_ref()
88            .and_then(|a| a.id())
89            .or_else(|| metadata.anchor.as_ref().map(|anchor| anchor.data()));
90
91        // AsciiDoc lets a section define its ID via an anchor embedded at the end
92        // of the title (`== Title [[id]] ==`, optionally `[[id,reftext]]`). When
93        // present (and not already overridden by an explicit ID above), the anchor
94        // is consumed to set the section ID and removed from the rendered title,
95        // rather than being rendered as an inline anchor.
96        let (title_span, embedded_id, embedded_reftext) = if attr_or_anchor_id.is_none() {
97            match_embedded_section_anchor(level_and_title.item.1)
98        } else {
99            (level_and_title.item.1, None, None)
100        };
101
102        // Assign the section type. At level 1, we look for an `appendix` section style;
103        // at all other levels, we inherit the section type from parent.
104        let section_type = if discrete {
105            SectionType::Discrete
106        } else if level == 1 {
107            let section_type = if let Some(ref attrlist) = metadata.attrlist
108                && let Some(block_style) = attrlist.block_style()
109                && block_style == "appendix"
110            {
111                SectionType::Appendix
112            } else {
113                SectionType::Normal
114            };
115            parser.topmost_section_type = section_type;
116            section_type
117        } else {
118            parser.topmost_section_type
119        };
120
121        // Assign section number BEFORE parsing child blocks so that sections are
122        // numbered in document order (parent before children).
123        //
124        // Appendix sections are lettered (A, B, ...) independently of `sectnums`
125        // because their title prefix is governed by the `appendix-caption`
126        // attribute (see the "Appendix label" section of the spec). An appendix
127        // root – the section that directly carries the `appendix` style –
128        // therefore always advances the appendix counter so it (and the numbering
129        // of any subsection) can derive its letter, even when `sectnums` is unset.
130        let sectnums_active =
131            parser.is_attribute_set("sectnums") && level <= parser.sectnumlevels && !discrete;
132
133        let is_appendix_root = !discrete && level == 1 && section_type == SectionType::Appendix;
134
135        // A cross-reference builds `full`/`short` xrefstyle text from a section's
136        // signifier and number, but only when the section has a number *and* no
137        // explicit reftext (an explicit reftext is used verbatim instead). An
138        // explicit reftext can come from a `reftext` attribute, the second field
139        // of a `[[id,reftext]]` block anchor, or the second field of an anchor
140        // embedded in the section title.
141        let has_explicit_reftext = metadata
142            .attrlist
143            .as_ref()
144            .and_then(|a| a.named_attribute("reftext"))
145            .is_some()
146            || metadata.anchor_reftext.is_some()
147            || embedded_reftext.is_some();
148
149        // An anchor reftext can carry attribute references (`[[install,install
150        // on {platform-name}]]`), whether the anchor sits above the heading
151        // (`metadata.anchor_reftext`) or is embedded in the title
152        // (`embedded_reftext`). Resolve them against the attributes in effect at
153        // the anchor's location – captured here, before the section body is
154        // parsed and can itself redefine those attributes – mirroring how the
155        // anchor ID and a `reftext=` attribute are already substituted when the
156        // attribute list is parsed.
157        let anchor_reftext = metadata
158            .anchor_reftext
159            .as_ref()
160            .map(|span| substitute_attributes_in_reftext(*span, parser));
161        let embedded_reftext =
162            embedded_reftext.map(|span| substitute_attributes_in_reftext(span, parser));
163
164        let (section_number, caption, xref_signifier) = if is_appendix_root {
165            // The appendix letter is resolved through the `appendix-number`
166            // counter (mirroring Ruby Asciidoctor's `Document#counter
167            // 'appendix-number', 'A'`): each appendix advances the counter, so
168            // a document-set `appendix-number` value is the letter *before* the
169            // first appendix (`:appendix-number: α` letters the appendices β,
170            // γ, …) and the attribute always reads back as the current letter.
171            let letter = parser.counter("appendix-number", Some("A"));
172
173            parser
174                .last_appendix_section_number
175                .assign_next_number(level);
176            parser.last_appendix_section_number.appendix_letter = Some(letter);
177
178            let number = parser.last_appendix_section_number.clone();
179            let caption = appendix_caption(parser, &number);
180
181            // An appendix is always lettered and its title is emphasized, even
182            // when `sectnums` is unset; its reference signifier is
183            // `appendix-refsig`.
184            let signifier = (!has_explicit_reftext).then(|| XrefSignifier {
185                label: join_signifier(
186                    parser.attribute_value("appendix-refsig").as_maybe_str(),
187                    &number.to_string(),
188                ),
189                emphasize: true,
190            });
191            let section_number = if sectnums_active { Some(number) } else { None };
192            (section_number, Some(caption), signifier)
193        } else if sectnums_active {
194            let number = parser.assign_section_number(level);
195            let signifier = (!has_explicit_reftext).then(|| XrefSignifier {
196                label: join_signifier(
197                    parser.attribute_value("section-refsig").as_maybe_str(),
198                    &number.to_string(),
199                ),
200                emphasize: false,
201            });
202            (Some(number), None, signifier)
203        } else {
204            (None, None, None)
205        };
206
207        let mut most_recent_level = level;
208
209        // Apply the title's substitutions BEFORE parsing the section body, so
210        // that a `footnote:[…]` macro in the title is numbered ahead of any
211        // footnotes in the body (document order: the title precedes its body).
212        // Substituting before the body also means the title only sees document
213        // attributes defined ahead of it, not ones its body sets later.
214        //
215        // Asciidoctor instead converts headings eagerly and out of document
216        // order (to build IDs and cross-reference text), which numbers heading
217        // footnotes out of sequence. The crate deliberately diverges toward
218        // straightforward document-order numbering; see
219        // https://github.com/asciidoc-rs/asciidoc-parser/issues/594.
220        //
221        // A footnote in the title is a real, document-order footnote, but its
222        // marker must not leak into the section's reference text (an xref's link
223        // text) or auto-generated ID. Marking the title's footnote markers with
224        // sentinels lets those be excised below from a single render – no second
225        // substitution pass, so counters and attribute-expanded footnotes are
226        // processed exactly once.
227        let mut section_title = Content::from(title_span);
228        parser.mark_footnote_spans.set(true);
229        SubstitutionGroup::Title.apply(&mut section_title, parser, metadata.attrlist.as_ref());
230        parser.mark_footnote_spans.set(false);
231
232        // The footnote-free rendering of the title, for the reference text and
233        // auto-generated ID; a no-op string copy when the title had no footnote.
234        let title_reftext = strip_footnote_marker_spans(section_title.rendered());
235
236        // Strip the now-consumed sentinels from the title itself, keeping the
237        // footnote marker so the heading still renders it.
238        section_title.remove_footnote_marker_sentinels();
239
240        // A section carrying the `bibliography` style implicitly adds that style
241        // to each top-level unordered list in its body (see the "Bibliography
242        // section syntax" section of the spec). Record that we are parsing such a
243        // section's body so `ListBlock::parse` can detect it, and restore the
244        // previous value afterward so the style does not leak into sibling
245        // sections (or, via a non-bibliography subsection, into its children).
246        let is_bibliography_section = !discrete
247            && metadata
248                .attrlist
249                .as_ref()
250                .and_then(|attrlist| attrlist.block_style())
251                == Some("bibliography");
252
253        let previously_in_bibliography_section = parser.parsing_bibliography_section_body;
254        parser.parsing_bibliography_section_body = is_bibliography_section;
255
256        // A block title above a section heading does not become the section's
257        // title; it is carried over to the first block inside the section
258        // (matching Asciidoctor). Stash it on the parser: the next block parsed
259        // claims it – usually the section's first child, or (when the section
260        // body is empty) the sibling section that follows, which re-stashes it
261        // for its own first block. A discrete heading is an ordinary block, not
262        // a section, so it keeps its title. See `Block::parse_internal` for the
263        // claiming side.
264        if !discrete && let Some(title) = metadata.title.as_ref() {
265            // The carried title travels as an owned snapshot, keeping any
266            // deferred cross-references so an embedded `<<id>>` still resolves
267            // for the claiming block once the catalog is complete.
268            parser.pending_block_title = Some(title.to_owned_title());
269        }
270
271        let mut maw_blocks = parse_blocks_until(
272            level_and_title.after,
273            |i, parser| {
274                discrete
275                    || peer_or_ancestor_section(*i, level, &mut most_recent_level, warnings, parser)
276            },
277            parser,
278        );
279
280        parser.parsing_bibliography_section_body = previously_in_bibliography_section;
281
282        let blocks = maw_blocks.item;
283        let source = metadata.source.trim_remainder(blocks.after);
284
285        let proposed_base_id = generate_section_id(&title_reftext, parser);
286
287        // An explicit ID above the heading wins; otherwise an anchor embedded in
288        // the title supplies the ID.
289        let manual_id = attr_or_anchor_id.or(embedded_id);
290
291        // Reftext precedence mirrors `Block::block_reftext`: an explicit
292        // `reftext` attribute, then a `[[id,reftext]]` block-anchor reftext, then
293        // an embedded-anchor reftext (both with their attribute references
294        // already resolved above), then the section title.
295        let reftext: CowStr<'_> = metadata
296            .attrlist
297            .as_ref()
298            .and_then(|a| {
299                a.named_attribute("reftext")
300                    .map(|a| CowStr::from(a.value()))
301            })
302            .or_else(|| anchor_reftext.clone())
303            .or_else(|| embedded_reftext.clone())
304            .unwrap_or_else(|| CowStr::from(title_reftext.as_str()));
305
306        let section_id = if sectids && manual_id.is_none() {
307            let id = parser.generate_and_register_unique_id(
308                &proposed_base_id,
309                Some(&reftext),
310                RefType::Section,
311            );
312            if let Some(signifier) = xref_signifier {
313                parser.set_ref_signifier(&id, signifier);
314            }
315            Some(id)
316        } else {
317            if let Some(manual_id) = manual_id {
318                match parser.register_ref(manual_id, Some(&reftext), RefType::Section) {
319                    Ok(()) => {
320                        if let Some(signifier) = xref_signifier {
321                            parser.set_ref_signifier(manual_id, signifier);
322                        }
323                    }
324                    Err(_duplicate_error) => {
325                        warnings.push(Warning {
326                            source: metadata.source.trim_remainder(level_and_title.after),
327                            warning: WarningType::DuplicateId(manual_id.to_string()),
328                            origin: None,
329                        });
330                    }
331                }
332            }
333
334            // An ID drawn from an anchor embedded in the title has no `anchor`
335            // span or attrlist entry for `id()` to read it back from, so record
336            // it here (unlike an ID supplied above the heading, which `id()`
337            // sources directly from the anchor/attrlist).
338            embedded_id.map(str::to_string)
339        };
340
341        // Restore "normal" top-level section type if exiting a level 1 appendix.
342        if level == 1 && !discrete {
343            parser.topmost_section_type = SectionType::Normal;
344        }
345
346        warnings.append(&mut maw_blocks.warnings);
347
348        Some(MatchedItem {
349            item: Self {
350                level,
351                section_title,
352                blocks: blocks.item,
353                source: source.trim_trailing_whitespace(),
354
355                // A non-discrete section never keeps a block title; it was
356                // stashed above for the next block parsed to claim.
357                title_source: if discrete {
358                    metadata.title_source
359                } else {
360                    None
361                },
362                title: if discrete {
363                    metadata.title.clone()
364                } else {
365                    None
366                },
367                anchor: metadata.anchor,
368                anchor_reftext: metadata.anchor_reftext,
369                attrlist: metadata.attrlist.clone(),
370                section_type,
371                section_id,
372                caption,
373                section_number,
374            },
375            after: blocks.after,
376        })
377    }
378
379    /// Return the section's level.
380    ///
381    /// The section title must be prefixed with a section marker, which
382    /// indicates the section level. The number of equal signs in the marker
383    /// represents the section level using a 0-based index (e.g., two equal
384    /// signs represents level 1). A section marker can range from two to six
385    /// equal signs and must be followed by a space.
386    ///
387    /// This function will return an integer between 1 and 5.
388    pub fn level(&self) -> usize {
389        self.level
390    }
391
392    /// Return a [`Span`] containing the section title source.
393    pub fn section_title_source(&self) -> Span<'src> {
394        self.section_title.original()
395    }
396
397    /// Return the processed section title after substitutions have been
398    /// applied.
399    pub fn section_title(&'src self) -> &'src str {
400        self.section_title.rendered()
401    }
402
403    /// Return the type of this section (normal or appendix).
404    pub fn section_type(&'src self) -> SectionType {
405        self.section_type
406    }
407
408    /// Accessor intended to be used for testing only. Use the `id()` accessor
409    /// in the `IsBlock` trait to retrieve the effective ID for this block,
410    /// which considers both auto-generated IDs and manually-set IDs.
411    #[cfg(test)]
412    pub(crate) fn section_id(&'src self) -> Option<&'src str> {
413        self.section_id.as_deref()
414    }
415
416    /// Return the section number assigned to this section, if any.
417    pub fn section_number(&'src self) -> Option<&'src SectionNumber> {
418        self.section_number.as_ref()
419    }
420
421    /// Returns the section title's deferred cross-reference template and
422    /// segments, if the title contains any cross-references.
423    ///
424    /// Used by the document-order title resolution pass (see
425    /// [`Document::resolve_references`]).
426    ///
427    /// [`Document::resolve_references`]: crate::Document::resolve_references
428    pub(crate) fn section_title_deferred_parts(&self) -> Option<(&str, &[XrefSegment])> {
429        self.section_title.deferred_parts()
430    }
431
432    /// Overwrites the rendered section title, used by the document-order title
433    /// resolution pass to install a title whose cross-references were resolved
434    /// with cross-title coordination.
435    pub(crate) fn set_section_title_rendered(&mut self, rendered: String) {
436        self.section_title.set_rendered(rendered);
437    }
438
439    /// Returns the ID under which this section is registered in the catalog, if
440    /// any, as an owned string.
441    ///
442    /// Mirrors the effective-ID precedence of [`IsBlock::id`] (attribute-list
443    /// ID, then explicit anchor, then the auto-generated section ID) but
444    /// without the `&'src self` borrow, so the document-order title
445    /// resolution pass can key titles by ID while walking `&mut` blocks.
446    pub(crate) fn reference_id(&self) -> Option<String> {
447        self.attrlist
448            .as_ref()
449            .and_then(|attrlist| attrlist.id())
450            .map(str::to_string)
451            .or_else(|| self.anchor.map(|a| a.data().to_string()))
452            .or_else(|| self.section_id.clone())
453    }
454
455    /// Returns `true` when the section's reference text comes from an explicit
456    /// `reftext` attribute or a `[[id,reftext]]` anchor reftext, rather than
457    /// from its title. Such a section's reference text does not change when its
458    /// title's cross-references resolve, so the title resolution pass does not
459    /// treat it as a recomputable target.
460    pub(crate) fn has_explicit_reftext(&self) -> bool {
461        self.attrlist
462            .as_ref()
463            .and_then(|attrlist| attrlist.named_attribute("reftext"))
464            .is_some()
465            || self.anchor_reftext.is_some()
466    }
467}
468
469/// Builds the appendix title prefix (caption) for an appendix root section.
470///
471/// The prefix combines the `appendix-caption` label (which defaults to
472/// "`Appendix`"), the appendix letter (A, B, ...), and a separator. When
473/// `appendix-caption` is set, the prefix is `"<label> <letter>: "`; when it is
474/// unset (or empty), the label is dropped, leaving `"<letter>. "`. This mirrors
475/// Ruby Asciidoctor.
476fn appendix_caption(parser: &Parser, number: &SectionNumber) -> String {
477    let letter = number.to_string();
478    match parser.attribute_value("appendix-caption") {
479        InterpretedValue::Value(label) if !label.is_empty() => format!("{label} {letter}: "),
480        _ => format!("{letter}. "),
481    }
482}
483
484/// Combines a reference signifier with a reference number for the
485/// `full`/`short` xrefstyle label. When the signifier is set the label is
486/// `"<signifier> <number>"` (e.g. `"Section 2.3"`); when it is unset (or empty)
487/// – as after `:!section-refsig:` – the signifier is dropped and only the
488/// number remains.
489fn join_signifier(signifier: Option<&str>, number: &str) -> String {
490    match signifier {
491        Some(signifier) if !signifier.is_empty() => format!("{signifier} {number}"),
492        _ => number.to_string(),
493    }
494}
495
496impl<'src> IsBlock<'src> for SectionBlock<'src> {
497    fn content_model(&self) -> ContentModel {
498        ContentModel::Compound
499    }
500
501    // `content_mut` keeps the default `None`: the section's own resolvable
502    // content is its heading, which is resolved by the document-order title
503    // pass (see `document::title_refs`) rather than the per-content pass –
504    // that pass coordinates cross-references *between* titles (forward and
505    // circular), which per-content resolution cannot see.
506
507    fn raw_context(&self) -> CowStr<'src> {
508        // A discrete (floating) heading is modeled by Asciidoctor as a block
509        // with the `floating_title` context rather than a section, so a
510        // consumer that switches on block context can tell it apart from an
511        // ordinary section – for instance, to exclude it from the table of
512        // contents or to pick a converter method.
513        if self.section_type == SectionType::Discrete {
514            "floating_title".into()
515        } else {
516            "section".into()
517        }
518    }
519
520    fn child_blocks_mut(&mut self) -> &mut [Block<'src>] {
521        &mut self.blocks
522    }
523
524    fn title_source(&'src self) -> Option<Span<'src>> {
525        self.title_source
526    }
527
528    fn title(&self) -> Option<&str> {
529        self.title.as_ref().map(Content::rendered_str)
530    }
531
532    fn anchor(&'src self) -> Option<Span<'src>> {
533        self.anchor
534    }
535
536    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
537        self.anchor_reftext
538    }
539
540    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
541        self.attrlist.as_ref()
542    }
543
544    fn caption(&self) -> Option<&str> {
545        self.caption.as_deref()
546    }
547
548    fn id(&'src self) -> Option<&'src str> {
549        // An explicit ID above the heading wins, and an attribute-list ID
550        // (`[id=…]`/`[#id]`) takes precedence over a `[[id]]` block anchor –
551        // matching the precedence used when the section registers itself in the
552        // catalog (see `attr_or_anchor_id` in `SectionBlock::parse`), so this
553        // accessor reports the same ID the section is cross-referenced under.
554        self.attrlist()
555            .and_then(|attrlist| attrlist.id())
556            .or_else(|| self.anchor().map(|a| a.data()))
557            // Fall back to auto-generated ID if no explicit ID is set.
558            .or(self.section_id.as_deref())
559    }
560}
561
562impl<'src> HasSpan<'src> for SectionBlock<'src> {
563    fn span(&self) -> Span<'src> {
564        self.source
565    }
566}
567
568impl std::fmt::Debug for SectionBlock<'_> {
569    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570        f.debug_struct("SectionBlock")
571            .field("level", &self.level)
572            .field("section_title", &self.section_title)
573            .field("blocks", &DebugSliceReference(&self.blocks))
574            .field("source", &self.source)
575            .field("title_source", &self.title_source)
576            .field("title", &self.title)
577            .field("anchor", &self.anchor)
578            .field("anchor_reftext", &self.anchor_reftext)
579            .field("attrlist", &self.attrlist)
580            .field("section_type", &self.section_type)
581            .field("section_id", &self.section_id)
582            .field("caption", &self.caption)
583            .field("section_number", &self.section_number)
584            .finish()
585    }
586}
587
588/// The lowest and highest levels a section heading may occupy. A syntactic
589/// heading level (0 for `=`, up to 5 for `======`) shifted by `leveloffset`
590/// must land within this inclusive range; a result outside it is clamped.
591const MIN_SECTION_LEVEL: i32 = 1;
592const MAX_SECTION_LEVEL: i32 = 5;
593
594/// Strips an optional symmetric ATX title close from `title`: a trailing run of
595/// `marker` exactly `count` long, preceded by whitespace (e.g. the ` ==` in
596/// `== Title ==`). A run that does not match the opening marker (`== Title
597/// ===`) or is not preceded by whitespace (`== Title==`) is left intact, and a
598/// title consisting only of the close is left intact. Mirrors the trailing
599/// `(?: +\1)?` group of Asciidoctor's section-title regex.
600pub(crate) fn strip_symmetric_title_close(title: Span<'_>, marker: char, count: usize) -> Span<'_> {
601    // The close must be separated from the title by an ASCII blank (space or
602    // tab), matching Asciidoctor's `CG_BLANK` (`[ \t]`) – not arbitrary Unicode
603    // whitespace, so e.g. `== Title<NBSP>==` keeps its `==` as title text.
604    const BLANK: [char; 2] = [' ', '\t'];
605    let close = marker.to_string().repeat(count);
606    match title.data().strip_suffix(&close) {
607        Some(without_close)
608            if without_close.ends_with(BLANK)
609                && !without_close.trim_end_matches(BLANK).is_empty() =>
610        {
611            title.slice_to(..without_close.trim_end_matches(BLANK).len())
612        }
613        _ => title,
614    }
615}
616
617/// Matches an anchor embedded at the end of a section title (after the
618/// symmetric ATX close has already been stripped): the title text, optional
619/// escape, the anchor ID, and an optional reftext.
620///
621/// Mirrors Asciidoctor's `InlineSectionAnchorRx`. The anchor must be separated
622/// from the title by at least one blank; the ID follows the usual name rules
623/// (leading letter/`_`/`:`, then letters/digits/`_`/`-`/`:`/`.`); an optional
624/// reftext after the first comma may itself contain commas.
625#[allow(clippy::unwrap_used)]
626static EMBEDDED_SECTION_ANCHOR: LazyLock<Regex> = LazyLock::new(|| {
627    Regex::new(
628        r"(?x)
629        ^(.*?)                                                # (1) title text before the anchor
630        [\ \t]+                                               # blank(s) separating title from anchor
631        (\\)?                                                 # (2) optional escape backslash
632        \[\[
633          ( [\p{Alphabetic}_:] [\p{Alphabetic}\p{Nd}_\-:.]* )  # (3) anchor id
634          (?: , [\ \t]* (\S.*) )?                             # (4) optional reftext (may contain commas)
635        \]\]
636        $",
637    )
638    .unwrap()
639});
640
641/// Detects and consumes an anchor embedded at the end of a section `title`
642/// (`Title [[id]]` or `Title [[id,reftext]]`), returning the title span with
643/// the anchor removed, the anchor ID, and the reftext (each `None` when
644/// absent).
645///
646/// The `title` span must already have had any symmetric ATX close stripped. An
647/// *escaped* anchor (`Title \[[id]]`) is intentionally left intact – the ID is
648/// not adopted and the title is returned unchanged so the inline-anchor
649/// substitution can unescape it – mirroring Ruby Asciidoctor.
650fn match_embedded_section_anchor<'src>(
651    title: Span<'src>,
652) -> (Span<'src>, Option<&'src str>, Option<Span<'src>>) {
653    // A quick reject avoids running the regex on the common no-anchor title.
654    if !title.data().ends_with("]]") {
655        return (title, None, None);
656    }
657
658    let Some(caps) = EMBEDDED_SECTION_ANCHOR.captures(title.data()) else {
659        return (title, None, None);
660    };
661
662    // An escaped anchor is not adopted as the section ID.
663    if caps.get(2).is_some() {
664        return (title, None, None);
665    }
666
667    let title_text = caps.get(1).map_or("", |m| m.as_str());
668    let id = caps.get(3).map(|m| m.as_str());
669
670    // Trailing whitespace is trimmed from the reftext, matching the inline-anchor
671    // substitution's handling of a shorthand `[[id,reftext]]`. The reftext is
672    // returned as a source span (rather than a `&str`) so its attribute
673    // references can be resolved against the document source at the caller.
674    let reftext = caps
675        .get(4)
676        .map(|m| title.slice(m.start()..m.end()).trim_trailing_whitespace());
677
678    (title.slice_to(..title_text.len()), id, reftext)
679}
680
681/// Parses a section title line, returning the section's *effective* level
682/// (with `offset`, the running `leveloffset`, already applied) and the span of
683/// the title text.
684///
685/// The syntactic level is 0-based: a bare `=` is 0, `==` is 1, up to `======`
686/// at 5. `offset` shifts it to the effective level, which is then constrained
687/// to the [`MIN_SECTION_LEVEL`]..=[`MAX_SECTION_LEVEL`] range:
688///
689/// * A bare `=` (syntactic level 0) that no positive offset lifts to level 1 or
690///   beyond has no section representation; it is rejected as an unsupported
691///   level-0 heading (recording a warning), preserving the single-document-
692///   title rule.
693/// * Any other heading whose effective level falls outside the supported range
694///   is clamped to the nearest valid level and a warning is recorded.
695fn parse_title_line<'src>(
696    source: Span<'src>,
697    offset: i32,
698    warnings: &mut Vec<Warning<'src>>,
699) -> Option<MatchedItem<'src, (usize, Span<'src>)>> {
700    let mi = source.take_non_empty_line()?;
701    let mut line = mi.item;
702
703    let mut count = 0;
704
705    let marker_char = if line.starts_with('=') { '=' } else { '#' };
706
707    if marker_char == '=' {
708        while let Some(mi) = line.take_prefix("=") {
709            count += 1;
710            line = mi.after;
711        }
712    } else {
713        while let Some(mi) = line.take_prefix("#") {
714            count += 1;
715            line = mi.after;
716        }
717    }
718
719    if count == 0 {
720        return None;
721    }
722
723    if count > 6 {
724        warnings.push(Warning {
725            source: source.take_normalized_line().item,
726            warning: WarningType::SectionHeadingLevelExceedsMaximum(count - 1),
727            origin: None,
728        });
729
730        return None;
731    }
732
733    // Fold in the running `leveloffset`. `saturating_add` keeps a hostile
734    // offset (e.g. an absolute `:leveloffset:` near `i32::MAX`) from
735    // overflowing – a panic in debug builds and a wrap in release builds – the
736    // syntactic level itself is at most 5.
737    let syntactic_level = (count - 1) as i32;
738    let effective_level = syntactic_level.saturating_add(offset);
739
740    // A bare `=` (syntactic level 0) that no positive offset lifts to level 1
741    // or beyond is a document title appearing in the body, which is not a
742    // section (the single-document-title rule). Decline it exactly as an
743    // un-offset level-0 heading is declined, rather than clamping it into a
744    // section. This is checked before the whitespace requirement below so a
745    // spaceless `=blah` is still reported, matching a bare level-0 heading.
746    if syntactic_level == 0 && effective_level < MIN_SECTION_LEVEL {
747        warnings.push(Warning {
748            source: source.take_normalized_line().item,
749            warning: WarningType::Level0SectionHeadingNotSupported,
750            origin: None,
751        });
752
753        return None;
754    }
755
756    // The marker must be followed by whitespace to be a section title at all;
757    // validate that before clamping the level so a non-title line such as
758    // `==x` is declined quietly, without a spurious out-of-range warning.
759    let title = line.take_required_whitespace()?;
760
761    let title_span = strip_symmetric_title_close(title.after, marker_char, count);
762
763    // A real section heading whose offset-adjusted level lands outside the
764    // supported 1..=5 range is clamped into range and reported, rather than
765    // producing an out-of-range (or, under a hostile offset, absurd) level.
766    let level = if effective_level < MIN_SECTION_LEVEL {
767        warnings.push(Warning {
768            source: source.take_normalized_line().item,
769            warning: WarningType::SectionHeadingLevelOutOfRange(
770                effective_level,
771                MIN_SECTION_LEVEL as usize,
772            ),
773            origin: None,
774        });
775        MIN_SECTION_LEVEL as usize
776    } else if effective_level > MAX_SECTION_LEVEL {
777        warnings.push(Warning {
778            source: source.take_normalized_line().item,
779            warning: WarningType::SectionHeadingLevelOutOfRange(
780                effective_level,
781                MAX_SECTION_LEVEL as usize,
782            ),
783            origin: None,
784        });
785        MAX_SECTION_LEVEL as usize
786    } else {
787        effective_level as usize
788    };
789
790    Some(MatchedItem {
791        item: (level, title_span),
792        after: mi.after,
793    })
794}
795
796fn peer_or_ancestor_section<'src>(
797    source: Span<'src>,
798    level: usize,
799    most_recent_level: &mut usize,
800    warnings: &mut Vec<Warning<'src>>,
801    parser: &Parser,
802) -> bool {
803    // Skip over any block metadata (title, anchor, attrlist) to find the actual
804    // section line. We create a temporary parser to avoid modifying the real
805    // parser state.
806    let mut temp_parser = Parser::default();
807
808    // Block-metadata parsing consults `leveloffset` to decide whether a comment
809    // separating collected metadata from a following heading is transparent
810    // (see `skip_comments_before_section`): a bare `=` counts as a section only
811    // when a positive offset promotes it. Mirror the live parser's offset onto
812    // the temporary parser so the boundary look-ahead makes the same decision
813    // the real parse will – otherwise a peer/ancestor section reached across
814    // such a comment would be missed and wrongly nested. The stored offset is
815    // already an absolute integer, so it needs no further resolution.
816    let level_offset = parser.level_offset();
817    if level_offset != 0 {
818        temp_parser.set_attribute_by_value_from_header("leveloffset", level_offset.to_string());
819    }
820
821    let block_metadata_maw = BlockMetadata::parse(source, &mut temp_parser);
822
823    let block_metadata = block_metadata_maw.item;
824    if block_metadata.is_discrete() {
825        return false;
826    }
827
828    // Discard any blank lines between the collected metadata and the heading,
829    // mirroring the tolerance `Block::parse_internal` applies on the live parse
830    // path. Block metadata may be separated from its block by blank lines
831    // (including the blank lines around a comment that block-metadata parsing
832    // skips over), and `parse_title_line` requires a non-blank first line, so
833    // without this the boundary check would miss such a heading and wrongly fold
834    // the following peer/ancestor section into the current one.
835    let source_after_metadata = block_metadata.block_start.discard_empty_lines();
836
837    // Compare effective levels: the boundary heading's `leveloffset` is read
838    // from the *live* parser (every block up to this point, including any
839    // `:leveloffset:` attribute entry, has already been applied), while `level`
840    // is the current section's own effective level. A heading whose effective
841    // level is below 1 has no section representation, so `parse_title_line`
842    // returns `None` and it is treated as ordinary content – exactly as an
843    // un-offset level-0 heading would be.
844    //
845    // Any warnings the heading would raise (a clamped level, an unsupported
846    // level-0 heading, ...) are discarded here: this is only a look-ahead to
847    // find the section boundary, and the heading is parsed again – recording
848    // those warnings once – either as a child block of this section or in the
849    // enclosing scope once this section ends.
850    let mut ignored_warnings = vec![];
851    if let Some(mi) = parse_title_line(
852        source_after_metadata,
853        parser.level_offset(),
854        &mut ignored_warnings,
855    ) {
856        let found_level = mi.item.0;
857
858        if found_level > *most_recent_level + 1 {
859            warnings.push(Warning {
860                source: source.take_normalized_line().item,
861                warning: WarningType::SectionHeadingLevelSkipped(*most_recent_level, found_level),
862                origin: None,
863            });
864        }
865
866        *most_recent_level = found_level;
867
868        found_level <= level
869    } else {
870        false
871    }
872}
873
874/// Records a "section title out of sequence" warning for a *top-level* section
875/// whose level skips ahead of level 1 – the document root's expected first
876/// child level. The nested case (a section skipping a level under its *parent
877/// section*) is handled during parsing by [`peer_or_ancestor_section`]; this
878/// covers the document-root case (e.g. `= Doc` followed directly by `=== X`),
879/// which that boundary check never sees.
880///
881/// At most one such warning is possible: any later top-level section is a peer
882/// or ancestor of an earlier one (a deeper heading becomes a *child* instead),
883/// so it can never skip ahead of `most_recent_level + 1`.
884///
885/// Discrete headings are not part of the section sequence and are skipped. The
886/// caller restricts this to titled, non-`fragment` documents (a title-less
887/// document or a section fragment has no level-0 root to sequence against).
888pub(crate) fn root_section_sequence_warnings<'src>(blocks: &[Block<'src>]) -> Vec<Warning<'src>> {
889    let mut warnings = vec![];
890    let mut most_recent_level = 0;
891
892    for block in blocks {
893        let Block::Section(section) = block else {
894            continue;
895        };
896
897        if section.section_type() == SectionType::Discrete {
898            continue;
899        }
900
901        let found_level = section.level();
902
903        if found_level > most_recent_level + 1 {
904            warnings.push(Warning {
905                source: section.span().take_normalized_line().item,
906                warning: WarningType::SectionHeadingLevelSkipped(most_recent_level, found_level),
907                origin: None,
908            });
909        }
910
911        most_recent_level = found_level;
912    }
913
914    warnings
915}
916
917/// Propose a section ID from the section title.
918///
919/// This function is called when (1) no `id` attribute is specified explicitly,
920/// and (2) the `sectids` document attribute is set.
921///
922/// The ID is generated as described in the AsciiDoc language definition in [How
923/// a section ID is computed].
924///
925/// [How a section ID is computed](https://docs.asciidoctor.org/asciidoc/latest/sections/auto-ids/)
926fn generate_section_id(title: &str, parser: &Parser) -> String {
927    let idprefix = parser
928        .attribute_value("idprefix")
929        .as_maybe_str()
930        .unwrap_or_default()
931        .to_owned();
932
933    let idseparator = parser
934        .attribute_value("idseparator")
935        .as_maybe_str()
936        .unwrap_or_default()
937        .to_owned();
938
939    let mut gen_id = title.to_lowercase().to_owned();
940
941    #[allow(clippy::unwrap_used)]
942    static INVALID_SECTION_ID_CHARS: LazyLock<Regex> = LazyLock::new(|| {
943        Regex::new(
944            r"<[^>]+>|&lt;[^&]*&gt;|&(?:[a-z][a-z]+\d{0,2}|#\d{2,5}|#x[\da-f]{2,4});|[^ \w\-.]+",
945        )
946        .unwrap()
947    });
948
949    gen_id = INVALID_SECTION_ID_CHARS
950        .replace_all(&gen_id, "")
951        .to_string();
952
953    // Take only first character of separator if multiple provided.
954    let sep = idseparator
955        .chars()
956        .next()
957        .map(|s| s.to_string())
958        .unwrap_or_default();
959
960    gen_id = gen_id.replace([' ', '.', '-'], &sep);
961
962    if !sep.is_empty() {
963        while gen_id.contains(&format!("{}{}", sep, sep)) {
964            gen_id = gen_id.replace(&format!("{}{}", sep, sep), &sep);
965        }
966
967        if gen_id.ends_with(&sep) {
968            gen_id.pop();
969        }
970
971        // Strip a leading separator (e.g. from a title beginning with a space or
972        // hyphen) before the prefix is applied, matching Ruby Asciidoctor. This
973        // keeps a leading separator out of the final ID and avoids doubling it
974        // up against a non-empty `idprefix` (e.g. `=== {sp}Heading` → `_heading`,
975        // not `__heading`).
976        if gen_id.starts_with(&sep) {
977            gen_id = gen_id[sep.len()..].to_string();
978        }
979    }
980
981    format!("{idprefix}{gen_id}")
982}
983
984/// Represents the type of a section.
985///
986/// This crate currently supports the `appendix` section style, which results in
987/// special section numbering. All other sections are treated as `Normal`
988/// sections.
989#[derive(Clone, Copy, Default, Eq, Hash, PartialEq)]
990pub enum SectionType {
991    /// Most sections are of this type.
992    #[default]
993    Normal,
994
995    /// Represents a section with the style `appendix`.
996    Appendix,
997
998    /// Represents a discrete section heading.
999    /// A discrete section heading will have no nested blocks.
1000    Discrete,
1001}
1002
1003impl std::fmt::Debug for SectionType {
1004    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1005        match self {
1006            SectionType::Normal => write!(f, "SectionType::Normal"),
1007            SectionType::Appendix => write!(f, "SectionType::Appendix"),
1008            SectionType::Discrete => write!(f, "SectionType::Discrete"),
1009        }
1010    }
1011}
1012
1013/// Represents an assigned section number.
1014///
1015/// Section numbers aren't assigned by default, but can be enabled using the
1016/// `sectnums` and `sectnumlevels` attributes as described in [Section Numbers].
1017///
1018/// [Section Numbers]: https://docs.asciidoctor.org/asciidoc/latest/sections/numbers/
1019#[derive(Clone, Default, Eq, Hash, PartialEq)]
1020pub struct SectionNumber {
1021    pub(crate) section_type: SectionType,
1022    pub(crate) components: Vec<usize>,
1023
1024    // The letter (or, more generally, counter value) assigned to the appendix
1025    // this number belongs to, resolved from the `appendix-number` counter
1026    // (e.g. `"A"`, or `"β"` when the document sets `:appendix-number: α`).
1027    // Replaces the first component when the number is displayed. `None` for
1028    // normal section numbers.
1029    pub(crate) appendix_letter: Option<String>,
1030}
1031
1032impl SectionNumber {
1033    /// Generate the next section number for the specified level, based on this
1034    /// section number.
1035    ///
1036    /// `level` should be between 1 and 5, though this is not enforced.
1037    pub(crate) fn assign_next_number(&mut self, level: usize) {
1038        // Drop any ID components beyond the desired level.
1039        self.components.truncate(level);
1040
1041        if self.components.len() < level {
1042            self.components.resize(level, 1);
1043        } else if level > 0
1044            && let Some(component) = self.components.get_mut(level - 1)
1045        {
1046            *component += 1;
1047        }
1048    }
1049
1050    /// Iterate over the components of the section number.
1051    pub fn components(&self) -> &[usize] {
1052        &self.components
1053    }
1054
1055    /// Return the letter (or, more generally, `appendix-number` counter value)
1056    /// assigned to the appendix this number belongs to (e.g. `"A"`, or `"β"`
1057    /// when the document sets `:appendix-number: α`).
1058    ///
1059    /// Returns `None` for normal section numbers.
1060    pub fn appendix_letter(&self) -> Option<&str> {
1061        self.appendix_letter.as_deref()
1062    }
1063}
1064
1065impl fmt::Display for SectionNumber {
1066    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1067        f.write_str(
1068            &self
1069                .components
1070                .iter()
1071                .enumerate()
1072                .map(|(index, x)| {
1073                    if index == 0 && self.section_type == SectionType::Appendix {
1074                        // A parsed appendix number always carries its letter;
1075                        // the A, B, … derivation covers directly-constructed
1076                        // values that don't.
1077                        if let Some(letter) = &self.appendix_letter {
1078                            letter.clone()
1079                        } else {
1080                            char::from_u32(b'A' as u32 + (x - 1) as u32)
1081                                .unwrap_or('?')
1082                                .to_string()
1083                        }
1084                    } else {
1085                        x.to_string()
1086                    }
1087                })
1088                .collect::<Vec<String>>()
1089                .join("."),
1090        )
1091    }
1092}
1093
1094impl fmt::Debug for SectionNumber {
1095    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1096        f.debug_struct("SectionNumber")
1097            .field("section_type", &self.section_type)
1098            .field("components", &DebugSliceReference(&self.components))
1099            .field("appendix_letter", &self.appendix_letter)
1100            .finish()
1101    }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    #![allow(clippy::panic)]
1107    #![allow(clippy::unwrap_used)]
1108
1109    use crate::{
1110        blocks::{metadata::BlockMetadata, section::SectionType},
1111        tests::prelude::*,
1112    };
1113
1114    #[test]
1115    fn impl_clone() {
1116        // Silly test to mark the #[derive(...)] line as covered.
1117        let mut parser = Parser::default();
1118        let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1119
1120        let b1 = crate::blocks::SectionBlock::parse(
1121            &BlockMetadata::new("== Section Title"),
1122            &mut parser,
1123            &mut warnings,
1124        )
1125        .unwrap();
1126
1127        let b2 = b1.item.clone();
1128        assert_eq!(b1.item, b2);
1129    }
1130
1131    #[test]
1132    fn err_empty_source() {
1133        let mut parser = Parser::default();
1134        let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1135
1136        assert!(
1137            crate::blocks::SectionBlock::parse(&BlockMetadata::new(""), &mut parser, &mut warnings)
1138                .is_none()
1139        );
1140    }
1141
1142    #[test]
1143    fn err_only_spaces() {
1144        let mut parser = Parser::default();
1145        let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1146
1147        assert!(
1148            crate::blocks::SectionBlock::parse(
1149                &BlockMetadata::new("    "),
1150                &mut parser,
1151                &mut warnings
1152            )
1153            .is_none()
1154        );
1155    }
1156
1157    #[test]
1158    fn err_not_section() {
1159        let mut parser = Parser::default();
1160        let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1161
1162        assert!(
1163            crate::blocks::SectionBlock::parse(
1164                &BlockMetadata::new("blah blah"),
1165                &mut parser,
1166                &mut warnings
1167            )
1168            .is_none()
1169        );
1170    }
1171
1172    mod asciidoc_style_headers {
1173        use std::ops::Deref;
1174
1175        use crate::{
1176            blocks::{ContentModel, MediaType, metadata::BlockMetadata, section::SectionType},
1177            tests::prelude::*,
1178        };
1179
1180        #[test]
1181        fn err_missing_space_before_title() {
1182            let mut parser = Parser::default();
1183            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1184
1185            assert!(
1186                crate::blocks::SectionBlock::parse(
1187                    &BlockMetadata::new("=blah blah"),
1188                    &mut parser,
1189                    &mut warnings
1190                )
1191                .is_none()
1192            );
1193        }
1194
1195        #[test]
1196        fn simplest_section_block() {
1197            let mut parser = Parser::default();
1198            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1199
1200            let mi = crate::blocks::SectionBlock::parse(
1201                &BlockMetadata::new("== Section Title"),
1202                &mut parser,
1203                &mut warnings,
1204            )
1205            .unwrap();
1206
1207            assert_eq!(mi.item.content_model(), ContentModel::Compound);
1208            assert_eq!(mi.item.raw_context().deref(), "section");
1209            assert_eq!(mi.item.resolved_context().deref(), "section");
1210            assert!(mi.item.declared_style().is_none());
1211            assert_eq!(mi.item.id().unwrap(), "_section_title");
1212            assert!(mi.item.roles().is_empty());
1213            assert!(mi.item.options().is_empty());
1214            assert!(mi.item.title_source().is_none());
1215            assert!(mi.item.title().is_none());
1216            assert!(mi.item.anchor().is_none());
1217            assert!(mi.item.anchor_reftext().is_none());
1218            assert!(mi.item.attrlist().is_none());
1219            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1220
1221            assert_eq!(
1222                mi.item,
1223                SectionBlock {
1224                    level: 1,
1225                    section_title: Content {
1226                        original: Span {
1227                            data: "Section Title",
1228                            line: 1,
1229                            col: 4,
1230                            offset: 3,
1231                        },
1232                        rendered: "Section Title",
1233                    },
1234                    blocks: &[],
1235                    source: Span {
1236                        data: "== Section Title",
1237                        line: 1,
1238                        col: 1,
1239                        offset: 0,
1240                    },
1241                    title_source: None,
1242                    title: None,
1243                    anchor: None,
1244                    anchor_reftext: None,
1245                    attrlist: None,
1246                    section_type: SectionType::Normal,
1247                    section_id: Some("_section_title"),
1248                    caption: None,
1249                    section_number: None,
1250                }
1251            );
1252
1253            assert_eq!(
1254                mi.after,
1255                Span {
1256                    data: "",
1257                    line: 1,
1258                    col: 17,
1259                    offset: 16
1260                }
1261            );
1262        }
1263
1264        #[test]
1265        fn has_child_block() {
1266            let mut parser = Parser::default();
1267            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1268
1269            let mi = crate::blocks::SectionBlock::parse(
1270                &BlockMetadata::new("== Section Title\n\nabc"),
1271                &mut parser,
1272                &mut warnings,
1273            )
1274            .unwrap();
1275
1276            assert_eq!(mi.item.content_model(), ContentModel::Compound);
1277            assert_eq!(mi.item.raw_context().deref(), "section");
1278            assert_eq!(mi.item.resolved_context().deref(), "section");
1279            assert!(mi.item.declared_style().is_none());
1280            assert_eq!(mi.item.id().unwrap(), "_section_title");
1281            assert!(mi.item.roles().is_empty());
1282            assert!(mi.item.options().is_empty());
1283            assert!(mi.item.title_source().is_none());
1284            assert!(mi.item.title().is_none());
1285            assert!(mi.item.anchor().is_none());
1286            assert!(mi.item.anchor_reftext().is_none());
1287            assert!(mi.item.attrlist().is_none());
1288            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1289
1290            assert_eq!(
1291                mi.item,
1292                SectionBlock {
1293                    level: 1,
1294                    section_title: Content {
1295                        original: Span {
1296                            data: "Section Title",
1297                            line: 1,
1298                            col: 4,
1299                            offset: 3,
1300                        },
1301                        rendered: "Section Title",
1302                    },
1303                    blocks: &[Block::Simple(SimpleBlock {
1304                        content: Content {
1305                            original: Span {
1306                                data: "abc",
1307                                line: 3,
1308                                col: 1,
1309                                offset: 18,
1310                            },
1311                            rendered: "abc",
1312                        },
1313                        source: Span {
1314                            data: "abc",
1315                            line: 3,
1316                            col: 1,
1317                            offset: 18,
1318                        },
1319                        style: SimpleBlockStyle::Paragraph,
1320                        title_source: None,
1321                        title: None,
1322                        caption: None,
1323                        number: None,
1324                        anchor: None,
1325                        anchor_reftext: None,
1326                        attrlist: None,
1327                    })],
1328                    source: Span {
1329                        data: "== Section Title\n\nabc",
1330                        line: 1,
1331                        col: 1,
1332                        offset: 0,
1333                    },
1334                    title_source: None,
1335                    title: None,
1336                    anchor: None,
1337                    anchor_reftext: None,
1338                    attrlist: None,
1339                    section_type: SectionType::Normal,
1340                    section_id: Some("_section_title"),
1341                    caption: None,
1342                    section_number: None,
1343                }
1344            );
1345
1346            assert_eq!(
1347                mi.after,
1348                Span {
1349                    data: "",
1350                    line: 3,
1351                    col: 4,
1352                    offset: 21
1353                }
1354            );
1355        }
1356
1357        #[test]
1358        fn has_macro_block_with_extra_blank_line() {
1359            let mut parser = Parser::default();
1360            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1361
1362            let mi = crate::blocks::SectionBlock::parse(
1363                &BlockMetadata::new(
1364                    "== Section Title\n\nimage::bar[alt=Sunset,width=300,height=400]\n\n",
1365                ),
1366                &mut parser,
1367                &mut warnings,
1368            )
1369            .unwrap();
1370
1371            assert_eq!(mi.item.content_model(), ContentModel::Compound);
1372            assert_eq!(mi.item.raw_context().deref(), "section");
1373            assert_eq!(mi.item.resolved_context().deref(), "section");
1374            assert!(mi.item.declared_style().is_none());
1375            assert_eq!(mi.item.id().unwrap(), "_section_title");
1376            assert!(mi.item.roles().is_empty());
1377            assert!(mi.item.options().is_empty());
1378            assert!(mi.item.title_source().is_none());
1379            assert!(mi.item.title().is_none());
1380            assert!(mi.item.anchor().is_none());
1381            assert!(mi.item.anchor_reftext().is_none());
1382            assert!(mi.item.attrlist().is_none());
1383            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1384
1385            assert_eq!(
1386                mi.item,
1387                SectionBlock {
1388                    level: 1,
1389                    section_title: Content {
1390                        original: Span {
1391                            data: "Section Title",
1392                            line: 1,
1393                            col: 4,
1394                            offset: 3,
1395                        },
1396                        rendered: "Section Title",
1397                    },
1398                    blocks: &[Block::Media(MediaBlock {
1399                        type_: MediaType::Image,
1400                        target: Span {
1401                            data: "bar",
1402                            line: 3,
1403                            col: 8,
1404                            offset: 25,
1405                        },
1406                        macro_attrlist: Attrlist {
1407                            attributes: &[
1408                                ElementAttribute {
1409                                    name: Some("alt"),
1410                                    shorthand_items: &[],
1411                                    value: "Sunset"
1412                                },
1413                                ElementAttribute {
1414                                    name: Some("width"),
1415                                    shorthand_items: &[],
1416                                    value: "300"
1417                                },
1418                                ElementAttribute {
1419                                    name: Some("height"),
1420                                    shorthand_items: &[],
1421                                    value: "400"
1422                                }
1423                            ],
1424                            anchor: None,
1425                            source: Span {
1426                                data: "alt=Sunset,width=300,height=400",
1427                                line: 3,
1428                                col: 12,
1429                                offset: 29,
1430                            }
1431                        },
1432                        source: Span {
1433                            data: "image::bar[alt=Sunset,width=300,height=400]",
1434                            line: 3,
1435                            col: 1,
1436                            offset: 18,
1437                        },
1438                        title_source: None,
1439                        title: None,
1440                        caption: None,
1441                        number: None,
1442                        anchor: None,
1443                        anchor_reftext: None,
1444                        attrlist: None,
1445                    })],
1446                    source: Span {
1447                        data: "== Section Title\n\nimage::bar[alt=Sunset,width=300,height=400]",
1448                        line: 1,
1449                        col: 1,
1450                        offset: 0,
1451                    },
1452                    title_source: None,
1453                    title: None,
1454                    anchor: None,
1455                    anchor_reftext: None,
1456                    attrlist: None,
1457                    section_type: SectionType::Normal,
1458                    section_id: Some("_section_title"),
1459                    caption: None,
1460                    section_number: None,
1461                }
1462            );
1463
1464            assert_eq!(
1465                mi.after,
1466                Span {
1467                    data: "",
1468                    line: 5,
1469                    col: 1,
1470                    offset: 63
1471                }
1472            );
1473        }
1474
1475        #[test]
1476        fn has_child_block_with_errors() {
1477            let mut parser = Parser::default();
1478            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1479
1480            let mi = crate::blocks::SectionBlock::parse(
1481                &BlockMetadata::new(
1482                    "== Section Title\n\nimage::bar[alt=Sunset,width=300,,height=400]",
1483                ),
1484                &mut parser,
1485                &mut warnings,
1486            )
1487            .unwrap();
1488
1489            assert_eq!(mi.item.content_model(), ContentModel::Compound);
1490            assert_eq!(mi.item.raw_context().deref(), "section");
1491            assert_eq!(mi.item.resolved_context().deref(), "section");
1492            assert!(mi.item.declared_style().is_none());
1493            assert_eq!(mi.item.id().unwrap(), "_section_title");
1494            assert!(mi.item.roles().is_empty());
1495            assert!(mi.item.options().is_empty());
1496            assert!(mi.item.title_source().is_none());
1497            assert!(mi.item.title().is_none());
1498            assert!(mi.item.anchor().is_none());
1499            assert!(mi.item.anchor_reftext().is_none());
1500            assert!(mi.item.attrlist().is_none());
1501            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1502
1503            assert_eq!(
1504                mi.item,
1505                SectionBlock {
1506                    level: 1,
1507                    section_title: Content {
1508                        original: Span {
1509                            data: "Section Title",
1510                            line: 1,
1511                            col: 4,
1512                            offset: 3,
1513                        },
1514                        rendered: "Section Title",
1515                    },
1516                    blocks: &[Block::Media(MediaBlock {
1517                        type_: MediaType::Image,
1518                        target: Span {
1519                            data: "bar",
1520                            line: 3,
1521                            col: 8,
1522                            offset: 25,
1523                        },
1524                        macro_attrlist: Attrlist {
1525                            attributes: &[
1526                                ElementAttribute {
1527                                    name: Some("alt"),
1528                                    shorthand_items: &[],
1529                                    value: "Sunset"
1530                                },
1531                                ElementAttribute {
1532                                    name: Some("width"),
1533                                    shorthand_items: &[],
1534                                    value: "300"
1535                                },
1536                                ElementAttribute {
1537                                    name: Some("height"),
1538                                    shorthand_items: &[],
1539                                    value: "400"
1540                                }
1541                            ],
1542                            anchor: None,
1543                            source: Span {
1544                                data: "alt=Sunset,width=300,,height=400",
1545                                line: 3,
1546                                col: 12,
1547                                offset: 29,
1548                            }
1549                        },
1550                        source: Span {
1551                            data: "image::bar[alt=Sunset,width=300,,height=400]",
1552                            line: 3,
1553                            col: 1,
1554                            offset: 18,
1555                        },
1556                        title_source: None,
1557                        title: None,
1558                        caption: None,
1559                        number: None,
1560                        anchor: None,
1561                        anchor_reftext: None,
1562                        attrlist: None,
1563                    })],
1564                    source: Span {
1565                        data: "== Section Title\n\nimage::bar[alt=Sunset,width=300,,height=400]",
1566                        line: 1,
1567                        col: 1,
1568                        offset: 0,
1569                    },
1570                    title_source: None,
1571                    title: None,
1572                    anchor: None,
1573                    anchor_reftext: None,
1574                    attrlist: None,
1575                    section_type: SectionType::Normal,
1576                    section_id: Some("_section_title"),
1577                    caption: None,
1578                    section_number: None,
1579                }
1580            );
1581
1582            assert_eq!(
1583                mi.after,
1584                Span {
1585                    data: "",
1586                    line: 3,
1587                    col: 45,
1588                    offset: 62
1589                }
1590            );
1591
1592            assert_eq!(
1593                warnings,
1594                vec![Warning {
1595                    source: Span {
1596                        data: "alt=Sunset,width=300,,height=400",
1597                        line: 3,
1598                        col: 12,
1599                        offset: 29,
1600                    },
1601                    warning: WarningType::EmptyAttributeValue,
1602                }]
1603            );
1604        }
1605
1606        #[test]
1607        fn dont_stop_at_child_section() {
1608            let mut parser = Parser::default();
1609            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1610
1611            let mi = crate::blocks::SectionBlock::parse(
1612                &BlockMetadata::new("== Section Title\n\nabc\n\n=== Section 2\n\ndef"),
1613                &mut parser,
1614                &mut warnings,
1615            )
1616            .unwrap();
1617
1618            assert_eq!(mi.item.content_model(), ContentModel::Compound);
1619            assert_eq!(mi.item.raw_context().deref(), "section");
1620            assert_eq!(mi.item.resolved_context().deref(), "section");
1621            assert!(mi.item.declared_style().is_none());
1622            assert_eq!(mi.item.id().unwrap(), "_section_title");
1623            assert!(mi.item.roles().is_empty());
1624            assert!(mi.item.options().is_empty());
1625            assert!(mi.item.title_source().is_none());
1626            assert!(mi.item.title().is_none());
1627            assert!(mi.item.anchor().is_none());
1628            assert!(mi.item.anchor_reftext().is_none());
1629            assert!(mi.item.attrlist().is_none());
1630            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1631
1632            assert_eq!(
1633                mi.item,
1634                SectionBlock {
1635                    level: 1,
1636                    section_title: Content {
1637                        original: Span {
1638                            data: "Section Title",
1639                            line: 1,
1640                            col: 4,
1641                            offset: 3,
1642                        },
1643                        rendered: "Section Title",
1644                    },
1645                    blocks: &[
1646                        Block::Simple(SimpleBlock {
1647                            content: Content {
1648                                original: Span {
1649                                    data: "abc",
1650                                    line: 3,
1651                                    col: 1,
1652                                    offset: 18,
1653                                },
1654                                rendered: "abc",
1655                            },
1656                            source: Span {
1657                                data: "abc",
1658                                line: 3,
1659                                col: 1,
1660                                offset: 18,
1661                            },
1662                            style: SimpleBlockStyle::Paragraph,
1663                            title_source: None,
1664                            title: None,
1665                            caption: None,
1666                            number: None,
1667                            anchor: None,
1668                            anchor_reftext: None,
1669                            attrlist: None,
1670                        }),
1671                        Block::Section(SectionBlock {
1672                            level: 2,
1673                            section_title: Content {
1674                                original: Span {
1675                                    data: "Section 2",
1676                                    line: 5,
1677                                    col: 5,
1678                                    offset: 27,
1679                                },
1680                                rendered: "Section 2",
1681                            },
1682                            blocks: &[Block::Simple(SimpleBlock {
1683                                content: Content {
1684                                    original: Span {
1685                                        data: "def",
1686                                        line: 7,
1687                                        col: 1,
1688                                        offset: 38,
1689                                    },
1690                                    rendered: "def",
1691                                },
1692                                source: Span {
1693                                    data: "def",
1694                                    line: 7,
1695                                    col: 1,
1696                                    offset: 38,
1697                                },
1698                                style: SimpleBlockStyle::Paragraph,
1699                                title_source: None,
1700                                title: None,
1701                                caption: None,
1702                                number: None,
1703                                anchor: None,
1704                                anchor_reftext: None,
1705                                attrlist: None,
1706                            })],
1707                            source: Span {
1708                                data: "=== Section 2\n\ndef",
1709                                line: 5,
1710                                col: 1,
1711                                offset: 23,
1712                            },
1713                            title_source: None,
1714                            title: None,
1715                            anchor: None,
1716                            anchor_reftext: None,
1717                            attrlist: None,
1718                            section_type: SectionType::Normal,
1719                            section_id: Some("_section_2"),
1720                            caption: None,
1721                            section_number: None,
1722                        })
1723                    ],
1724                    source: Span {
1725                        data: "== Section Title\n\nabc\n\n=== Section 2\n\ndef",
1726                        line: 1,
1727                        col: 1,
1728                        offset: 0,
1729                    },
1730                    title_source: None,
1731                    title: None,
1732                    anchor: None,
1733                    anchor_reftext: None,
1734                    attrlist: None,
1735                    section_type: SectionType::Normal,
1736                    section_id: Some("_section_title"),
1737                    caption: None,
1738                    section_number: None,
1739                }
1740            );
1741
1742            assert_eq!(
1743                mi.after,
1744                Span {
1745                    data: "",
1746                    line: 7,
1747                    col: 4,
1748                    offset: 41
1749                }
1750            );
1751        }
1752
1753        #[test]
1754        fn stop_at_peer_section() {
1755            let mut parser = Parser::default();
1756            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1757
1758            let mi = crate::blocks::SectionBlock::parse(
1759                &BlockMetadata::new("== Section Title\n\nabc\n\n== Section 2\n\ndef"),
1760                &mut parser,
1761                &mut warnings,
1762            )
1763            .unwrap();
1764
1765            assert_eq!(mi.item.content_model(), ContentModel::Compound);
1766            assert_eq!(mi.item.raw_context().deref(), "section");
1767            assert_eq!(mi.item.resolved_context().deref(), "section");
1768            assert!(mi.item.declared_style().is_none());
1769            assert_eq!(mi.item.id().unwrap(), "_section_title");
1770            assert!(mi.item.roles().is_empty());
1771            assert!(mi.item.options().is_empty());
1772            assert!(mi.item.title_source().is_none());
1773            assert!(mi.item.title().is_none());
1774            assert!(mi.item.anchor().is_none());
1775            assert!(mi.item.anchor_reftext().is_none());
1776            assert!(mi.item.attrlist().is_none());
1777            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1778
1779            assert_eq!(
1780                mi.item,
1781                SectionBlock {
1782                    level: 1,
1783                    section_title: Content {
1784                        original: Span {
1785                            data: "Section Title",
1786                            line: 1,
1787                            col: 4,
1788                            offset: 3,
1789                        },
1790                        rendered: "Section Title",
1791                    },
1792                    blocks: &[Block::Simple(SimpleBlock {
1793                        content: Content {
1794                            original: Span {
1795                                data: "abc",
1796                                line: 3,
1797                                col: 1,
1798                                offset: 18,
1799                            },
1800                            rendered: "abc",
1801                        },
1802                        source: Span {
1803                            data: "abc",
1804                            line: 3,
1805                            col: 1,
1806                            offset: 18,
1807                        },
1808                        style: SimpleBlockStyle::Paragraph,
1809                        title_source: None,
1810                        title: None,
1811                        caption: None,
1812                        number: None,
1813                        anchor: None,
1814                        anchor_reftext: None,
1815                        attrlist: None,
1816                    })],
1817                    source: Span {
1818                        data: "== Section Title\n\nabc",
1819                        line: 1,
1820                        col: 1,
1821                        offset: 0,
1822                    },
1823                    title_source: None,
1824                    title: None,
1825                    anchor: None,
1826                    anchor_reftext: None,
1827                    attrlist: None,
1828                    section_type: SectionType::Normal,
1829                    section_id: Some("_section_title"),
1830                    caption: None,
1831                    section_number: None,
1832                }
1833            );
1834
1835            assert_eq!(
1836                mi.after,
1837                Span {
1838                    data: "== Section 2\n\ndef",
1839                    line: 5,
1840                    col: 1,
1841                    offset: 23
1842                }
1843            );
1844        }
1845
1846        #[test]
1847        fn stop_at_ancestor_section() {
1848            let mut parser = Parser::default();
1849            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1850
1851            let mi = crate::blocks::SectionBlock::parse(
1852                &BlockMetadata::new("=== Section Title\n\nabc\n\n== Section 2\n\ndef"),
1853                &mut parser,
1854                &mut warnings,
1855            )
1856            .unwrap();
1857
1858            assert_eq!(mi.item.content_model(), ContentModel::Compound);
1859            assert_eq!(mi.item.raw_context().deref(), "section");
1860            assert_eq!(mi.item.resolved_context().deref(), "section");
1861            assert!(mi.item.declared_style().is_none());
1862            assert_eq!(mi.item.id().unwrap(), "_section_title");
1863            assert!(mi.item.roles().is_empty());
1864            assert!(mi.item.options().is_empty());
1865            assert!(mi.item.title_source().is_none());
1866            assert!(mi.item.title().is_none());
1867            assert!(mi.item.anchor().is_none());
1868            assert!(mi.item.anchor_reftext().is_none());
1869            assert!(mi.item.attrlist().is_none());
1870            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1871
1872            assert_eq!(
1873                mi.item,
1874                SectionBlock {
1875                    level: 2,
1876                    section_title: Content {
1877                        original: Span {
1878                            data: "Section Title",
1879                            line: 1,
1880                            col: 5,
1881                            offset: 4,
1882                        },
1883                        rendered: "Section Title",
1884                    },
1885                    blocks: &[Block::Simple(SimpleBlock {
1886                        content: Content {
1887                            original: Span {
1888                                data: "abc",
1889                                line: 3,
1890                                col: 1,
1891                                offset: 19,
1892                            },
1893                            rendered: "abc",
1894                        },
1895                        source: Span {
1896                            data: "abc",
1897                            line: 3,
1898                            col: 1,
1899                            offset: 19,
1900                        },
1901                        style: SimpleBlockStyle::Paragraph,
1902                        title_source: None,
1903                        title: None,
1904                        caption: None,
1905                        number: None,
1906                        anchor: None,
1907                        anchor_reftext: None,
1908                        attrlist: None,
1909                    })],
1910                    source: Span {
1911                        data: "=== Section Title\n\nabc",
1912                        line: 1,
1913                        col: 1,
1914                        offset: 0,
1915                    },
1916                    title_source: None,
1917                    title: None,
1918                    anchor: None,
1919                    anchor_reftext: None,
1920                    attrlist: None,
1921                    section_type: SectionType::Normal,
1922                    section_id: Some("_section_title"),
1923                    caption: None,
1924                    section_number: None,
1925                }
1926            );
1927
1928            assert_eq!(
1929                mi.after,
1930                Span {
1931                    data: "== Section 2\n\ndef",
1932                    line: 5,
1933                    col: 1,
1934                    offset: 24
1935                }
1936            );
1937        }
1938
1939        #[test]
1940        fn comment_transfer_boundary_respects_leveloffset() {
1941            // Under `:leveloffset: +2`, `== Parent` is level 3 and a bare
1942            // `= Child` is level 2 – an ancestor that must end Parent. The
1943            // `[[x]]` anchor and the `// comment` before `= Child` transfer to
1944            // it, and the section-boundary look-ahead must apply the active
1945            // `leveloffset` (rather than a default offset of zero) so `= Child`
1946            // is recognized as the boundary and lands as a sibling of Parent,
1947            // not nested inside it. Regression test for the boundary check
1948            // reading the offset from its throwaway parser.
1949            let doc = Parser::default().parse(
1950                "= Doc\n:leveloffset: +2\n\n== Parent\n\npara\n\n[[x]]\n// comment\n= Child\n\nbody",
1951            );
1952
1953            let top: Vec<_> = doc.child_blocks().collect();
1954            assert_eq!(top.len(), 2, "Child must be a sibling of Parent");
1955
1956            let parent = top.first().unwrap();
1957            let child = top.last().unwrap();
1958            assert_eq!(parent.id(), Some("_parent"));
1959
1960            // The transferred anchor gives Child its id, and Child owns the
1961            // following paragraph as a child rather than sitting under Parent.
1962            assert_eq!(child.id(), Some("x"));
1963            assert!(
1964                parent
1965                    .child_blocks()
1966                    .all(|b| b.raw_context().as_ref() != "section")
1967            );
1968        }
1969
1970        #[test]
1971        fn section_title_with_markup() {
1972            let mut parser = Parser::default();
1973            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
1974
1975            let mi = crate::blocks::SectionBlock::parse(
1976                &BlockMetadata::new("== Section with *bold* text"),
1977                &mut parser,
1978                &mut warnings,
1979            )
1980            .unwrap();
1981
1982            assert_eq!(
1983                mi.item.section_title_source(),
1984                Span {
1985                    data: "Section with *bold* text",
1986                    line: 1,
1987                    col: 4,
1988                    offset: 3,
1989                }
1990            );
1991
1992            assert_eq!(
1993                mi.item.section_title(),
1994                "Section with <strong>bold</strong> text"
1995            );
1996
1997            assert_eq!(mi.item.section_type(), SectionType::Normal);
1998            assert_eq!(mi.item.id().unwrap(), "_section_with_bold_text");
1999        }
2000
2001        #[test]
2002        fn section_title_with_special_chars() {
2003            let mut parser = Parser::default();
2004            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2005
2006            let mi = crate::blocks::SectionBlock::parse(
2007                &BlockMetadata::new("== Section with <brackets> & ampersands"),
2008                &mut parser,
2009                &mut warnings,
2010            )
2011            .unwrap();
2012
2013            assert_eq!(
2014                mi.item.section_title_source(),
2015                Span {
2016                    data: "Section with <brackets> & ampersands",
2017                    line: 1,
2018                    col: 4,
2019                    offset: 3,
2020                }
2021            );
2022
2023            assert_eq!(
2024                mi.item.section_title(),
2025                "Section with &lt;brackets&gt; &amp; ampersands"
2026            );
2027
2028            assert_eq!(mi.item.id().unwrap(), "_section_with_ampersands");
2029        }
2030
2031        #[test]
2032        fn err_level_0_section_heading() {
2033            let mut parser = Parser::default();
2034            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2035
2036            let result = crate::blocks::SectionBlock::parse(
2037                &BlockMetadata::new("= Document Title"),
2038                &mut parser,
2039                &mut warnings,
2040            );
2041
2042            assert!(result.is_none());
2043
2044            assert_eq!(
2045                warnings,
2046                vec![Warning {
2047                    source: Span {
2048                        data: "= Document Title",
2049                        line: 1,
2050                        col: 1,
2051                        offset: 0,
2052                    },
2053                    warning: WarningType::Level0SectionHeadingNotSupported,
2054                }]
2055            );
2056        }
2057
2058        #[test]
2059        fn err_section_heading_level_exceeds_maximum() {
2060            let mut parser = Parser::default();
2061            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2062
2063            let result = crate::blocks::SectionBlock::parse(
2064                &BlockMetadata::new("======= Level 6 Section"),
2065                &mut parser,
2066                &mut warnings,
2067            );
2068
2069            assert!(result.is_none());
2070
2071            assert_eq!(
2072                warnings,
2073                vec![Warning {
2074                    source: Span {
2075                        data: "======= Level 6 Section",
2076                        line: 1,
2077                        col: 1,
2078                        offset: 0,
2079                    },
2080                    warning: WarningType::SectionHeadingLevelExceedsMaximum(6),
2081                }]
2082            );
2083        }
2084
2085        #[test]
2086        fn valid_maximum_level_5_section() {
2087            let mut parser = Parser::default();
2088            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2089
2090            let mi = crate::blocks::SectionBlock::parse(
2091                &BlockMetadata::new("====== Level 5 Section"),
2092                &mut parser,
2093                &mut warnings,
2094            )
2095            .unwrap();
2096
2097            assert!(warnings.is_empty());
2098
2099            assert_eq!(mi.item.level(), 5);
2100            assert_eq!(mi.item.section_title(), "Level 5 Section");
2101            assert_eq!(mi.item.section_type(), SectionType::Normal);
2102            assert_eq!(mi.item.id().unwrap(), "_level_5_section");
2103        }
2104
2105        #[test]
2106        fn warn_section_level_skipped() {
2107            let mut parser = Parser::default();
2108            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2109
2110            let mi = crate::blocks::SectionBlock::parse(
2111                &BlockMetadata::new("== Level 1\n\n==== Level 3 (skipped level 2)"),
2112                &mut parser,
2113                &mut warnings,
2114            )
2115            .unwrap();
2116
2117            assert_eq!(mi.item.level(), 1);
2118            assert_eq!(mi.item.section_title(), "Level 1");
2119            assert_eq!(mi.item.section_type(), SectionType::Normal);
2120            assert_eq!(mi.item.child_blocks().count(), 1);
2121            assert_eq!(mi.item.id().unwrap(), "_level_1");
2122
2123            assert_eq!(
2124                warnings,
2125                vec![Warning {
2126                    source: Span {
2127                        data: "==== Level 3 (skipped level 2)",
2128                        line: 3,
2129                        col: 1,
2130                        offset: 12,
2131                    },
2132                    warning: WarningType::SectionHeadingLevelSkipped(1, 3),
2133                }]
2134            );
2135        }
2136    }
2137
2138    mod markdown_style_headings {
2139        use std::ops::Deref;
2140
2141        use crate::{
2142            blocks::{ContentModel, MediaType, metadata::BlockMetadata, section::SectionType},
2143            tests::prelude::*,
2144        };
2145
2146        #[test]
2147        fn err_missing_space_before_title() {
2148            let mut parser = Parser::default();
2149            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2150
2151            assert!(
2152                crate::blocks::SectionBlock::parse(
2153                    &BlockMetadata::new("#blah blah"),
2154                    &mut parser,
2155                    &mut warnings
2156                )
2157                .is_none()
2158            );
2159        }
2160
2161        #[test]
2162        fn simplest_section_block() {
2163            let mut parser = Parser::default();
2164            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2165
2166            let mi = crate::blocks::SectionBlock::parse(
2167                &BlockMetadata::new("## Section Title"),
2168                &mut parser,
2169                &mut warnings,
2170            )
2171            .unwrap();
2172
2173            assert_eq!(mi.item.content_model(), ContentModel::Compound);
2174            assert_eq!(mi.item.raw_context().deref(), "section");
2175            assert_eq!(mi.item.resolved_context().deref(), "section");
2176            assert!(mi.item.declared_style().is_none());
2177            assert_eq!(mi.item.id().unwrap(), "_section_title");
2178            assert!(mi.item.roles().is_empty());
2179            assert!(mi.item.options().is_empty());
2180            assert!(mi.item.title_source().is_none());
2181            assert!(mi.item.title().is_none());
2182            assert!(mi.item.anchor().is_none());
2183            assert!(mi.item.anchor_reftext().is_none());
2184            assert!(mi.item.attrlist().is_none());
2185            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
2186
2187            assert_eq!(
2188                mi.item,
2189                SectionBlock {
2190                    level: 1,
2191                    section_title: Content {
2192                        original: Span {
2193                            data: "Section Title",
2194                            line: 1,
2195                            col: 4,
2196                            offset: 3,
2197                        },
2198                        rendered: "Section Title",
2199                    },
2200                    blocks: &[],
2201                    source: Span {
2202                        data: "## Section Title",
2203                        line: 1,
2204                        col: 1,
2205                        offset: 0,
2206                    },
2207                    title_source: None,
2208                    title: None,
2209                    anchor: None,
2210                    anchor_reftext: None,
2211                    attrlist: None,
2212                    section_type: SectionType::Normal,
2213                    section_id: Some("_section_title"),
2214                    caption: None,
2215                    section_number: None,
2216                }
2217            );
2218
2219            assert_eq!(
2220                mi.after,
2221                Span {
2222                    data: "",
2223                    line: 1,
2224                    col: 17,
2225                    offset: 16
2226                }
2227            );
2228        }
2229
2230        #[test]
2231        fn has_child_block() {
2232            let mut parser = Parser::default();
2233            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2234
2235            let mi = crate::blocks::SectionBlock::parse(
2236                &BlockMetadata::new("## Section Title\n\nabc"),
2237                &mut parser,
2238                &mut warnings,
2239            )
2240            .unwrap();
2241
2242            assert_eq!(mi.item.content_model(), ContentModel::Compound);
2243            assert_eq!(mi.item.raw_context().deref(), "section");
2244            assert_eq!(mi.item.resolved_context().deref(), "section");
2245            assert!(mi.item.declared_style().is_none());
2246            assert_eq!(mi.item.id().unwrap(), "_section_title");
2247            assert!(mi.item.roles().is_empty());
2248            assert!(mi.item.options().is_empty());
2249            assert!(mi.item.title_source().is_none());
2250            assert!(mi.item.title().is_none());
2251            assert!(mi.item.anchor().is_none());
2252            assert!(mi.item.anchor_reftext().is_none());
2253            assert!(mi.item.attrlist().is_none());
2254            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
2255
2256            assert_eq!(
2257                mi.item,
2258                SectionBlock {
2259                    level: 1,
2260                    section_title: Content {
2261                        original: Span {
2262                            data: "Section Title",
2263                            line: 1,
2264                            col: 4,
2265                            offset: 3,
2266                        },
2267                        rendered: "Section Title",
2268                    },
2269                    blocks: &[Block::Simple(SimpleBlock {
2270                        content: Content {
2271                            original: Span {
2272                                data: "abc",
2273                                line: 3,
2274                                col: 1,
2275                                offset: 18,
2276                            },
2277                            rendered: "abc",
2278                        },
2279                        source: Span {
2280                            data: "abc",
2281                            line: 3,
2282                            col: 1,
2283                            offset: 18,
2284                        },
2285                        style: SimpleBlockStyle::Paragraph,
2286                        title_source: None,
2287                        title: None,
2288                        caption: None,
2289                        number: None,
2290                        anchor: None,
2291                        anchor_reftext: None,
2292                        attrlist: None,
2293                    })],
2294                    source: Span {
2295                        data: "## Section Title\n\nabc",
2296                        line: 1,
2297                        col: 1,
2298                        offset: 0,
2299                    },
2300                    title_source: None,
2301                    title: None,
2302                    anchor: None,
2303                    anchor_reftext: None,
2304                    attrlist: None,
2305                    section_type: SectionType::Normal,
2306                    section_id: Some("_section_title"),
2307                    caption: None,
2308                    section_number: None,
2309                }
2310            );
2311
2312            assert_eq!(
2313                mi.after,
2314                Span {
2315                    data: "",
2316                    line: 3,
2317                    col: 4,
2318                    offset: 21
2319                }
2320            );
2321        }
2322
2323        #[test]
2324        fn has_macro_block_with_extra_blank_line() {
2325            let mut parser = Parser::default();
2326            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2327
2328            let mi = crate::blocks::SectionBlock::parse(
2329                &BlockMetadata::new(
2330                    "## Section Title\n\nimage::bar[alt=Sunset,width=300,height=400]\n\n",
2331                ),
2332                &mut parser,
2333                &mut warnings,
2334            )
2335            .unwrap();
2336
2337            assert_eq!(mi.item.content_model(), ContentModel::Compound);
2338            assert_eq!(mi.item.raw_context().deref(), "section");
2339            assert_eq!(mi.item.resolved_context().deref(), "section");
2340            assert!(mi.item.declared_style().is_none());
2341            assert_eq!(mi.item.id().unwrap(), "_section_title");
2342            assert!(mi.item.roles().is_empty());
2343            assert!(mi.item.options().is_empty());
2344            assert!(mi.item.title_source().is_none());
2345            assert!(mi.item.title().is_none());
2346            assert!(mi.item.anchor().is_none());
2347            assert!(mi.item.anchor_reftext().is_none());
2348            assert!(mi.item.attrlist().is_none());
2349            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
2350
2351            assert_eq!(
2352                mi.item,
2353                SectionBlock {
2354                    level: 1,
2355                    section_title: Content {
2356                        original: Span {
2357                            data: "Section Title",
2358                            line: 1,
2359                            col: 4,
2360                            offset: 3,
2361                        },
2362                        rendered: "Section Title",
2363                    },
2364                    blocks: &[Block::Media(MediaBlock {
2365                        type_: MediaType::Image,
2366                        target: Span {
2367                            data: "bar",
2368                            line: 3,
2369                            col: 8,
2370                            offset: 25,
2371                        },
2372                        macro_attrlist: Attrlist {
2373                            attributes: &[
2374                                ElementAttribute {
2375                                    name: Some("alt"),
2376                                    shorthand_items: &[],
2377                                    value: "Sunset"
2378                                },
2379                                ElementAttribute {
2380                                    name: Some("width"),
2381                                    shorthand_items: &[],
2382                                    value: "300"
2383                                },
2384                                ElementAttribute {
2385                                    name: Some("height"),
2386                                    shorthand_items: &[],
2387                                    value: "400"
2388                                }
2389                            ],
2390                            anchor: None,
2391                            source: Span {
2392                                data: "alt=Sunset,width=300,height=400",
2393                                line: 3,
2394                                col: 12,
2395                                offset: 29,
2396                            }
2397                        },
2398                        source: Span {
2399                            data: "image::bar[alt=Sunset,width=300,height=400]",
2400                            line: 3,
2401                            col: 1,
2402                            offset: 18,
2403                        },
2404                        title_source: None,
2405                        title: None,
2406                        caption: None,
2407                        number: None,
2408                        anchor: None,
2409                        anchor_reftext: None,
2410                        attrlist: None,
2411                    })],
2412                    source: Span {
2413                        data: "## Section Title\n\nimage::bar[alt=Sunset,width=300,height=400]",
2414                        line: 1,
2415                        col: 1,
2416                        offset: 0,
2417                    },
2418                    title_source: None,
2419                    title: None,
2420                    anchor: None,
2421                    anchor_reftext: None,
2422                    attrlist: None,
2423                    section_type: SectionType::Normal,
2424                    section_id: Some("_section_title"),
2425                    caption: None,
2426                    section_number: None,
2427                }
2428            );
2429
2430            assert_eq!(
2431                mi.after,
2432                Span {
2433                    data: "",
2434                    line: 5,
2435                    col: 1,
2436                    offset: 63
2437                }
2438            );
2439        }
2440
2441        #[test]
2442        fn has_child_block_with_errors() {
2443            let mut parser = Parser::default();
2444            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2445
2446            let mi = crate::blocks::SectionBlock::parse(
2447                &BlockMetadata::new(
2448                    "## Section Title\n\nimage::bar[alt=Sunset,width=300,,height=400]",
2449                ),
2450                &mut parser,
2451                &mut warnings,
2452            )
2453            .unwrap();
2454
2455            assert_eq!(mi.item.content_model(), ContentModel::Compound);
2456            assert_eq!(mi.item.raw_context().deref(), "section");
2457            assert_eq!(mi.item.resolved_context().deref(), "section");
2458            assert!(mi.item.declared_style().is_none());
2459            assert_eq!(mi.item.id().unwrap(), "_section_title");
2460            assert!(mi.item.roles().is_empty());
2461            assert!(mi.item.options().is_empty());
2462            assert!(mi.item.title_source().is_none());
2463            assert!(mi.item.title().is_none());
2464            assert!(mi.item.anchor().is_none());
2465            assert!(mi.item.anchor_reftext().is_none());
2466            assert!(mi.item.attrlist().is_none());
2467            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
2468
2469            assert_eq!(
2470                mi.item,
2471                SectionBlock {
2472                    level: 1,
2473                    section_title: Content {
2474                        original: Span {
2475                            data: "Section Title",
2476                            line: 1,
2477                            col: 4,
2478                            offset: 3,
2479                        },
2480                        rendered: "Section Title",
2481                    },
2482                    blocks: &[Block::Media(MediaBlock {
2483                        type_: MediaType::Image,
2484                        target: Span {
2485                            data: "bar",
2486                            line: 3,
2487                            col: 8,
2488                            offset: 25,
2489                        },
2490                        macro_attrlist: Attrlist {
2491                            attributes: &[
2492                                ElementAttribute {
2493                                    name: Some("alt"),
2494                                    shorthand_items: &[],
2495                                    value: "Sunset"
2496                                },
2497                                ElementAttribute {
2498                                    name: Some("width"),
2499                                    shorthand_items: &[],
2500                                    value: "300"
2501                                },
2502                                ElementAttribute {
2503                                    name: Some("height"),
2504                                    shorthand_items: &[],
2505                                    value: "400"
2506                                }
2507                            ],
2508                            anchor: None,
2509                            source: Span {
2510                                data: "alt=Sunset,width=300,,height=400",
2511                                line: 3,
2512                                col: 12,
2513                                offset: 29,
2514                            }
2515                        },
2516                        source: Span {
2517                            data: "image::bar[alt=Sunset,width=300,,height=400]",
2518                            line: 3,
2519                            col: 1,
2520                            offset: 18,
2521                        },
2522                        title_source: None,
2523                        title: None,
2524                        caption: None,
2525                        number: None,
2526                        anchor: None,
2527                        anchor_reftext: None,
2528                        attrlist: None,
2529                    })],
2530                    source: Span {
2531                        data: "## Section Title\n\nimage::bar[alt=Sunset,width=300,,height=400]",
2532                        line: 1,
2533                        col: 1,
2534                        offset: 0,
2535                    },
2536                    title_source: None,
2537                    title: None,
2538                    anchor: None,
2539                    anchor_reftext: None,
2540                    attrlist: None,
2541                    section_type: SectionType::Normal,
2542                    section_id: Some("_section_title"),
2543                    caption: None,
2544                    section_number: None,
2545                }
2546            );
2547
2548            assert_eq!(
2549                mi.after,
2550                Span {
2551                    data: "",
2552                    line: 3,
2553                    col: 45,
2554                    offset: 62
2555                }
2556            );
2557
2558            assert_eq!(
2559                warnings,
2560                vec![Warning {
2561                    source: Span {
2562                        data: "alt=Sunset,width=300,,height=400",
2563                        line: 3,
2564                        col: 12,
2565                        offset: 29,
2566                    },
2567                    warning: WarningType::EmptyAttributeValue,
2568                }]
2569            );
2570        }
2571
2572        #[test]
2573        fn dont_stop_at_child_section() {
2574            let mut parser = Parser::default();
2575            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2576
2577            let mi = crate::blocks::SectionBlock::parse(
2578                &BlockMetadata::new("## Section Title\n\nabc\n\n### Section 2\n\ndef"),
2579                &mut parser,
2580                &mut warnings,
2581            )
2582            .unwrap();
2583
2584            assert_eq!(mi.item.content_model(), ContentModel::Compound);
2585            assert_eq!(mi.item.raw_context().deref(), "section");
2586            assert_eq!(mi.item.resolved_context().deref(), "section");
2587            assert!(mi.item.declared_style().is_none());
2588            assert_eq!(mi.item.id().unwrap(), "_section_title");
2589            assert!(mi.item.roles().is_empty());
2590            assert!(mi.item.options().is_empty());
2591            assert!(mi.item.title_source().is_none());
2592            assert!(mi.item.title().is_none());
2593            assert!(mi.item.anchor().is_none());
2594            assert!(mi.item.anchor_reftext().is_none());
2595            assert!(mi.item.attrlist().is_none());
2596            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
2597
2598            assert_eq!(
2599                mi.item,
2600                SectionBlock {
2601                    level: 1,
2602                    section_title: Content {
2603                        original: Span {
2604                            data: "Section Title",
2605                            line: 1,
2606                            col: 4,
2607                            offset: 3,
2608                        },
2609                        rendered: "Section Title",
2610                    },
2611                    blocks: &[
2612                        Block::Simple(SimpleBlock {
2613                            content: Content {
2614                                original: Span {
2615                                    data: "abc",
2616                                    line: 3,
2617                                    col: 1,
2618                                    offset: 18,
2619                                },
2620                                rendered: "abc",
2621                            },
2622                            source: Span {
2623                                data: "abc",
2624                                line: 3,
2625                                col: 1,
2626                                offset: 18,
2627                            },
2628                            style: SimpleBlockStyle::Paragraph,
2629                            title_source: None,
2630                            title: None,
2631                            caption: None,
2632                            number: None,
2633                            anchor: None,
2634                            anchor_reftext: None,
2635                            attrlist: None,
2636                        }),
2637                        Block::Section(SectionBlock {
2638                            level: 2,
2639                            section_title: Content {
2640                                original: Span {
2641                                    data: "Section 2",
2642                                    line: 5,
2643                                    col: 5,
2644                                    offset: 27,
2645                                },
2646                                rendered: "Section 2",
2647                            },
2648                            blocks: &[Block::Simple(SimpleBlock {
2649                                content: Content {
2650                                    original: Span {
2651                                        data: "def",
2652                                        line: 7,
2653                                        col: 1,
2654                                        offset: 38,
2655                                    },
2656                                    rendered: "def",
2657                                },
2658                                source: Span {
2659                                    data: "def",
2660                                    line: 7,
2661                                    col: 1,
2662                                    offset: 38,
2663                                },
2664                                style: SimpleBlockStyle::Paragraph,
2665                                title_source: None,
2666                                title: None,
2667                                caption: None,
2668                                number: None,
2669                                anchor: None,
2670                                anchor_reftext: None,
2671                                attrlist: None,
2672                            })],
2673                            source: Span {
2674                                data: "### Section 2\n\ndef",
2675                                line: 5,
2676                                col: 1,
2677                                offset: 23,
2678                            },
2679                            title_source: None,
2680                            title: None,
2681                            anchor: None,
2682                            anchor_reftext: None,
2683                            attrlist: None,
2684                            section_type: SectionType::Normal,
2685                            section_id: Some("_section_2"),
2686                            caption: None,
2687                            section_number: None,
2688                        })
2689                    ],
2690                    source: Span {
2691                        data: "## Section Title\n\nabc\n\n### Section 2\n\ndef",
2692                        line: 1,
2693                        col: 1,
2694                        offset: 0,
2695                    },
2696                    title_source: None,
2697                    title: None,
2698                    anchor: None,
2699                    anchor_reftext: None,
2700                    attrlist: None,
2701                    section_type: SectionType::Normal,
2702                    section_id: Some("_section_title"),
2703                    caption: None,
2704                    section_number: None,
2705                }
2706            );
2707
2708            assert_eq!(
2709                mi.after,
2710                Span {
2711                    data: "",
2712                    line: 7,
2713                    col: 4,
2714                    offset: 41
2715                }
2716            );
2717        }
2718
2719        #[test]
2720        fn stop_at_peer_section() {
2721            let mut parser = Parser::default();
2722            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2723
2724            let mi = crate::blocks::SectionBlock::parse(
2725                &BlockMetadata::new("## Section Title\n\nabc\n\n## Section 2\n\ndef"),
2726                &mut parser,
2727                &mut warnings,
2728            )
2729            .unwrap();
2730
2731            assert_eq!(mi.item.content_model(), ContentModel::Compound);
2732            assert_eq!(mi.item.raw_context().deref(), "section");
2733            assert_eq!(mi.item.resolved_context().deref(), "section");
2734            assert!(mi.item.declared_style().is_none());
2735            assert_eq!(mi.item.id().unwrap(), "_section_title");
2736            assert!(mi.item.roles().is_empty());
2737            assert!(mi.item.options().is_empty());
2738            assert!(mi.item.title_source().is_none());
2739            assert!(mi.item.title().is_none());
2740            assert!(mi.item.anchor().is_none());
2741            assert!(mi.item.anchor_reftext().is_none());
2742            assert!(mi.item.attrlist().is_none());
2743            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
2744
2745            assert_eq!(
2746                mi.item,
2747                SectionBlock {
2748                    level: 1,
2749                    section_title: Content {
2750                        original: Span {
2751                            data: "Section Title",
2752                            line: 1,
2753                            col: 4,
2754                            offset: 3,
2755                        },
2756                        rendered: "Section Title",
2757                    },
2758                    blocks: &[Block::Simple(SimpleBlock {
2759                        content: Content {
2760                            original: Span {
2761                                data: "abc",
2762                                line: 3,
2763                                col: 1,
2764                                offset: 18,
2765                            },
2766                            rendered: "abc",
2767                        },
2768                        source: Span {
2769                            data: "abc",
2770                            line: 3,
2771                            col: 1,
2772                            offset: 18,
2773                        },
2774                        style: SimpleBlockStyle::Paragraph,
2775                        title_source: None,
2776                        title: None,
2777                        caption: None,
2778                        number: None,
2779                        anchor: None,
2780                        anchor_reftext: None,
2781                        attrlist: None,
2782                    })],
2783                    source: Span {
2784                        data: "## Section Title\n\nabc",
2785                        line: 1,
2786                        col: 1,
2787                        offset: 0,
2788                    },
2789                    title_source: None,
2790                    title: None,
2791                    anchor: None,
2792                    anchor_reftext: None,
2793                    attrlist: None,
2794                    section_type: SectionType::Normal,
2795                    section_id: Some("_section_title"),
2796                    caption: None,
2797                    section_number: None,
2798                }
2799            );
2800
2801            assert_eq!(
2802                mi.after,
2803                Span {
2804                    data: "## Section 2\n\ndef",
2805                    line: 5,
2806                    col: 1,
2807                    offset: 23
2808                }
2809            );
2810        }
2811
2812        #[test]
2813        fn stop_at_ancestor_section() {
2814            let mut parser = Parser::default();
2815            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2816
2817            let mi = crate::blocks::SectionBlock::parse(
2818                &BlockMetadata::new("### Section Title\n\nabc\n\n## Section 2\n\ndef"),
2819                &mut parser,
2820                &mut warnings,
2821            )
2822            .unwrap();
2823
2824            assert_eq!(mi.item.content_model(), ContentModel::Compound);
2825            assert_eq!(mi.item.raw_context().deref(), "section");
2826            assert_eq!(mi.item.resolved_context().deref(), "section");
2827            assert!(mi.item.declared_style().is_none());
2828            assert_eq!(mi.item.id().unwrap(), "_section_title");
2829            assert!(mi.item.roles().is_empty());
2830            assert!(mi.item.options().is_empty());
2831            assert!(mi.item.title_source().is_none());
2832            assert!(mi.item.title().is_none());
2833            assert!(mi.item.anchor().is_none());
2834            assert!(mi.item.anchor_reftext().is_none());
2835            assert!(mi.item.attrlist().is_none());
2836            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
2837
2838            assert_eq!(
2839                mi.item,
2840                SectionBlock {
2841                    level: 2,
2842                    section_title: Content {
2843                        original: Span {
2844                            data: "Section Title",
2845                            line: 1,
2846                            col: 5,
2847                            offset: 4,
2848                        },
2849                        rendered: "Section Title",
2850                    },
2851                    blocks: &[Block::Simple(SimpleBlock {
2852                        content: Content {
2853                            original: Span {
2854                                data: "abc",
2855                                line: 3,
2856                                col: 1,
2857                                offset: 19,
2858                            },
2859                            rendered: "abc",
2860                        },
2861                        source: Span {
2862                            data: "abc",
2863                            line: 3,
2864                            col: 1,
2865                            offset: 19,
2866                        },
2867                        style: SimpleBlockStyle::Paragraph,
2868                        title_source: None,
2869                        title: None,
2870                        caption: None,
2871                        number: None,
2872                        anchor: None,
2873                        anchor_reftext: None,
2874                        attrlist: None,
2875                    })],
2876                    source: Span {
2877                        data: "### Section Title\n\nabc",
2878                        line: 1,
2879                        col: 1,
2880                        offset: 0,
2881                    },
2882                    title_source: None,
2883                    title: None,
2884                    anchor: None,
2885                    anchor_reftext: None,
2886                    attrlist: None,
2887                    section_type: SectionType::Normal,
2888                    section_id: Some("_section_title"),
2889                    caption: None,
2890                    section_number: None,
2891                }
2892            );
2893
2894            assert_eq!(
2895                mi.after,
2896                Span {
2897                    data: "## Section 2\n\ndef",
2898                    line: 5,
2899                    col: 1,
2900                    offset: 24
2901                }
2902            );
2903        }
2904
2905        #[test]
2906        fn section_title_with_markup() {
2907            let mut parser = Parser::default();
2908            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2909
2910            let mi = crate::blocks::SectionBlock::parse(
2911                &BlockMetadata::new("## Section with *bold* text"),
2912                &mut parser,
2913                &mut warnings,
2914            )
2915            .unwrap();
2916
2917            assert_eq!(
2918                mi.item.section_title_source(),
2919                Span {
2920                    data: "Section with *bold* text",
2921                    line: 1,
2922                    col: 4,
2923                    offset: 3,
2924                }
2925            );
2926
2927            assert_eq!(
2928                mi.item.section_title(),
2929                "Section with <strong>bold</strong> text"
2930            );
2931
2932            assert_eq!(mi.item.section_type(), SectionType::Normal);
2933            assert_eq!(mi.item.id().unwrap(), "_section_with_bold_text");
2934        }
2935
2936        #[test]
2937        fn section_title_with_special_chars() {
2938            let mut parser = Parser::default();
2939            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2940
2941            let mi = crate::blocks::SectionBlock::parse(
2942                &BlockMetadata::new("## Section with <brackets> & ampersands"),
2943                &mut parser,
2944                &mut warnings,
2945            )
2946            .unwrap();
2947
2948            assert_eq!(
2949                mi.item.section_title_source(),
2950                Span {
2951                    data: "Section with <brackets> & ampersands",
2952                    line: 1,
2953                    col: 4,
2954                    offset: 3,
2955                }
2956            );
2957
2958            assert_eq!(
2959                mi.item.section_title(),
2960                "Section with &lt;brackets&gt; &amp; ampersands"
2961            );
2962
2963            assert_eq!(mi.item.section_type(), SectionType::Normal);
2964        }
2965
2966        #[test]
2967        fn err_level_0_section_heading() {
2968            let mut parser = Parser::default();
2969            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2970
2971            let result = crate::blocks::SectionBlock::parse(
2972                &BlockMetadata::new("# Document Title"),
2973                &mut parser,
2974                &mut warnings,
2975            );
2976
2977            assert!(result.is_none());
2978
2979            assert_eq!(
2980                warnings,
2981                vec![Warning {
2982                    source: Span {
2983                        data: "# Document Title",
2984                        line: 1,
2985                        col: 1,
2986                        offset: 0,
2987                    },
2988                    warning: WarningType::Level0SectionHeadingNotSupported,
2989                }]
2990            );
2991        }
2992
2993        #[test]
2994        fn err_section_heading_level_exceeds_maximum() {
2995            let mut parser = Parser::default();
2996            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
2997
2998            let result = crate::blocks::SectionBlock::parse(
2999                &BlockMetadata::new("####### Level 6 Section"),
3000                &mut parser,
3001                &mut warnings,
3002            );
3003
3004            assert!(result.is_none());
3005
3006            assert_eq!(
3007                warnings,
3008                vec![Warning {
3009                    source: Span {
3010                        data: "####### Level 6 Section",
3011                        line: 1,
3012                        col: 1,
3013                        offset: 0,
3014                    },
3015                    warning: WarningType::SectionHeadingLevelExceedsMaximum(6),
3016                }]
3017            );
3018        }
3019
3020        #[test]
3021        fn valid_maximum_level_5_section() {
3022            let mut parser = Parser::default();
3023            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
3024
3025            let mi = crate::blocks::SectionBlock::parse(
3026                &BlockMetadata::new("###### Level 5 Section"),
3027                &mut parser,
3028                &mut warnings,
3029            )
3030            .unwrap();
3031
3032            assert!(warnings.is_empty());
3033
3034            assert_eq!(mi.item.level(), 5);
3035            assert_eq!(mi.item.section_title(), "Level 5 Section");
3036            assert_eq!(mi.item.section_type(), SectionType::Normal);
3037            assert_eq!(mi.item.id().unwrap(), "_level_5_section");
3038        }
3039
3040        #[test]
3041        fn warn_section_level_skipped() {
3042            let mut parser = Parser::default();
3043            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
3044
3045            let mi = crate::blocks::SectionBlock::parse(
3046                &BlockMetadata::new("## Level 1\n\n#### Level 3 (skipped level 2)"),
3047                &mut parser,
3048                &mut warnings,
3049            )
3050            .unwrap();
3051
3052            assert_eq!(mi.item.level(), 1);
3053            assert_eq!(mi.item.section_title(), "Level 1");
3054            assert_eq!(mi.item.section_type(), SectionType::Normal);
3055            assert_eq!(mi.item.child_blocks().count(), 1);
3056            assert_eq!(mi.item.id().unwrap(), "_level_1");
3057
3058            assert_eq!(
3059                warnings,
3060                vec![Warning {
3061                    source: Span {
3062                        data: "#### Level 3 (skipped level 2)",
3063                        line: 3,
3064                        col: 1,
3065                        offset: 12,
3066                    },
3067                    warning: WarningType::SectionHeadingLevelSkipped(1, 3),
3068                }]
3069            );
3070        }
3071    }
3072
3073    #[test]
3074    fn warn_multiple_section_levels_skipped() {
3075        let mut parser = Parser::default();
3076        let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
3077
3078        let mi = crate::blocks::SectionBlock::parse(
3079            &BlockMetadata::new("== Level 1\n\n===== Level 4 (skipped levels 2 and 3)"),
3080            &mut parser,
3081            &mut warnings,
3082        )
3083        .unwrap();
3084
3085        assert_eq!(mi.item.level(), 1);
3086        assert_eq!(mi.item.section_title(), "Level 1");
3087        assert_eq!(mi.item.section_type(), SectionType::Normal);
3088        assert_eq!(mi.item.child_blocks().count(), 1);
3089        assert_eq!(mi.item.id().unwrap(), "_level_1");
3090
3091        assert_eq!(
3092            warnings,
3093            vec![Warning {
3094                source: Span {
3095                    data: "===== Level 4 (skipped levels 2 and 3)",
3096                    line: 3,
3097                    col: 1,
3098                    offset: 12,
3099                },
3100                warning: WarningType::SectionHeadingLevelSkipped(1, 4),
3101            }]
3102        );
3103    }
3104
3105    #[test]
3106    fn no_warning_for_consecutive_section_levels() {
3107        let mut parser = Parser::default();
3108        let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
3109
3110        let mi = crate::blocks::SectionBlock::parse(
3111            &BlockMetadata::new("== Level 1\n\n=== Level 2 (no skip)"),
3112            &mut parser,
3113            &mut warnings,
3114        )
3115        .unwrap();
3116
3117        assert_eq!(mi.item.level(), 1);
3118        assert_eq!(mi.item.section_title(), "Level 1");
3119        assert_eq!(mi.item.section_type(), SectionType::Normal);
3120        assert_eq!(mi.item.child_blocks().count(), 1);
3121        assert_eq!(mi.item.id().unwrap(), "_level_1");
3122
3123        assert!(warnings.is_empty());
3124    }
3125
3126    #[test]
3127    fn section_id_generation_basic() {
3128        let input = "== Section One";
3129        let mut parser = Parser::default();
3130        let document = parser.parse(input);
3131
3132        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3133            assert_eq!(section.id(), Some("_section_one"));
3134        } else {
3135            panic!("Expected section block");
3136        }
3137    }
3138
3139    #[test]
3140    fn section_id_generation_with_special_characters() {
3141        let input = "== We're back! & Company";
3142        let mut parser = Parser::default();
3143        let document = parser.parse(input);
3144
3145        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3146            assert_eq!(section.id(), Some("_were_back_company"));
3147        } else {
3148            panic!("Expected section block");
3149        }
3150    }
3151
3152    #[test]
3153    fn section_id_generation_with_entities() {
3154        let input = "== Ben &amp; Jerry &#34;Ice Cream&#34;";
3155        let mut parser = Parser::default();
3156        let document = parser.parse(input);
3157
3158        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3159            assert_eq!(section.id(), Some("_ben_jerry_ice_cream"));
3160        } else {
3161            panic!("Expected section block");
3162        }
3163    }
3164
3165    #[test]
3166    fn section_id_generation_disabled_when_sectids_unset() {
3167        let input = ":!sectids:\n\n== Section One";
3168        let mut parser = Parser::default();
3169        let document = parser.parse(input);
3170
3171        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3172            assert_eq!(section.id(), None);
3173        } else {
3174            panic!("Expected section block");
3175        }
3176    }
3177
3178    #[test]
3179    fn section_id_generation_with_custom_prefix() {
3180        let input = ":idprefix: id_\n\n== Section One";
3181        let mut parser = Parser::default();
3182        let document = parser.parse(input);
3183
3184        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3185            assert_eq!(section.id(), Some("id_section_one"));
3186        } else {
3187            panic!("Expected section block");
3188        }
3189    }
3190
3191    #[test]
3192    fn section_id_generation_with_custom_separator() {
3193        let input = ":idseparator: -\n\n== Section One";
3194        let mut parser = Parser::default();
3195        let document = parser.parse(input);
3196
3197        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3198            assert_eq!(section.id(), Some("_section-one"));
3199        } else {
3200            panic!("Expected section block");
3201        }
3202    }
3203
3204    #[test]
3205    fn section_id_generation_with_empty_prefix() {
3206        let input = ":idprefix:\n\n== Section One";
3207        let mut parser = Parser::default();
3208        let document = parser.parse(input);
3209
3210        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3211            assert_eq!(section.id(), Some("section_one"));
3212        } else {
3213            panic!("Expected section block");
3214        }
3215    }
3216
3217    #[test]
3218    fn section_id_generation_removes_trailing_separator() {
3219        let input = ":idseparator: -\n\n== Section Title-";
3220        let mut parser = Parser::default();
3221        let document = parser.parse(input);
3222
3223        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3224            assert_eq!(section.id(), Some("_section-title"));
3225        } else {
3226            panic!("Expected section block");
3227        }
3228    }
3229
3230    #[test]
3231    fn section_id_generation_removes_leading_separator_when_prefix_empty() {
3232        let input = ":idprefix:\n:idseparator: -\n\n== -Section Title";
3233        let mut parser = Parser::default();
3234        let document = parser.parse(input);
3235
3236        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3237            assert_eq!(section.id(), Some("section-title"));
3238        } else {
3239            panic!("Expected section block");
3240        }
3241    }
3242
3243    #[test]
3244    fn section_id_generation_handles_multiple_trailing_separators() {
3245        let input = ":idseparator: _\n\n== Title with Multiple Dots...";
3246        let mut parser = Parser::default();
3247        let document = parser.parse(input);
3248
3249        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3250            assert_eq!(section.id(), Some("_title_with_multiple_dots"));
3251        } else {
3252            panic!("Expected section block");
3253        }
3254    }
3255
3256    #[test]
3257    fn warn_duplicate_manual_section_id() {
3258        let input = "[#my_id]\n== First Section\n\n[#my_id]\n== Second Section";
3259        let mut parser = Parser::default();
3260        let document = parser.parse(input);
3261
3262        let mut warnings = document.warnings();
3263
3264        assert_eq!(
3265            warnings.next().unwrap(),
3266            Warning {
3267                source: Span {
3268                    data: "[#my_id]\n== Second Section",
3269                    line: 4,
3270                    col: 1,
3271                    offset: 27,
3272                },
3273                warning: WarningType::DuplicateId("my_id".to_owned()),
3274            }
3275        );
3276
3277        assert!(warnings.next().is_none());
3278    }
3279
3280    #[test]
3281    fn section_with_custom_reftext_attribute() {
3282        let input = "[reftext=\"Custom Reference Text\"]\n== Section Title";
3283        let mut parser = Parser::default();
3284        let document = parser.parse(input);
3285
3286        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3287            assert_eq!(section.id(), Some("_section_title"));
3288        } else {
3289            panic!("Expected section block");
3290        }
3291
3292        let catalog = document.catalog();
3293        let entry = catalog.get_ref("_section_title");
3294        assert!(entry.is_some());
3295        assert_eq!(
3296            entry.unwrap().reftext,
3297            Some("Custom Reference Text".to_string())
3298        );
3299    }
3300
3301    #[test]
3302    fn section_without_reftext_uses_title() {
3303        let input = "== Section Title";
3304        let mut parser = Parser::default();
3305        let document = parser.parse(input);
3306
3307        if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
3308            assert_eq!(section.id(), Some("_section_title"));
3309        } else {
3310            panic!("Expected section block");
3311        }
3312
3313        let catalog = document.catalog();
3314        let entry = catalog.get_ref("_section_title");
3315        assert!(entry.is_some());
3316        assert_eq!(entry.unwrap().reftext, Some("Section Title".to_string()));
3317    }
3318
3319    mod section_numbering {
3320        use crate::{blocks::Block, tests::prelude::*};
3321
3322        #[test]
3323        fn single_section_with_sectnums() {
3324            let input = ":sectnums:\n\n== First Section";
3325            let mut parser = Parser::default();
3326            let document = parser.parse(input);
3327
3328            if let Some(Block::Section(section)) = document.child_blocks().next() {
3329                let section_number = section.section_number();
3330                assert!(section_number.is_some());
3331                assert_eq!(section_number.unwrap().to_string(), "1");
3332                assert_eq!(section_number.unwrap().components(), [1]);
3333            } else {
3334                panic!("Expected section block");
3335            }
3336        }
3337
3338        #[test]
3339        fn multiple_level_1_sections() {
3340            let input = ":sectnums:\n\n== First Section\n\n== Second Section\n\n== Third Section";
3341            let mut parser = Parser::default();
3342            let document = parser.parse(input);
3343
3344            let mut sections = document.child_blocks().filter_map(|block| {
3345                if let Block::Section(section) = block {
3346                    Some(section)
3347                } else {
3348                    None
3349                }
3350            });
3351
3352            let first = sections.next().unwrap();
3353            assert_eq!(first.section_number().unwrap().to_string(), "1");
3354
3355            let second = sections.next().unwrap();
3356            assert_eq!(second.section_number().unwrap().to_string(), "2");
3357
3358            let third = sections.next().unwrap();
3359            assert_eq!(third.section_number().unwrap().to_string(), "3");
3360        }
3361
3362        #[test]
3363        fn nested_sections() {
3364            let input = ":sectnums:\n\n== Level 1\n\n=== Level 2\n\n==== Level 3";
3365            let document = Parser::default().parse(input);
3366
3367            if let Some(Block::Section(level1)) = document.child_blocks().next() {
3368                assert_eq!(level1.section_number().unwrap().to_string(), "1");
3369
3370                if let Some(Block::Section(level2)) = level1.child_blocks().next() {
3371                    assert_eq!(level2.section_number().unwrap().to_string(), "1.1");
3372
3373                    if let Some(Block::Section(level3)) = level2.child_blocks().next() {
3374                        assert_eq!(level3.section_number().unwrap().to_string(), "1.1.1");
3375                    } else {
3376                        panic!("Expected level 3 section");
3377                    }
3378                } else {
3379                    panic!("Expected level 2 section");
3380                }
3381            } else {
3382                panic!("Expected level 1 section");
3383            }
3384        }
3385
3386        #[test]
3387        fn mixed_section_levels() {
3388            let input = ":sectnums:\n\n== First\n\n=== First.One\n\n=== First.Two\n\n== Second\n\n=== Second.One";
3389            let document = Parser::default().parse(input);
3390
3391            let mut sections = document.child_blocks().filter_map(|block| {
3392                if let Block::Section(section) = block {
3393                    Some(section)
3394                } else {
3395                    None
3396                }
3397            });
3398
3399            let first = sections.next().unwrap();
3400            assert_eq!(first.section_number().unwrap().to_string(), "1");
3401
3402            let first_one = first
3403                .child_blocks()
3404                .filter_map(|block| {
3405                    if let Block::Section(section) = block {
3406                        Some(section)
3407                    } else {
3408                        None
3409                    }
3410                })
3411                .next()
3412                .unwrap();
3413            assert_eq!(first_one.section_number().unwrap().to_string(), "1.1");
3414
3415            let first_two = first
3416                .child_blocks()
3417                .filter_map(|block| {
3418                    if let Block::Section(section) = block {
3419                        Some(section)
3420                    } else {
3421                        None
3422                    }
3423                })
3424                .nth(1)
3425                .unwrap();
3426            assert_eq!(first_two.section_number().unwrap().to_string(), "1.2");
3427
3428            let second = sections.next().unwrap();
3429            assert_eq!(second.section_number().unwrap().to_string(), "2");
3430
3431            let second_one = second
3432                .child_blocks()
3433                .filter_map(|block| {
3434                    if let Block::Section(section) = block {
3435                        Some(section)
3436                    } else {
3437                        None
3438                    }
3439                })
3440                .next()
3441                .unwrap();
3442            assert_eq!(second_one.section_number().unwrap().to_string(), "2.1");
3443        }
3444
3445        #[test]
3446        fn sectnums_disabled() {
3447            let input = "== First Section\n\n== Second Section";
3448            let mut parser = Parser::default();
3449            let document = parser.parse(input);
3450
3451            for block in document.child_blocks() {
3452                if let Block::Section(section) = block {
3453                    assert!(section.section_number().is_none());
3454                }
3455            }
3456        }
3457
3458        #[test]
3459        fn sectnums_explicitly_unset() {
3460            let input = ":!sectnums:\n\n== First Section\n\n== Second Section";
3461            let mut parser = Parser::default();
3462            let document = parser.parse(input);
3463
3464            for block in document.child_blocks() {
3465                if let Block::Section(section) = block {
3466                    assert!(section.section_number().is_none());
3467                }
3468            }
3469        }
3470
3471        #[test]
3472        fn numbered_alias_enables_numbering() {
3473            // `numbered` is a legacy alias for `sectnums`: setting it numbers
3474            // sections just as `sectnums` would, and `is_attribute_set` reports
3475            // the primary name, mirroring Asciidoctor.
3476            let input = ":numbered:\n\n== First Section\n\n== Second Section";
3477            let mut parser = Parser::default();
3478            let document = parser.parse(input);
3479
3480            assert!(parser.is_attribute_set("sectnums"));
3481
3482            let mut sections = document.child_blocks().filter_map(|block| {
3483                if let Block::Section(section) = block {
3484                    Some(section)
3485                } else {
3486                    None
3487                }
3488            });
3489
3490            assert_eq!(
3491                sections
3492                    .next()
3493                    .unwrap()
3494                    .section_number()
3495                    .unwrap()
3496                    .to_string(),
3497                "1"
3498            );
3499            assert_eq!(
3500                sections
3501                    .next()
3502                    .unwrap()
3503                    .section_number()
3504                    .unwrap()
3505                    .to_string(),
3506                "2"
3507            );
3508        }
3509
3510        #[test]
3511        fn numbered_alias_can_be_toggled_off_within_document() {
3512            // `numbered!` unsets the alias mid-document; sections after the
3513            // toggle are not numbered.
3514            let input =
3515                ":numbered:\n\n== Numbered\n\n:numbered!:\n\n== Unnumbered\n\n== Also Unnumbered";
3516            let mut parser = Parser::default();
3517            let document = parser.parse(input);
3518
3519            let mut sections = document.child_blocks().filter_map(|block| {
3520                if let Block::Section(section) = block {
3521                    Some(section)
3522                } else {
3523                    None
3524                }
3525            });
3526
3527            assert_eq!(
3528                sections
3529                    .next()
3530                    .unwrap()
3531                    .section_number()
3532                    .unwrap()
3533                    .to_string(),
3534                "1"
3535            );
3536            assert!(sections.next().unwrap().section_number().is_none());
3537            assert!(sections.next().unwrap().section_number().is_none());
3538        }
3539
3540        #[test]
3541        fn deep_nesting() {
3542            let input = ":sectnums:\n:sectnumlevels: 5\n\n== Level 1\n\n=== Level 2\n\n==== Level 3\n\n===== Level 4\n\n====== Level 5";
3543            let document = Parser::default().parse(input);
3544
3545            if let Some(Block::Section(l1)) = document.child_blocks().next() {
3546                assert_eq!(l1.section_number().unwrap().to_string(), "1");
3547
3548                if let Some(Block::Section(l2)) = l1.child_blocks().next() {
3549                    assert_eq!(l2.section_number().unwrap().to_string(), "1.1");
3550
3551                    if let Some(Block::Section(l3)) = l2.child_blocks().next() {
3552                        assert_eq!(l3.section_number().unwrap().to_string(), "1.1.1");
3553
3554                        if let Some(Block::Section(l4)) = l3.child_blocks().next() {
3555                            assert_eq!(l4.section_number().unwrap().to_string(), "1.1.1.1");
3556
3557                            if let Some(Block::Section(l5)) = l4.child_blocks().next() {
3558                                assert_eq!(l5.section_number().unwrap().to_string(), "1.1.1.1.1");
3559                            } else {
3560                                panic!("Expected level 5 section");
3561                            }
3562                        } else {
3563                            panic!("Expected level 4 section");
3564                        }
3565                    } else {
3566                        panic!("Expected level 3 section");
3567                    }
3568                } else {
3569                    panic!("Expected level 2 section");
3570                }
3571            } else {
3572                panic!("Expected level 1 section");
3573            }
3574        }
3575    }
3576
3577    #[test]
3578    fn impl_debug() {
3579        let mut parser = Parser::default();
3580        let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
3581
3582        let section = crate::blocks::SectionBlock::parse(
3583            &BlockMetadata::new("== Section Title"),
3584            &mut parser,
3585            &mut warnings,
3586        )
3587        .unwrap()
3588        .item;
3589
3590        assert_eq!(
3591            format!("{section:#?}"),
3592            r#"SectionBlock {
3593    level: 1,
3594    section_title: Content {
3595        original: Span {
3596            data: "Section Title",
3597            line: 1,
3598            col: 4,
3599            offset: 3,
3600        },
3601        rendered: "Section Title",
3602    },
3603    blocks: &[],
3604    source: Span {
3605        data: "== Section Title",
3606        line: 1,
3607        col: 1,
3608        offset: 0,
3609    },
3610    title_source: None,
3611    title: None,
3612    anchor: None,
3613    anchor_reftext: None,
3614    attrlist: None,
3615    section_type: SectionType::Normal,
3616    section_id: Some(
3617        "_section_title",
3618    ),
3619    caption: None,
3620    section_number: None,
3621}"#
3622        );
3623    }
3624
3625    mod section_type {
3626        use crate::blocks::section::SectionType;
3627
3628        #[test]
3629        fn impl_debug() {
3630            let st = SectionType::Normal;
3631            assert_eq!(format!("{st:?}"), "SectionType::Normal");
3632
3633            let st = SectionType::Appendix;
3634            assert_eq!(format!("{st:?}"), "SectionType::Appendix");
3635
3636            let st = SectionType::Discrete;
3637            assert_eq!(format!("{st:?}"), "SectionType::Discrete");
3638        }
3639    }
3640
3641    mod section_number {
3642        mod assign_next_number {
3643            use crate::blocks::section::SectionNumber;
3644
3645            #[test]
3646            fn default() {
3647                let sn = SectionNumber::default();
3648                assert_eq!(sn.components(), []);
3649                assert_eq!(sn.to_string(), "");
3650                assert_eq!(
3651                    format!("{sn:?}"),
3652                    "SectionNumber { section_type: SectionType::Normal, components: &[], appendix_letter: None }"
3653                );
3654            }
3655
3656            #[test]
3657            fn level_1() {
3658                let mut sn = SectionNumber::default();
3659                sn.assign_next_number(1);
3660                assert_eq!(sn.components(), [1]);
3661                assert_eq!(sn.to_string(), "1");
3662                assert_eq!(
3663                    format!("{sn:?}"),
3664                    "SectionNumber { section_type: SectionType::Normal, components: &[1], appendix_letter: None }"
3665                );
3666            }
3667
3668            #[test]
3669            fn level_3() {
3670                let mut sn = SectionNumber::default();
3671                sn.assign_next_number(3);
3672                assert_eq!(sn.components(), [1, 1, 1]);
3673                assert_eq!(sn.to_string(), "1.1.1");
3674                assert_eq!(
3675                    format!("{sn:?}"),
3676                    "SectionNumber { section_type: SectionType::Normal, components: &[1, 1, 1], appendix_letter: None }"
3677                );
3678            }
3679
3680            #[test]
3681            fn level_3_then_1() {
3682                let mut sn = SectionNumber::default();
3683                sn.assign_next_number(3);
3684                sn.assign_next_number(1);
3685                assert_eq!(sn.components(), [2]);
3686                assert_eq!(sn.to_string(), "2");
3687                assert_eq!(
3688                    format!("{sn:?}"),
3689                    "SectionNumber { section_type: SectionType::Normal, components: &[2], appendix_letter: None }"
3690                );
3691            }
3692
3693            #[test]
3694            fn level_3_then_1_then_2() {
3695                let mut sn = SectionNumber::default();
3696                sn.assign_next_number(3);
3697                sn.assign_next_number(1);
3698                sn.assign_next_number(2);
3699                assert_eq!(sn.components(), [2, 1]);
3700                assert_eq!(sn.to_string(), "2.1");
3701                assert_eq!(
3702                    format!("{sn:?}"),
3703                    "SectionNumber { section_type: SectionType::Normal, components: &[2, 1], appendix_letter: None }"
3704                );
3705            }
3706        }
3707
3708        mod assign_next_number_appendix {
3709            use crate::blocks::{SectionType, section::SectionNumber};
3710
3711            #[test]
3712            fn default() {
3713                let sn = SectionNumber {
3714                    section_type: SectionType::Appendix,
3715                    components: vec![],
3716                    appendix_letter: None,
3717                };
3718                assert_eq!(sn.components(), []);
3719                assert_eq!(sn.to_string(), "");
3720                assert_eq!(
3721                    format!("{sn:?}"),
3722                    "SectionNumber { section_type: SectionType::Appendix, components: &[], appendix_letter: None }"
3723                );
3724            }
3725
3726            #[test]
3727            fn level_1() {
3728                let mut sn = SectionNumber {
3729                    section_type: SectionType::Appendix,
3730                    components: vec![],
3731                    appendix_letter: None,
3732                };
3733                sn.assign_next_number(1);
3734                assert_eq!(sn.components(), [1]);
3735                assert_eq!(sn.to_string(), "A");
3736                assert_eq!(
3737                    format!("{sn:?}"),
3738                    "SectionNumber { section_type: SectionType::Appendix, components: &[1], appendix_letter: None }"
3739                );
3740            }
3741
3742            #[test]
3743            fn level_3() {
3744                let mut sn = SectionNumber {
3745                    section_type: SectionType::Appendix,
3746                    components: vec![],
3747                    appendix_letter: None,
3748                };
3749                sn.assign_next_number(3);
3750                assert_eq!(sn.components(), [1, 1, 1]);
3751                assert_eq!(sn.to_string(), "A.1.1");
3752                assert_eq!(
3753                    format!("{sn:?}"),
3754                    "SectionNumber { section_type: SectionType::Appendix, components: &[1, 1, 1], appendix_letter: None }"
3755                );
3756            }
3757
3758            #[test]
3759            fn level_3_then_1() {
3760                let mut sn = SectionNumber {
3761                    section_type: SectionType::Appendix,
3762                    components: vec![],
3763                    appendix_letter: None,
3764                };
3765                sn.assign_next_number(3);
3766                sn.assign_next_number(1);
3767                assert_eq!(sn.components(), [2]);
3768                assert_eq!(sn.to_string(), "B");
3769                assert_eq!(
3770                    format!("{sn:?}"),
3771                    "SectionNumber { section_type: SectionType::Appendix, components: &[2], appendix_letter: None }"
3772                );
3773            }
3774
3775            #[test]
3776            fn level_3_then_1_then_2() {
3777                let mut sn = SectionNumber {
3778                    section_type: SectionType::Appendix,
3779                    components: vec![],
3780                    appendix_letter: None,
3781                };
3782                sn.assign_next_number(3);
3783                sn.assign_next_number(1);
3784                sn.assign_next_number(2);
3785                assert_eq!(sn.components(), [2, 1]);
3786                assert_eq!(sn.to_string(), "B.1");
3787                assert_eq!(
3788                    format!("{sn:?}"),
3789                    "SectionNumber { section_type: SectionType::Appendix, components: &[2, 1], appendix_letter: None }"
3790                );
3791            }
3792
3793            #[test]
3794            fn appendix_letter_overrides_first_component() {
3795                let mut sn = SectionNumber {
3796                    section_type: SectionType::Appendix,
3797                    components: vec![],
3798                    appendix_letter: Some("\u{3b2}".to_owned()),
3799                };
3800                sn.assign_next_number(1);
3801                sn.assign_next_number(2);
3802                assert_eq!(sn.components(), [1, 1]);
3803                assert_eq!(sn.appendix_letter(), Some("\u{3b2}"));
3804                assert_eq!(sn.to_string(), "\u{3b2}.1");
3805                assert_eq!(
3806                    format!("{sn:?}"),
3807                    "SectionNumber { section_type: SectionType::Appendix, components: &[1, 1], appendix_letter: Some(\"\u{3b2}\") }"
3808                );
3809            }
3810        }
3811    }
3812
3813    mod appendix_number_attribute {
3814        use crate::{blocks::Block, tests::prelude::*};
3815
3816        // The `appendix-number` attribute is resolved as a counter (mirroring
3817        // Ruby Asciidoctor), so its value is the letter *before* the first
3818        // appendix and each appendix advances it.
3819
3820        #[test]
3821        fn seeds_lettering_from_the_attribute() {
3822            let doc = Parser::default()
3823                .parse(":appendix-number: M\n\n[appendix]\n== One\n\n[appendix]\n== Two\n");
3824
3825            let caps: Vec<Option<&str>> = all_sections(&doc).iter().map(|s| s.caption()).collect();
3826            assert_eq!(caps, vec![Some("Appendix N: "), Some("Appendix O: ")]);
3827        }
3828
3829        #[test]
3830        fn increments_a_numeric_value_numerically() {
3831            let doc = Parser::default()
3832                .parse(":appendix-number: 9\n\n[appendix]\n== One\n\n[appendix]\n== Two\n");
3833
3834            let caps: Vec<Option<&str>> = all_sections(&doc).iter().map(|s| s.caption()).collect();
3835            assert_eq!(caps, vec![Some("Appendix 10: "), Some("Appendix 11: ")]);
3836        }
3837
3838        #[test]
3839        fn bare_attribute_resolves_to_default_seed() {
3840            // A bare `:appendix-number:` takes the built-in default `@`, the
3841            // character before `A`, so lettering still starts at `A`.
3842            let doc = Parser::default()
3843                .parse(":appendix-number:\n\n[appendix]\n== One\n\n[appendix]\n== Two\n");
3844
3845            let caps: Vec<Option<&str>> = all_sections(&doc).iter().map(|s| s.caption()).collect();
3846            assert_eq!(caps, vec![Some("Appendix A: "), Some("Appendix B: ")]);
3847        }
3848
3849        #[test]
3850        fn letters_section_numbers_of_appendix_and_subsections() {
3851            let doc = Parser::default()
3852                .parse(":sectnums:\n:appendix-number: \u{3b1}\n\n[appendix]\n== One\n\n=== Sub\n");
3853
3854            let nums: Vec<Option<String>> = all_sections(&doc)
3855                .iter()
3856                .map(|s| s.section_number().map(|n| n.to_string()))
3857                .collect();
3858            assert_eq!(
3859                nums,
3860                vec![Some("\u{3b2}".to_owned()), Some("\u{3b2}.1".to_owned())]
3861            );
3862        }
3863
3864        #[test]
3865        fn attribute_reads_back_as_the_current_letter() {
3866            // Advancing the counter stores the new value back into the
3867            // attribute, so a reference inside the appendix sees its letter.
3868            let doc = Parser::default().parse("[appendix]\n== One\n\nLetter {appendix-number}.\n");
3869
3870            let section = first_section(&doc);
3871            let Some(Block::Simple(paragraph)) = section.child_blocks().next() else {
3872                panic!("expected a simple block");
3873            };
3874            assert_eq!(paragraph.content().rendered(), "Letter A.");
3875        }
3876    }
3877
3878    mod discrete_headings {
3879        use std::ops::Deref;
3880
3881        use crate::{
3882            blocks::{ContentModel, metadata::BlockMetadata, section::SectionType},
3883            tests::prelude::*,
3884        };
3885
3886        #[test]
3887        fn basic_case() {
3888            let mut parser = Parser::default();
3889            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
3890
3891            let mi = crate::blocks::SectionBlock::parse(
3892                &BlockMetadata::new("[discrete]\n== Discrete Heading"),
3893                &mut parser,
3894                &mut warnings,
3895            )
3896            .unwrap();
3897
3898            assert_eq!(mi.item.content_model(), ContentModel::Compound);
3899            assert_eq!(mi.item.raw_context().deref(), "floating_title");
3900            assert_eq!(mi.item.resolved_context().deref(), "floating_title");
3901            assert_eq!(mi.item.level(), 1);
3902            assert_eq!(mi.item.section_title(), "Discrete Heading");
3903            assert_eq!(mi.item.section_type(), SectionType::Discrete);
3904            assert!(mi.item.child_blocks().next().is_none());
3905            assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
3906            assert!(mi.item.title().is_none());
3907            assert!(mi.item.anchor().is_none());
3908            assert!(mi.item.attrlist().is_some());
3909            assert_eq!(mi.item.section_number(), None);
3910            assert!(warnings.is_empty());
3911
3912            assert_eq!(
3913                mi.item.section_title_source(),
3914                Span {
3915                    data: "Discrete Heading",
3916                    line: 2,
3917                    col: 4,
3918                    offset: 14,
3919                }
3920            );
3921
3922            assert_eq!(
3923                mi.item.span(),
3924                Span {
3925                    data: "[discrete]\n== Discrete Heading",
3926                    line: 1,
3927                    col: 1,
3928                    offset: 0,
3929                }
3930            );
3931
3932            assert_eq!(
3933                mi.after,
3934                Span {
3935                    data: "",
3936                    line: 2,
3937                    col: 20,
3938                    offset: 30,
3939                }
3940            );
3941        }
3942
3943        #[test]
3944        fn float_style() {
3945            let mut parser = Parser::default();
3946            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
3947
3948            let mi = crate::blocks::SectionBlock::parse(
3949                &BlockMetadata::new("[float]\n== Floating Heading"),
3950                &mut parser,
3951                &mut warnings,
3952            )
3953            .unwrap();
3954
3955            assert_eq!(mi.item.level(), 1);
3956            assert_eq!(mi.item.section_title(), "Floating Heading");
3957            assert_eq!(mi.item.section_type(), SectionType::Discrete);
3958            assert!(mi.item.child_blocks().next().is_none());
3959            assert!(warnings.is_empty());
3960        }
3961
3962        #[test]
3963        fn has_no_child_blocks() {
3964            let mut parser = Parser::default();
3965            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
3966
3967            let mi = crate::blocks::SectionBlock::parse(
3968                &BlockMetadata::new("[discrete]\n== Discrete Heading\n\nThis is a paragraph."),
3969                &mut parser,
3970                &mut warnings,
3971            )
3972            .unwrap();
3973
3974            assert_eq!(mi.item.level(), 1);
3975            assert_eq!(mi.item.section_title(), "Discrete Heading");
3976            assert_eq!(mi.item.section_type(), SectionType::Discrete);
3977
3978            // Discrete headings should have no nested blocks.
3979            assert!(mi.item.child_blocks().next().is_none());
3980
3981            // The paragraph should be left unparsed.
3982            assert_eq!(
3983                mi.after,
3984                Span {
3985                    data: "This is a paragraph.",
3986                    line: 4,
3987                    col: 1,
3988                    offset: 32,
3989                }
3990            );
3991
3992            assert!(warnings.is_empty());
3993        }
3994
3995        #[test]
3996        fn not_in_section_hierarchy() {
3997            let input = "== Section 1\n\n[discrete]\n=== Discrete\n\n=== Section 1.1";
3998            let mut parser = Parser::default();
3999            let document = parser.parse(input);
4000
4001            let mut blocks = document.child_blocks();
4002
4003            // First should be "Section 1".
4004            if let Some(crate::blocks::Block::Section(section)) = blocks.next() {
4005                assert_eq!(section.section_title(), "Section 1");
4006                assert_eq!(section.level(), 1);
4007                assert_eq!(section.section_type(), SectionType::Normal);
4008
4009                let mut children = section.child_blocks();
4010
4011                // First child should be the discrete heading.
4012                if let Some(crate::blocks::Block::Section(discrete)) = children.next() {
4013                    assert_eq!(discrete.section_title(), "Discrete");
4014                    assert_eq!(discrete.level(), 2);
4015                    assert_eq!(discrete.section_type(), SectionType::Discrete);
4016                    assert!(discrete.child_blocks().next().is_none());
4017                } else {
4018                    panic!("Expected discrete heading block");
4019                }
4020
4021                // Second child should be "Section 1.1".
4022                if let Some(crate::blocks::Block::Section(subsection)) = children.next() {
4023                    assert_eq!(subsection.section_title(), "Section 1.1");
4024                    assert_eq!(subsection.level(), 2);
4025                    assert_eq!(subsection.section_type(), SectionType::Normal);
4026                } else {
4027                    panic!("Expected subsection block");
4028                }
4029            } else {
4030                panic!("Expected section block");
4031            }
4032        }
4033
4034        #[test]
4035        fn has_auto_id() {
4036            let input = "[discrete]\n== Discrete Heading";
4037            let mut parser = Parser::default();
4038            let document = parser.parse(input);
4039
4040            if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
4041                // Discrete headings should generate auto IDs.
4042                assert_eq!(section.id(), Some("_discrete_heading"));
4043            } else {
4044                panic!("Expected section block");
4045            }
4046        }
4047
4048        #[test]
4049        fn with_manual_id() {
4050            let input = "[discrete#my-id]\n== Discrete Heading";
4051            let mut parser = Parser::default();
4052            let document = parser.parse(input);
4053
4054            if let Some(crate::blocks::Block::Section(section)) = document.child_blocks().next() {
4055                // Manual IDs should still work with discrete headings.
4056                assert_eq!(section.id(), Some("my-id"));
4057            } else {
4058                panic!("Expected section block");
4059            }
4060        }
4061
4062        #[test]
4063        fn no_section_number() {
4064            let input = ":sectnums:\n\n== Section 1\n\n[discrete]\n=== Discrete\n\n=== Section 1.1";
4065            let mut parser = Parser::default();
4066            let document = parser.parse(input);
4067
4068            let mut blocks = document.child_blocks();
4069
4070            if let Some(crate::blocks::Block::Section(section)) = blocks.next() {
4071                assert_eq!(section.section_title(), "Section 1");
4072                assert!(section.section_number().is_some());
4073
4074                let mut children = section.child_blocks();
4075
4076                // Discrete heading should not have a section number.
4077                if let Some(crate::blocks::Block::Section(discrete)) = children.next() {
4078                    assert_eq!(discrete.section_title(), "Discrete");
4079                    assert_eq!(discrete.section_number(), None);
4080                } else {
4081                    panic!("Expected discrete heading block");
4082                }
4083
4084                // Regular subsection should have a section number.
4085                if let Some(crate::blocks::Block::Section(subsection)) = children.next() {
4086                    assert_eq!(subsection.section_title(), "Section 1.1");
4087                    assert!(subsection.section_number().is_some());
4088                } else {
4089                    panic!("Expected subsection block");
4090                }
4091            } else {
4092                panic!("Expected section block");
4093            }
4094        }
4095
4096        #[test]
4097        fn title_can_have_markup() {
4098            let mut parser = Parser::default();
4099            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
4100
4101            let mi = crate::blocks::SectionBlock::parse(
4102                &BlockMetadata::new("[discrete]\n== Discrete with *bold* text"),
4103                &mut parser,
4104                &mut warnings,
4105            )
4106            .unwrap();
4107
4108            assert_eq!(
4109                mi.item.section_title(),
4110                "Discrete with <strong>bold</strong> text"
4111            );
4112            assert_eq!(mi.item.section_type(), SectionType::Discrete);
4113            assert!(warnings.is_empty());
4114        }
4115
4116        #[test]
4117        fn level_2() {
4118            let mut parser = Parser::default();
4119            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
4120
4121            let mi = crate::blocks::SectionBlock::parse(
4122                &BlockMetadata::new("[discrete]\n=== Level 2 Discrete"),
4123                &mut parser,
4124                &mut warnings,
4125            )
4126            .unwrap();
4127
4128            assert_eq!(mi.item.level(), 2);
4129            assert_eq!(mi.item.section_title(), "Level 2 Discrete");
4130            assert_eq!(mi.item.section_type(), SectionType::Discrete);
4131            assert!(warnings.is_empty());
4132        }
4133
4134        #[test]
4135        fn level_5() {
4136            let mut parser = Parser::default();
4137            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
4138
4139            let mi = crate::blocks::SectionBlock::parse(
4140                &BlockMetadata::new("[discrete]\n====== Level 5 Discrete"),
4141                &mut parser,
4142                &mut warnings,
4143            )
4144            .unwrap();
4145
4146            assert_eq!(mi.item.level(), 5);
4147            assert_eq!(mi.item.section_title(), "Level 5 Discrete");
4148            assert_eq!(mi.item.section_type(), SectionType::Discrete);
4149            assert!(warnings.is_empty());
4150        }
4151
4152        #[test]
4153        fn markdown_style() {
4154            let mut parser = Parser::default();
4155            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
4156
4157            let mi = crate::blocks::SectionBlock::parse(
4158                &BlockMetadata::new("[discrete]\n## Discrete Heading"),
4159                &mut parser,
4160                &mut warnings,
4161            )
4162            .unwrap();
4163
4164            assert_eq!(mi.item.level(), 1);
4165            assert_eq!(mi.item.section_title(), "Discrete Heading");
4166            assert_eq!(mi.item.section_type(), SectionType::Discrete);
4167            assert!(warnings.is_empty());
4168        }
4169
4170        #[test]
4171        fn with_block_title() {
4172            let mut parser = Parser::default();
4173            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
4174
4175            let mi = crate::blocks::SectionBlock::parse(
4176                &BlockMetadata::new(".Block Title\n[discrete]\n== Discrete Heading"),
4177                &mut parser,
4178                &mut warnings,
4179            )
4180            .unwrap();
4181
4182            assert_eq!(mi.item.level(), 1);
4183            assert_eq!(mi.item.section_title(), "Discrete Heading");
4184            assert_eq!(mi.item.section_type(), SectionType::Discrete);
4185            assert_eq!(mi.item.title(), Some("Block Title"));
4186            assert!(warnings.is_empty());
4187        }
4188
4189        #[test]
4190        fn with_anchor() {
4191            let mut parser = Parser::default();
4192            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
4193
4194            let mi = crate::blocks::SectionBlock::parse(
4195                &BlockMetadata::new("[[my_anchor]]\n[discrete]\n== Discrete Heading"),
4196                &mut parser,
4197                &mut warnings,
4198            )
4199            .unwrap();
4200
4201            assert_eq!(mi.item.level(), 1);
4202            assert_eq!(mi.item.section_title(), "Discrete Heading");
4203            assert_eq!(mi.item.section_type(), SectionType::Discrete);
4204            assert_eq!(mi.item.id(), Some("my_anchor"));
4205            assert!(warnings.is_empty());
4206        }
4207
4208        #[test]
4209        fn doesnt_include_subsequent_blocks() {
4210            let mut parser = Parser::default();
4211            let mut warnings: Vec<crate::warnings::Warning<'_>> = vec![];
4212
4213            let mi = crate::blocks::SectionBlock::parse(
4214                &BlockMetadata::new(
4215                    "[discrete]\n== Discrete Heading\n\nparagraph\n\n== Next Section",
4216                ),
4217                &mut parser,
4218                &mut warnings,
4219            )
4220            .unwrap();
4221
4222            assert_eq!(mi.item.level(), 1);
4223            assert_eq!(mi.item.section_title(), "Discrete Heading");
4224            assert_eq!(mi.item.section_type(), SectionType::Discrete);
4225
4226            // Should have no child blocks.
4227            assert!(mi.item.child_blocks().next().is_none());
4228
4229            // The paragraph and next section should be unparsed.
4230            assert!(mi.after.data().contains("paragraph"));
4231            assert!(mi.after.data().contains("== Next Section"));
4232
4233            assert!(warnings.is_empty());
4234        }
4235    }
4236}