Skip to main content

asciidoc_parser/blocks/
section.rs

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