Skip to main content

asciidoc_parser/blocks/
section.rs

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