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