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