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