Skip to main content

asciidoc_parser/document/
header.rs

1use crate::{
2    HasSpan, Parser, Span,
3    attributes::{Attrlist, AttrlistContext},
4    blocks::metadata::block_title_text,
5    content::{Content, SubstitutionGroup, substitute_attributes_in_reftext},
6    document::{
7        Attribute, Author, AuthorLine, InterpretedValue, RefType, RevisionLine,
8        is_attribute_entry_pass_macro, matches_author_pattern, set_author_metadata,
9    },
10    internal::{debug::DebugSliceReference, opaque_iter::opaque_slice_iter},
11    span::MatchedItem,
12    warnings::{MatchAndWarnings, Warning, WarningType},
13};
14
15opaque_slice_iter! {
16    /// An iterator over the document attributes declared in a [`Header`],
17    /// returned by [`Header::attributes`].
18    pub struct HeaderAttributes<'a> yielding Attribute<'a>;
19}
20
21opaque_slice_iter! {
22    /// An iterator over the comment lines in a [`Header`], returned by
23    /// [`Header::comments`].
24    pub struct Comments<'a> yielding Span<'a>;
25}
26
27/// An AsciiDoc document may begin with a document header. The document header
28/// encapsulates the document title, author and revision information,
29/// document-wide attributes, and other document metadata.
30#[derive(Clone, Eq, PartialEq)]
31pub struct Header<'src> {
32    title_source: Option<Span<'src>>,
33    title: Option<String>,
34    doctitle: Option<String>,
35    main_title: Option<String>,
36    subtitle: Option<String>,
37    id: Option<String>,
38    roles: Vec<String>,
39    attributes: Vec<Attribute<'src>>,
40    author_line: Option<AuthorLine<'src>>,
41    authors: Vec<Author>,
42    revision_line: Option<RevisionLine<'src>>,
43    comments: Vec<Span<'src>>,
44    source: Span<'src>,
45}
46
47impl<'src> Header<'src> {
48    pub(crate) fn parse(
49        mut source: Span<'src>,
50        parser: &mut Parser,
51    ) -> MatchAndWarnings<'src, MatchedItem<'src, Self>> {
52        let original_source = source.discard_empty_lines();
53
54        let mut title_source: Option<Span<'src>> = None;
55        let mut title: Option<String> = None;
56
57        // State that mirrors Asciidoctor's `parse_document_header` doctitle
58        // handling: whether an implicit `= Title` line was seen, the eager
59        // (at-title-line) substitution stored in the `doctitle` attribute, and
60        // whether a `:doctitle:` attribute entry appeared below the title (a
61        // candidate to override the section title).
62        let mut saw_implicit_title = false;
63        let mut implicit_overridden_from_above = false;
64        let mut implicit_doctitle_str: Option<String> = None;
65        let mut doctitle_entry_after_title = false;
66
67        let mut id: Option<String> = None;
68        let mut roles: Vec<String> = vec![];
69        let mut pending_block_title_source: Option<Span<'src>> = None;
70        let mut attributes: Vec<Attribute> = vec![];
71        let mut author_line: Option<AuthorLine<'src>> = None;
72        let mut author_attribute: Option<Author> = None;
73        let mut authorinitials_from_entry = false;
74        let mut revision_line: Option<RevisionLine<'src>> = None;
75        let mut comments: Vec<Span<'src>> = vec![];
76        let mut warnings: Vec<Warning<'src>> = vec![];
77
78        // Aside from the title line, items can appear in almost any order.
79        while !source.is_empty() {
80            let line_mi = source.take_normalized_line();
81            let line = line_mi.item;
82
83            // A blank line after the title ends the header.
84            if line.is_empty() {
85                if title.is_some() {
86                    break;
87                }
88                source = line_mi.after;
89            } else if line.starts_with("//") && !line.starts_with("///") {
90                comments.push(line);
91                source = line_mi.after;
92            } else if title.is_some()
93                && let Some((after, terminated)) = skip_block_comment(line, line_mi.after)
94            {
95                // Once a title has been seen, a `////` block comment delimiter
96                // opens a comment block within the header. Skip every line
97                // through the matching closing delimiter (or to the end of the
98                // input if the block is never closed), retaining the whole block
99                // as a single comment so it is not mistaken for the author or
100                // revision line. Blank lines inside the block do not terminate
101                // the header.
102                //
103                // Before a title is seen there is no header author/revision
104                // context to protect, so a leading `////` is left for the block
105                // parser, which retains it as a body-level comment block.
106                comments.push(source.trim_remainder(after).trim_trailing_line_end());
107
108                // An unterminated comment block swallows the rest of the header
109                // (any following attribute entries are never applied), so warn
110                // as Asciidoctor does, anchoring the warning at the opening
111                // delimiter. This mirrors the body-level comment block path,
112                // which reports the same `UnterminatedDelimitedBlock` warning.
113                if !terminated {
114                    warnings.push(Warning {
115                        source: line,
116                        warning: WarningType::UnterminatedDelimitedBlock,
117                        origin: None,
118                    });
119                }
120
121                source = after;
122            } else if line.starts_with(':')
123                && let Some(attr) = Attribute::parse(source, parser)
124            {
125                // Track an explicit `:authorinitials:` entry so a `:author:`
126                // entry (whether it precedes or follows this one) does not
127                // overwrite it with initials re-derived from the author's name.
128                // Asciidoctor preserves an explicit `authorinitials` for a
129                // single `author`; an empty value (`:authorinitials:`) still
130                // counts as explicit, only an unset (`:authorinitials!:`) does
131                // not.
132                if attr
133                    .item
134                    .name()
135                    .data()
136                    .eq_ignore_ascii_case("authorinitials")
137                {
138                    authorinitials_from_entry =
139                        !matches!(attr.item.value(), InterpretedValue::Unset);
140                }
141
142                // Special handling for :author: attribute to populate individual author
143                // attributes.
144                //
145                // When the value is a plain name, the partitioned name replaces
146                // the stored `author` value. This condenses repeated interior
147                // whitespace and joins a name with four or more parts, matching
148                // Asciidoctor.
149                //
150                // Asciidoctor runs an attribute-entry value through the
151                // substitution pipeline *before* partitioning it into name parts
152                // (`process_authors`, `names_only`). When that substitution
153                // produced inline HTML – for example a `pass:[…]` macro whose
154                // content resolves to a link and inline formatting – the rendered
155                // markup is stripped before partitioning so it does not leak into
156                // `firstname`/`middlename`/`lastname`, while the `author` value
157                // keeps the rendered markup. A value that produced no markup
158                // keeps the original raw-value partitioning, so every other form
159                // is unchanged.
160                let mut author_name_override: Option<String> = None;
161                if attr.item.name().data().eq_ignore_ascii_case("author")
162                    && let Some(raw_value) = attr.item.raw_value()
163                    && let Some(author) = Author::parse_from_entry(
164                        raw_value.data(),
165                        attr.item.value().as_maybe_str(),
166                        parser,
167                    )
168                {
169                    // Set individual author attributes.
170                    parser.set_attribute_by_value_from_header("firstname", author.firstname());
171                    if let Some(middlename) = author.middlename() {
172                        parser.set_attribute_by_value_from_header("middlename", middlename);
173                    }
174                    if let Some(lastname) = author.lastname() {
175                        parser.set_attribute_by_value_from_header("lastname", lastname);
176                    }
177
178                    // Do not re-derive `authorinitials` when the document has
179                    // supplied its own via an explicit entry (see above).
180                    if !authorinitials_from_entry {
181                        parser.set_attribute_by_value_from_header(
182                            "authorinitials",
183                            author.initials(),
184                        );
185                    }
186
187                    if let Some(email) = author.email() {
188                        parser.set_attribute_by_value_from_header("email", email);
189                    }
190
191                    // Override the stored `author` value with the reconstructed
192                    // name when the value was partitioned rather than kept
193                    // verbatim. A resolved whole-value `pass:[…]` macro is
194                    // partitioned from its substituted value, so the
195                    // reconstructed name (the rendered markup with name-joiner
196                    // underscores turned to spaces) replaces the stored value.
197                    // Otherwise the value is overridden only for a plain name
198                    // that was partitioned by the fallback whitespace split (four
199                    // or more parts, or punctuation such as a comma); a value that
200                    // matches the pattern, carries an inline email (`<…>`), or
201                    // holds an attribute reference (`{…}`) keeps the substituted
202                    // entry value set below.
203                    let raw = raw_value.data();
204
205                    if is_attribute_entry_pass_macro(raw)
206                        || (!raw.contains('<')
207                            && !raw.contains('{')
208                            && !matches_author_pattern(raw))
209                    {
210                        author_name_override = Some(author.name().to_string());
211                    }
212
213                    // Retain the author parsed from the entry value so the
214                    // resolved author list does not have to re-parse the stored
215                    // `author` attribute. A later `:author:` entry overrides an
216                    // earlier one.
217                    author_attribute = Some(author);
218                }
219
220                parser.set_attribute_from_header(&attr.item, &mut warnings);
221
222                if let Some(author_name) = author_name_override {
223                    parser.set_attribute_by_value_from_header("author", author_name);
224                }
225
226                // A `:doctitle:` entry below the document title is a candidate to
227                // override the implicit section title (resolved after the header
228                // is fully parsed; see below).
229                if title.is_some() && attr.item.name().data().eq_ignore_ascii_case("doctitle") {
230                    doctitle_entry_after_title = true;
231                }
232
233                attributes.push(attr.item);
234                source = attr.after;
235            } else if title.is_none()
236                && line.starts_with('[')
237                && line.ends_with(']')
238                && document_title_follows_block_metadata(source, parser.level_offset())
239                && let Some((metadata, metadata_warnings)) = parse_document_metadata(line, parser)
240            {
241                warnings.extend(metadata_warnings);
242
243                // A block attribute line directly above the document title assigns
244                // metadata to the *document* – its `id`, `reftext`, `role`, and
245                // options – mirroring Asciidoctor's `parse_document_header`. Each
246                // recognized value folds into the document's attributes at this
247                // point in the header, so it follows document order alongside any
248                // equivalent header attribute entry (e.g. `:reftext:`).
249                //
250                // A `separator` sets the subtitle separator; it behaves exactly
251                // like assigning the `title-separator` document attribute here, so
252                // both mechanisms share the same partitioning logic.
253                //
254                // The line is only intercepted when a document title eventually
255                // follows – possibly after further stacked block attribute lines,
256                // each folded on its own pass through this loop, mirroring
257                // Asciidoctor's `parse_block_metadata_lines`. Otherwise it is
258                // block metadata for the body (e.g. a table's `separator`) and is
259                // left for the block parser.
260                if let Some(doc_id) = metadata.id {
261                    id = Some(doc_id);
262                }
263                if let Some(separator) = metadata.separator {
264                    parser.set_attribute_by_value_from_header("title-separator", separator);
265                }
266                if let Some(reftext) = metadata.reftext {
267                    parser.set_attribute_by_value_from_header("reftext", reftext);
268                }
269                if !metadata.roles.is_empty() {
270                    // Fold the role(s) into the `role` document attribute
271                    // (space-joined, as Asciidoctor stores `attributes['role']`)
272                    // and also retain them on the header so `Document::roles()`
273                    // agrees with the document attribute (see #820). Roles from
274                    // separate stacked block attribute lines accumulate, just as
275                    // multiple roles within a single line combine (see #821).
276                    roles.extend(metadata.roles);
277                    parser.set_attribute_by_value_from_header("role", roles.join(" "));
278                }
279                for option in metadata.options {
280                    parser.set_attribute_by_value_from_header(format!("{option}-option"), "");
281                }
282                source = line_mi.after;
283            } else if title.is_none()
284                && let Some(block_title) = block_title_text(line)
285                && document_title_follows_block_metadata(source, parser.level_offset())
286            {
287                // A block title (`.Title`) directly above the document title is
288                // not a title *of* the document – a document has no block title.
289                // Asciidoctor demotes the following `= …` heading to a level-0
290                // section and carries the block title over to the first block of
291                // content. This crate does not represent a body-level document
292                // title as a level-0 section, so the `= …` heading remains the
293                // document title; the block title is still recognized here (so
294                // the heading is not mistaken for a paragraph) and carried over
295                // to the first body block, matching the block-title carryover
296                // above an ordinary section heading (see #782).
297                //
298                // The line is only intercepted when a document title eventually
299                // follows – possibly after further stacked block metadata lines,
300                // each folded on its own pass through this loop. A later block
301                // title overrides an earlier one (last-wins), mirroring
302                // Asciidoctor's `parse_block_metadata_lines`. Otherwise it is an
303                // ordinary block title for the body and is left for the block
304                // parser.
305                pending_block_title_source = Some(block_title);
306                source = line_mi.after;
307            } else if title.is_none()
308                && let Some((marker, count)) = document_title_marker(line, parser.level_offset())
309            {
310                // Strip an optional symmetric close (a trailing ` ==` or ` ##`
311                // matching the opening marker run), mirroring section titles.
312                let title_span = crate::blocks::strip_symmetric_title_close(
313                    line.discard(count).discard_whitespace(),
314                    marker,
315                    count,
316                );
317                saw_implicit_title = true;
318
319                title_source = Some(title_span);
320
321                // A `doctitle` attribute already set above the title – via a
322                // `:doctitle:` entry or the API – overrides the implicit title:
323                // the implicit text is discarded, the existing doctitle stands
324                // as the document title, and the `doctitle` attribute is left
325                // untouched. Otherwise the implicit title is
326                // substituted now (so `{doctitle}` references below resolve to
327                // it) and recorded as the baseline for a later override check.
328                if let InterpretedValue::Value(existing) = parser.attribute_value("doctitle")
329                    && !existing.is_empty()
330                {
331                    implicit_overridden_from_above = true;
332                    implicit_doctitle_str = Some(existing.clone());
333                    title = Some(existing);
334                } else {
335                    let title_str = apply_header_subs(title_span.data(), parser);
336
337                    parser.set_attribute_by_value_from_header("doctitle", &title_str);
338
339                    implicit_doctitle_str = Some(title_str.clone());
340                    title = Some(title_str);
341                }
342
343                source = line_mi.after;
344            } else if title.is_some() && author_line.is_none() {
345                author_line = Some(AuthorLine::parse(line, parser));
346                source = line_mi.after;
347            } else if title.is_some() && author_line.is_some() && revision_line.is_none() {
348                revision_line = Some(RevisionLine::parse(line, parser));
349                source = line_mi.after;
350            } else {
351                if title.is_some() {
352                    warnings.push(Warning {
353                        source: line,
354                        warning: WarningType::DocumentHeaderNotTerminated,
355                        origin: None,
356                    });
357                }
358                break;
359            }
360        }
361
362        let after = source.discard_empty_lines();
363        let source = original_source.trim_remainder(source);
364
365        // Finalize the document (section) title, mirroring Asciidoctor's
366        // `parse_document_header` doctitle handling. The `doctitle` attribute
367        // retains the eager, at-title-line substitution; the section title below
368        // is (re)derived from the *final* attribute state so that:
369        //
370        //   - an implicit `= Title` referencing an attribute defined later in the
371        //     header still resolves ("lazy" resolution),
372        //   - a `:doctitle:` attribute entry (above or below the title, or in a
373        //     document with no title line at all) can supply or override it.
374        let final_doctitle_attr = match parser.attribute_value("doctitle") {
375            InterpretedValue::Value(v) if !v.is_empty() => Some(v),
376            _ => None,
377        };
378
379        title = if saw_implicit_title {
380            // The base section title is normally the eager (at-title-line)
381            // substitution already held in `title`. It is re-resolved against the
382            // final attribute set only when that eager substitution left an
383            // unresolved attribute reference – an attribute defined later in the
384            // header – so that one-shot substitutions such as a `{counter:…}` in
385            // the title are not evaluated a second time. When the implicit title
386            // was overridden by a `doctitle` set above it, `title` already holds
387            // that (resolved) value and is not re-substituted.
388            //
389            // Residual edge: a title that *mixes* a counter with a later-defined
390            // reference (e.g. `= {counter:n} {project-name}`) still contains a
391            // `{` after the eager pass, so the re-resolution runs and advances the
392            // counter a second time. Re-resolving is done from the raw title (not
393            // the eager result) so that escaped `\{…}` and specialchars stay
394            // correct; the counter here is the price of that. This is a rare
395            // combination and no test exercises it.
396            let base = if !implicit_overridden_from_above
397                && let Some(raw) = title_source
398                && implicit_doctitle_str
399                    .as_deref()
400                    .is_some_and(|s| s.contains('{'))
401            {
402                Some(apply_header_subs(raw.data(), parser))
403            } else {
404                title
405            };
406
407            // A `:doctitle:` entry below the title overrides the section title
408            // when it sets a new, non-empty value (an empty or unchanged value
409            // leaves the implicit title in place).
410            if doctitle_entry_after_title
411                && let Some(ref dt) = final_doctitle_attr
412                && Some(dt) != implicit_doctitle_str.as_ref()
413            {
414                Some(dt.clone())
415            } else {
416                base
417            }
418        } else {
419            // No `= Title` line: a `:doctitle:` attribute entry, if any, supplies
420            // the implicit document title.
421            final_doctitle_attr
422        };
423
424        // Partition the (fully substituted) document title into a main title and
425        // an optional subtitle. This happens after the header has been fully
426        // parsed so that a `title-separator` attribute takes effect even when it
427        // is defined below the document title line.
428        let (main_title, subtitle) = match &title {
429            Some(title) => {
430                let (main_title, subtitle) = partition_title(title, parser);
431                (Some(main_title), subtitle)
432            }
433            None => (None, None),
434        };
435
436        // The value returned by `Document::doctitle()`: a `title` attribute entry
437        // overrides the section title (Asciidoctor's `Document#doctitle`), even
438        // when it is blank; otherwise the section title is the doctitle.
439        let doctitle = match parser.attribute_value("title") {
440            InterpretedValue::Value(v) => Some(v),
441            InterpretedValue::Set => Some(String::new()),
442            InterpretedValue::Unset => title.clone(),
443        };
444
445        // A document title carrying an explicit ID (`[#id]` above `= Title`)
446        // registers that ID in the catalog, mirroring Asciidoctor, which
447        // registers the document itself under its ID. Without this, a
448        // cross-reference to the document (`<<id>>`) finds no catalog entry and
449        // falls back to the bracketed `[id]` form instead of the title's
450        // reference text. The reference text follows the same precedence as a
451        // whole-document self-reference (see
452        // [`this_document_reference`](crate::content::this_document_reference)):
453        // an explicit `reftext` attribute, otherwise the document title. The
454        // header is parsed before the body, so this ID registers ahead of any
455        // body anchor; a later duplicate is ignored here and reported by the
456        // body parse.
457        if let Some(doc_id) = id.as_deref() {
458            let reftext = match parser.attribute_value("reftext") {
459                InterpretedValue::Value(reftext) if !reftext.is_empty() => Some(reftext),
460                _ => doctitle.clone().filter(|title| !title.is_empty()),
461            };
462
463            let _ = parser.register_ref(doc_id, reftext.as_deref(), RefType::Section);
464        }
465
466        // Resolve the document's author list. The author line, when present, is
467        // the source of truth; otherwise the list is derived from the `author`,
468        // `authors`, and indexed `author_N` document attributes (see
469        // [`resolve_authors`]). Those attributes can only be set by header
470        // attribute entries, so a header with none needs no reconciliation.
471        let authors = resolve_authors(
472            author_line.as_ref(),
473            author_attribute,
474            !attributes.is_empty(),
475            parser,
476        );
477
478        // Asciidoctor exposes the number of resolved authors via the
479        // `authorcount` document attribute. It defaults to `0` (a built-in
480        // default), so only a non-zero count is materialized here – this keeps an
481        // author-less parse from touching the attribute map at all.
482        if !authors.is_empty() {
483            parser.set_attribute_by_value_from_header("authorcount", authors.len().to_string());
484        }
485
486        // A block title recognized directly above the document title is carried
487        // over to the first block of the body. Stash it on the parser as a
488        // pending block title (the same mechanism a section heading uses); the
489        // first block parsed after the header claims it. The title travels as an
490        // owned snapshot with normal (title) substitutions already applied,
491        // matching a `.Title` line on an ordinary block. It is only ever set
492        // when a document title was seen, since the recognizing branch requires
493        // one to follow.
494        if let Some(block_title) = pending_block_title_source {
495            let mut content = Content::from(block_title);
496            SubstitutionGroup::Normal.apply(&mut content, parser, None);
497            parser.pending_block_title = Some(content.to_owned_title());
498        }
499
500        MatchAndWarnings {
501            item: MatchedItem {
502                item: Self {
503                    title_source,
504                    title,
505                    doctitle,
506                    main_title,
507                    subtitle,
508                    id,
509                    roles,
510                    attributes,
511                    author_line,
512                    authors,
513                    revision_line,
514                    comments,
515                    source: source.trim_trailing_whitespace(),
516                },
517                after,
518            },
519            warnings,
520        }
521    }
522
523    /// Return a [`Span`] describing the raw document title, if there was one.
524    pub fn title_source(&'src self) -> Option<Span<'src>> {
525        self.title_source
526    }
527
528    /// Return the document's title, if there was one, having applied header
529    /// substitutions.
530    ///
531    /// If the title contains a subtitle (see [`subtitle`]), this returns the
532    /// full, combined title. Use [`main_title`] to obtain only the portion
533    /// preceding the subtitle.
534    ///
535    /// [`subtitle`]: Self::subtitle
536    /// [`main_title`]: Self::main_title
537    pub fn title(&self) -> Option<&str> {
538        self.title.as_deref()
539    }
540
541    /// Return the effective document title, applying the override precedence of
542    /// Asciidoctor's `Document#doctitle`: a `title` attribute entry (even a
543    /// blank one) takes priority over the section [`title`], which in turn may
544    /// have been supplied or overridden by a `:doctitle:` attribute entry.
545    ///
546    /// This backs [`Document::doctitle`] and can differ from [`title`]: for
547    /// `= Document Title` followed by `:title: Override`, [`title`] is
548    /// `Document Title` while this is `Override`.
549    ///
550    /// [`title`]: Self::title
551    /// [`Document::doctitle`]: crate::Document::doctitle
552    pub(crate) fn doctitle(&self) -> Option<&str> {
553        self.doctitle.as_deref()
554    }
555
556    /// Return the main portion of the document title, if there was a title.
557    ///
558    /// When the document title contains a subtitle separator (a colon followed
559    /// by a space, by default), the title is partitioned into a main title and
560    /// a [`subtitle`]. This returns the portion preceding the final separator.
561    /// When there is no subtitle, this is identical to [`title`].
562    ///
563    /// [`subtitle`]: Self::subtitle
564    /// [`title`]: Self::title
565    pub fn main_title(&self) -> Option<&str> {
566        self.main_title.as_deref()
567    }
568
569    /// Return the document's subtitle, if the title contained one.
570    ///
571    /// A subtitle is the text following the final subtitle separator in the
572    /// document title. The separator defaults to a colon followed by a space
573    /// (`:{sp}`) and can be overridden with the `title-separator` document
574    /// attribute. Returns `None` when the title has no subtitle.
575    pub fn subtitle(&self) -> Option<&str> {
576        self.subtitle.as_deref()
577    }
578
579    /// Return the document's ID, if one was assigned.
580    ///
581    /// A document ID is set with a block attribute line directly above the
582    /// document title, using either the shorthand (`[#id]`) or longhand
583    /// (`[id=id]`) syntax. Returns `None` when no such ID was given.
584    pub fn id(&self) -> Option<&str> {
585        self.id.as_deref()
586    }
587
588    /// Return the document's role(s), if any were assigned.
589    ///
590    /// Roles are set with a block attribute line directly above the document
591    /// title, using either the shorthand (`[.role]`) or longhand (`[role=…]`)
592    /// syntax; multiple roles combine. The same role(s) are also folded into
593    /// the `role` document attribute (space-joined), so the block accessor and
594    /// the document attribute agree. Returns an empty vector when no role was
595    /// given.
596    pub fn roles(&self) -> Vec<&str> {
597        self.roles.iter().map(String::as_str).collect()
598    }
599
600    /// Return an iterator over the attributes in this header.
601    pub fn attributes(&'src self) -> HeaderAttributes<'src> {
602        HeaderAttributes::new(&self.attributes)
603    }
604
605    /// Returns the author line, if found.
606    pub fn author_line(&self) -> Option<&AuthorLine<'src>> {
607        self.author_line.as_ref()
608    }
609
610    /// Returns the document's authors.
611    ///
612    /// Authors may be declared on the [author line] or via the `author` /
613    /// `author_N` (and companion `email_N`, …) document attributes; this
614    /// returns the resolved list regardless of which mechanism was used. When
615    /// the document has no author information, the slice is empty.
616    ///
617    /// [author line]: https://docs.asciidoctor.org/asciidoc/latest/document/author-line/
618    pub fn authors(&self) -> &[Author] {
619        &self.authors
620    }
621
622    /// Returns the revision line, if found.
623    pub fn revision_line(&self) -> Option<&RevisionLine<'src>> {
624        self.revision_line.as_ref()
625    }
626
627    /// Return an iterator over the comments in this header.
628    pub fn comments(&'src self) -> Comments<'src> {
629        Comments::new(&self.comments)
630    }
631}
632
633impl<'src> HasSpan<'src> for Header<'src> {
634    fn span(&self) -> Span<'src> {
635        self.source
636    }
637}
638
639/// If `line` opens a `////` block comment, consume the comment block and
640/// return the source position immediately after its closing delimiter together
641/// with a flag reporting whether a closing delimiter was found; otherwise
642/// return `None`.
643///
644/// A block comment delimiter is a line of four or more forward slashes and
645/// nothing else, matching Asciidoctor's comment-block delimiter (a line of
646/// exactly three slashes instead terminates the header and is handled by the
647/// caller). The closing delimiter must repeat the opening line exactly; when
648/// it is absent the block runs to the end of the input, mirroring
649/// Asciidoctor's `read_lines_until`, and the returned flag is `false` so the
650/// caller can warn that the comment block was never terminated.
651///
652/// `after` is the source immediately following `line` (the opening delimiter).
653fn skip_block_comment<'src>(line: Span<'src>, after: Span<'src>) -> Option<(Span<'src>, bool)> {
654    let delimiter = line.data();
655    if delimiter.len() < 4 || !delimiter.bytes().all(|b| b == b'/') {
656        return None;
657    }
658
659    let mut next = after;
660    let mut terminated = false;
661    while !next.is_empty() {
662        let line_mi = next.take_normalized_line();
663        next = line_mi.after;
664        if line_mi.item.data() == delimiter {
665            terminated = true;
666            break;
667        }
668    }
669
670    Some((next, terminated))
671}
672
673/// Returns the ATX marker character and its run length for a `line` that is a
674/// document title under the running `level_offset`, or `None` if the line is
675/// not a document title.
676///
677/// Both the AsciiDoc marker (`=`) and the Markdown-style marker (`#`) are
678/// accepted, mirroring the alternation at the head of Asciidoctor's
679/// section-title regex. A line is the document title when its *effective* level
680/// – the syntactic level (marker run length minus one) shifted by
681/// `level_offset` – is 0, mirroring Asciidoctor's `is_next_line_doctitle?`.
682/// With no offset in effect this is exactly a single `=`/`#` marker; a negative
683/// `:leveloffset:` lets a deeper heading (`==` under `-1`) coerce to the
684/// document title, and a positive offset stops a bare `=` from being one.
685///
686/// The marker character and run length are returned so the caller can strip the
687/// markers and require a symmetric close of the same character and width.
688fn document_title_marker(line: Span<'_>, level_offset: i32) -> Option<(char, usize)> {
689    let data = line.data();
690
691    let marker = if data.starts_with('=') {
692        '='
693    } else if data.starts_with('#') {
694        '#'
695    } else {
696        return None;
697    };
698
699    // Count the leading marker run; a run longer than six is not a heading at
700    // all (`======` is the deepest section title).
701    let count = data.chars().take_while(|&c| c == marker).count();
702    if count > 6 {
703        return None;
704    }
705
706    // The marker run must be followed by a blank – a space or a tab – matching
707    // the section parser's `take_required_whitespace` and Asciidoctor's
708    // `[ \t]+` in the section-title regex. Accepting a tab (not just a space)
709    // keeps a tab-delimited heading shifted to effective level 0 on the same
710    // doctitle path as its space-delimited form.
711    if !data[count..].starts_with([' ', '\t']) {
712        return None;
713    }
714
715    // The effective level – the syntactic level (run length minus one) shifted
716    // by the running `leveloffset` – must be 0 for the line to be the document
717    // title.
718    let syntactic_level = (count as i32) - 1;
719    if syntactic_level.saturating_add(level_offset) != 0 {
720        return None;
721    }
722
723    Some((marker, count))
724}
725
726/// Reports whether a *promotable* document title follows the block metadata run
727/// beginning at `source` (the current bracket-delimited line, included in the
728/// scan).
729///
730/// Consecutive document-metadata block attribute lines (see
731/// [`is_document_metadata_line`]) and block title lines (`.Title`, see
732/// [`block_title_text`]) are consumed, and the first line that is neither is
733/// tested for a document title marker. This generalizes the original
734/// single-line lookahead so that stacked metadata lines above the title are all
735/// folded, mirroring Asciidoctor's `parse_block_metadata_lines`.
736///
737/// A line that starts with `[` and ends with `]` but is *not* a valid
738/// document-metadata line (e.g. a `[[anchor]]` block anchor or a leading-space
739/// form) stops the scan without matching, so the run of foldable lines is only
740/// ever a contiguous prefix of well-formed metadata lines terminated by the
741/// title.
742///
743/// The run's *effective* block style also gates promotion: if it resolves to
744/// `discrete`/`float`, the following level-0 (`=`) heading is a discrete
745/// floating title rather than the document title, so this returns `false` and
746/// neither the metadata nor the heading is folded here – both are left for the
747/// block parser, which produces a `SectionType::Discrete` heading (see #1014).
748/// The effective style is tracked with last-wins semantics that mirror
749/// `Attrlist::merge_block_attribute_line` / `merge_block_style_shorthand` – a
750/// line that specifies a block style overrides the running one, and a line with
751/// none leaves it unchanged – so the header's decision always agrees with the
752/// `BlockMetadata::is_discrete` decision the block parser would make on the
753/// same run.
754fn document_title_follows_block_metadata(source: Span<'_>, level_offset: i32) -> bool {
755    let mut next = source;
756    let mut effective_style_is_discrete = false;
757
758    while !next.is_empty() {
759        let line_mi = next.take_normalized_line();
760        let line = line_mi.item;
761
762        if document_title_marker(line, level_offset).is_some() {
763            return !effective_style_is_discrete;
764        }
765
766        // A block title (`.Title`) may appear anywhere in the metadata run above
767        // the document title; it carries no block style, so it leaves the
768        // running effective style unchanged and the scan continues.
769        if block_title_text(line).is_some() {
770            next = line_mi.after;
771            continue;
772        }
773
774        if !is_document_metadata_line(line) {
775            return false;
776        }
777
778        // Fold this line's block style into the running effective style
779        // (last-wins), leaving it unchanged when the line specifies none.
780        if let Some(is_discrete) = metadata_line_block_style_is_discrete(line) {
781            effective_style_is_discrete = is_discrete;
782        }
783
784        next = line_mi.after;
785    }
786
787    false
788}
789
790/// Classifies the block style of a single bracket-delimited document-metadata
791/// `line`, read structurally (the attribute list is *not* parsed):
792///
793/// * `None` – the line specifies no block style (a `[[id]]` anchor, a shorthand
794///   line whose first positional leads with `.`/`#`/`%`, or a named-first
795///   attribute list such as `[reftext=…]`), so it leaves a running style
796///   unchanged.
797/// * `Some(true)` – the block style is `discrete` or `float`.
798/// * `Some(false)` – the block style is some other value (e.g. `[appendix]`).
799///
800/// Reading the style off the raw text – rather than parsing the attribute list
801/// – keeps an embedded `{counter:…}` in a *value* from being evaluated at
802/// header time (the invariant the `rejected_metadata_run_does_not_fire_counter`
803/// test guards). This is safe because a block style is always the first
804/// positional attribute's leading shorthand token – a bare name that can never
805/// itself be a counter.
806fn metadata_line_block_style_is_discrete(line: Span<'_>) -> Option<bool> {
807    // Drop the enclosing square brackets (the caller has confirmed they are
808    // present via [`is_document_metadata_line`]).
809    let inner = line.slice(1..line.len() - 1).data();
810
811    // A `[[id]]` / `[[id,reftext]]` block anchor still has its inner brackets
812    // and carries no block style.
813    if inner.starts_with('[') {
814        return None;
815    }
816
817    // The block style is the leading shorthand token of the first positional
818    // attribute: a run of name characters terminated by a shorthand delimiter
819    // (`.`, `#`, `%`), a comma, whitespace, or the end of the attribute.
820    let token_len = inner
821        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
822        .unwrap_or(inner.len());
823
824    // A first positional that leads with a shorthand delimiter (`[#id]`,
825    // `[.role]`, `[%option]`), or a `=` that makes the token a named attribute
826    // name (`[reftext=…]`), carries no block style.
827    if token_len == 0 || inner[token_len..].starts_with('=') {
828        return None;
829    }
830
831    let token = &inner[..token_len];
832    Some(token == "discrete" || token == "float")
833}
834
835/// Reports whether `line` is a block attribute line that this crate folds into
836/// document metadata when it appears above the document title.
837///
838/// This captures the purely syntactic acceptance rules shared by the lookahead
839/// ([`document_title_follows_block_metadata`]) and the folding step
840/// ([`parse_document_metadata`]): the line must be bracket-delimited and its
841/// contents must not be empty and must not begin with whitespace. Both a block
842/// attribute list (`[#id]`, `[reftext=…]`, …) and a `[[id]]` / `[[id,reftext]]`
843/// block anchor are accepted; an empty `[[]]` anchor is not.
844fn is_document_metadata_line(line: Span<'_>) -> bool {
845    if !(line.starts_with('[') && line.ends_with(']')) {
846        return false;
847    }
848
849    let inner = line.slice(1..line.len() - 1);
850
851    if inner.is_empty() || inner.starts_with(' ') || inner.starts_with('\t') {
852        return false;
853    }
854
855    // A `[[anchor]]` block anchor is document metadata when it names a non-empty
856    // anchor; the empty `[[]]` form is not (`inner` would be the two-character
857    // `[]`).
858    if inner.starts_with('[') && inner.ends_with(']') {
859        return inner.len() > 2;
860    }
861
862    true
863}
864
865/// Document metadata folded from a block attribute line appearing directly
866/// above the document title.
867///
868/// Each field holds an already-owned copy of a recognized value, so the caller
869/// can apply them without borrowing the (dropped) attribute list.
870struct DocumentMetadata {
871    id: Option<String>,
872    separator: Option<String>,
873    reftext: Option<String>,
874    roles: Vec<String>,
875    options: Vec<String>,
876}
877
878/// Parse a metadata line appearing directly above the document title into
879/// [`DocumentMetadata`], dispatching on its form: a `[[id]]` / `[[id,reftext]]`
880/// block anchor, or a block attribute list (e.g. `[reftext="…"]`, `[#id]`,
881/// `[role=…]`, `[separator=::]`).
882///
883/// The `line` is expected to begin with `[` and end with `]`. Returns the
884/// folded metadata together with any warnings raised while parsing when the
885/// line is a well-formed metadata line, and `None` otherwise (so the caller can
886/// fall through to its normal handling of the line, which then terminates the
887/// header). The warnings are only surfaced when the line is actually consumed
888/// as document metadata; otherwise the line is left for the block parser, which
889/// reports them on its own path.
890fn parse_document_metadata<'src>(
891    line: Span<'src>,
892    parser: &Parser,
893) -> Option<(DocumentMetadata, Vec<Warning<'src>>)> {
894    // Reject forms that are not document metadata (a leading space or tab, an
895    // empty list, or an empty `[[]]` anchor); see [`is_document_metadata_line`]
896    // for the shared acceptance rules. The caller has already confirmed the
897    // enclosing square brackets are present.
898    if !is_document_metadata_line(line) {
899        return None;
900    }
901
902    // Drop the enclosing square brackets.
903    let inner = line.slice(1..line.len() - 1);
904
905    // A `[[id]]` / `[[id,reftext]]` block anchor still has its inner brackets.
906    if inner.starts_with('[') && inner.ends_with(']') {
907        return parse_document_metadata_anchor(inner.slice(1..inner.len() - 1), parser);
908    }
909
910    let MatchAndWarnings {
911        item: MatchedItem {
912            item: attrlist,
913            after: _,
914        },
915        warnings,
916    } = Attrlist::parse(inner, parser, AttrlistContext::Block);
917
918    let metadata = DocumentMetadata {
919        id: attrlist.id().map(str::to_string),
920        separator: attrlist
921            .named_attribute("separator")
922            .map(|attr| attr.value().to_string()),
923        reftext: attrlist
924            .named_attribute("reftext")
925            .map(|attr| attr.value().to_string()),
926        roles: attrlist.roles().iter().map(|r| r.to_string()).collect(),
927        options: attrlist.options().iter().map(|o| o.to_string()).collect(),
928    };
929
930    Some((metadata, warnings))
931}
932
933/// Fold a `[[id]]` / `[[id,reftext]]` block anchor above the document title
934/// into [`DocumentMetadata`]. `anchor` is the text *between* the inner brackets
935/// (`id` or `id,reftext`).
936///
937/// The anchor ID must be a valid XML name (as the block parser requires of any
938/// block anchor); otherwise `None` is returned so the line falls through and
939/// ends the header. Attribute references in the reftext are resolved against
940/// the attributes in effect at the anchor, mirroring the section/block anchor
941/// path (see [`substitute_attributes_in_reftext`]) rather than the doctitle's
942/// `SpecialCharacters`-plus-references header substitution.
943fn parse_document_metadata_anchor<'src>(
944    anchor: Span<'src>,
945    parser: &Parser,
946) -> Option<(DocumentMetadata, Vec<Warning<'src>>)> {
947    // Split an optional reftext off at the first comma (`id,reftext`). A comma
948    // in the final position leaves the whole span – trailing comma included – as
949    // the ID, which then fails XML-name validation, matching the block parser.
950    let (id, reftext) = match anchor.position(|c| c == ',') {
951        Some(comma) if comma < anchor.len() - 1 => (
952            anchor.slice(0..comma),
953            Some(substitute_attributes_in_reftext(
954                anchor.slice(comma + 1..anchor.len()),
955                parser,
956            )),
957        ),
958        _ => (anchor, None),
959    };
960
961    if !id.is_xml_name() {
962        return None;
963    }
964
965    let metadata = DocumentMetadata {
966        id: Some(id.data().to_string()),
967        separator: None,
968        reftext: reftext.map(|r| r.to_string()),
969        roles: vec![],
970        options: vec![],
971    };
972
973    Some((metadata, vec![]))
974}
975
976/// Partition a document title into its main title and optional subtitle.
977///
978/// The separator is the value of the `title-separator` document attribute
979/// (defaulting to `:`) with a single space appended. The separator is searched
980/// for from the end of the title, so only the last occurrence partitions the
981/// title. When the separator is not present, the entire title is the main
982/// title and there is no subtitle.
983fn partition_title(title: &str, parser: &Parser) -> (String, Option<String>) {
984    // Read the configured `title-separator` document attribute directly. Unlike
985    // `Parser::attribute_value`, this bypasses the counter overlay: the title
986    // separator is a configuration attribute, never a counter, and Asciidoctor
987    // likewise resolves it with a plain attribute lookup.
988    let separator = match parser.effective_attribute("title-separator") {
989        Some(av) => match &av.value {
990            InterpretedValue::Value(value) if !value.is_empty() => value.clone(),
991            _ => ":".to_string(),
992        },
993        None => ":".to_string(),
994    };
995
996    let separator = format!("{separator} ");
997
998    match title.rfind(&separator) {
999        Some(index) => {
1000            let main_title = title[..index].to_string();
1001            let subtitle = title[index + separator.len()..].to_string();
1002            (main_title, Some(subtitle))
1003        }
1004        None => (title.to_string(), None),
1005    }
1006}
1007
1008/// Resolves the document's author list.
1009///
1010/// When an [`AuthorLine`] is present it is normally authoritative (and has
1011/// already populated the `author_N` attributes), except that an explicit
1012/// `:authors:` entry whose value differs from the computed implicit value
1013/// replaces the implicit list. Otherwise the list is
1014/// reconstructed from document attributes, mirroring Asciidoctor's
1015/// `parse_header_metadata` reconciliation in precedence order: a
1016/// directly-assigned `author` attribute
1017/// stands in for a single author; failing that a semicolon-separated `authors`
1018/// attribute is split into individual authors; and failing that a contiguous
1019/// run of indexed `author_N` attributes (`author_1`, `author_2`, …) each
1020/// contributes one author. In each case the email is taken from the companion
1021/// `email`/`email_N` attribute, reflecting its final value.
1022///
1023/// For the `authors` and `author_N` forms this also populates the derived
1024/// author attributes (`author`, `firstname`, `authorinitials`, the `authors`
1025/// list, the per-author `author_N` companions, …) so that references such as
1026/// `{author}` resolve, matching Asciidoctor's `process_authors` (see issue
1027/// #718).
1028///
1029/// `author_attribute` is the author already parsed from the raw `author`
1030/// attribute value (see the header parse loop); it is reused rather than
1031/// re-parsing the HTML-encoded stored value.
1032///
1033/// `header_has_attributes` reports whether the header carried any attribute
1034/// entries. When it did not, none of the `author` / `authors` / `author_N`
1035/// attributes can be set, so the attribute lookups are skipped – the common
1036/// case for a document whose header is just a title (or absent).
1037fn resolve_authors(
1038    author_line: Option<&AuthorLine>,
1039    author_attribute: Option<Author>,
1040    header_has_attributes: bool,
1041    parser: &mut Parser,
1042) -> Vec<Author> {
1043    if let Some(author_line) = author_line {
1044        let implicit_authors: Vec<Author> = author_line.authors().cloned().collect();
1045
1046        // Reconcile an explicit `:authors:` entry against the implicit author
1047        // line, mirroring Asciidoctor's `parse_header_metadata`. When the
1048        // entry's value differs from the computed (comma-joined) value of the
1049        // implicit list, the entry *replaces* that list – re-splitting on `;`,
1050        // updating `authorcount`, and repopulating the derived `author_N` (and
1051        // per-author name-part) attributes. A value that matches the computed
1052        // value leaves the implicit list untouched.
1053        //
1054        // The `:authors:` path is reconciled first (matching Asciidoctor's
1055        // precedence: an explicit `:authors:` entry outranks the indexed
1056        // `:author_N:` entries reconciled just below); the single `:author:`
1057        // entry path keeps its existing inline handling.
1058        if let Some(authors_value) = attribute_string(parser, "authors") {
1059            let computed = implicit_authors
1060                .iter()
1061                .map(Author::name)
1062                .collect::<Vec<_>>()
1063                .join(", ");
1064
1065            if authors_value != computed
1066                && let Some(authors) = authors_from_authors_attribute(&authors_value, parser)
1067            {
1068                // `set_author_metadata` overwrites the derived attributes for
1069                // the replacement authors but does not clear ones the implicit
1070                // list set that the replacement does not (a shorter list leaves
1071                // a stale trailing `author_N`; a replacement author lacking a
1072                // middle name / email leaves the implicit `middlename` /
1073                // `email`). This mirrors Asciidoctor's `doc_attrs.update
1074                // author_metadata` – a merge that overwrites present keys and
1075                // never deletes absent ones – so `{author_3}` and friends can
1076                // outlive an `authorcount` that reflects the shorter list. The
1077                // divergence is Asciidoctor's; see the shrinking-replacement
1078                // regression test in this module.
1079                set_author_metadata(parser, &authors);
1080                return authors;
1081            }
1082        }
1083
1084        // Reconcile explicit indexed `:author_N:` entries against the implicit
1085        // author line, mirroring the indexed branch of Asciidoctor's
1086        // `parse_header_metadata`. Each `author_N` attribute whose value still
1087        // equals the implicit author's name leaves that position untouched; any
1088        // position whose value differs is overridden.
1089        // When at least one position was overridden, the reconciled list
1090        // repopulates the derived attributes – including the combined `authors`
1091        // string – so `{authors}` and `Document::authors()` reflect the
1092        // override, not just the individual `author_N` attribute.
1093        //
1094        // The indexed `author_N` attributes are only assigned once the implicit
1095        // line carries two or more authors, so a single-author line has no
1096        // `author_1` and needs no reconciliation here.
1097        if attribute_string(parser, "author_1").is_some() {
1098            let mut reconciled: Vec<Author> = Vec::new();
1099            let mut any_override = false;
1100            let mut index = 1;
1101
1102            while let Some(current) = attribute_string(parser, &format!("author_{index}")) {
1103                match implicit_authors.get(index - 1) {
1104                    // The position is unchanged from the implicit line: reuse the
1105                    // already-parsed author so its name partition is preserved. A
1106                    // multi-word `lastname` such as `het Draeke` would otherwise
1107                    // be re-split into a middle and last name by the names-only
1108                    // partitioning below.
1109                    Some(implicit) if current == implicit.name() => {
1110                        reconciled.push(implicit.clone());
1111                    }
1112
1113                    // The position was overridden (or added beyond the implicit
1114                    // list): partition the entry value with the names-only rules,
1115                    // exactly as Asciidoctor does for an attribute-supplied
1116                    // author.
1117                    _ => {
1118                        any_override = true;
1119
1120                        if let Some(author) = Author::parse(&current, parser, true) {
1121                            reconciled.push(author);
1122                        }
1123                    }
1124                }
1125
1126                index += 1;
1127            }
1128
1129            if any_override {
1130                let reconciled = collect_indexed_authors(reconciled.into_iter(), parser);
1131
1132                set_author_metadata(parser, &reconciled);
1133
1134                return reconciled;
1135            }
1136        }
1137
1138        return implicit_authors;
1139    }
1140
1141    if !header_has_attributes {
1142        return vec![];
1143    }
1144
1145    // A directly-assigned `author` attribute describes a single author – but
1146    // only while it remains set. A later `:author!:` unsets the attribute
1147    // without carrying a raw value to refresh `author_attribute`, so consult
1148    // the attribute's final state rather than trusting the cached parse. The
1149    // per-author attributes for this form were already populated inline as the
1150    // `:author:` entry was parsed.
1151    if attribute_string(parser, "author").is_some()
1152        && let Some(author) = author_attribute
1153    {
1154        let author = author.with_email(attribute_string(parser, "email"));
1155
1156        // Mirror Asciidoctor's `process_authors`, which sets the combined
1157        // `authors` attribute to the single author's name. The remaining derived
1158        // keys (`firstname`, `authorinitials`, …) were populated inline as the
1159        // `:author:` entry was parsed – deliberately, so an explicit
1160        // `:authorinitials:` override survives – but `authors` was still left
1161        // unset there (see issue #1027).
1162        parser.set_attribute_by_value_from_header("authors", author.name());
1163
1164        return vec![author];
1165    }
1166
1167    // A semicolon-separated `authors` attribute entry contributes one author
1168    // per entry (Asciidoctor's `process_authors` with `multiple` set).
1169    if let Some(authors_value) = attribute_string(parser, "authors")
1170        && let Some(authors) = authors_from_authors_attribute(&authors_value, parser)
1171    {
1172        set_author_metadata(parser, &authors);
1173        return authors;
1174    }
1175
1176    // Otherwise, walk the indexed `author_N` attributes until one is missing.
1177    let mut raw_names = vec![];
1178    let mut index = 1;
1179
1180    while let Some(name) = attribute_string(parser, &format!("author_{index}")) {
1181        raw_names.push(name);
1182        index += 1;
1183    }
1184
1185    let authors = collect_indexed_authors(
1186        raw_names
1187            .iter()
1188            .filter_map(|name| Author::parse(name, parser, true)),
1189        parser,
1190    );
1191
1192    if !authors.is_empty() {
1193        set_author_metadata(parser, &authors);
1194    }
1195
1196    authors
1197}
1198
1199/// Builds the author list described by an `authors` attribute value, splitting
1200/// it into individual authors (Asciidoctor's `process_authors` with `multiple`
1201/// set) and attaching each author's companion `email_N`. Returns `None` when
1202/// the value yields no authors (so the caller can fall through to its next
1203/// resolution step).
1204fn authors_from_authors_attribute(value: &str, parser: &Parser) -> Option<Vec<Author>> {
1205    let authors = collect_indexed_authors(
1206        split_author_entries(value)
1207            .into_iter()
1208            .filter_map(|entry| Author::parse(entry, parser, true)),
1209        parser,
1210    );
1211
1212    if authors.is_empty() {
1213        None
1214    } else {
1215        Some(authors)
1216    }
1217}
1218
1219/// Reads the string value of a document attribute, or `None` when it is unset
1220/// or set without a value.
1221fn attribute_string(parser: &Parser, name: &str) -> Option<String> {
1222    match parser.attribute_value(name) {
1223        InterpretedValue::Value(value) => Some(value),
1224        _ => None,
1225    }
1226}
1227
1228/// Attaches each parsed author's companion `email_N` attribute (`email_1` for
1229/// the first author, `email_2` for the second, …) so the resolved list carries
1230/// the emails supplied through separate attribute entries.
1231fn collect_indexed_authors(authors: impl Iterator<Item = Author>, parser: &Parser) -> Vec<Author> {
1232    authors
1233        .enumerate()
1234        .map(|(idx, author)| {
1235            author.with_email(attribute_string(parser, &format!("email_{}", idx + 1)))
1236        })
1237        .collect()
1238}
1239
1240/// Splits an `authors` attribute value into raw author entries.
1241///
1242/// A semicolon separates authors only when it is immediately followed by a
1243/// space or the end of the value, matching Asciidoctor's `AuthorDelimiterRx`
1244/// (`/;(?: |$)/`). Blank entries are left in place; [`Author::parse`] trims
1245/// each entry and discards the empty ones.
1246fn split_author_entries(value: &str) -> Vec<&str> {
1247    let bytes = value.as_bytes();
1248    let mut entries: Vec<&str> = Vec::new();
1249    let mut start = 0;
1250
1251    for (index, c) in value.char_indices() {
1252        if c != ';' {
1253            continue;
1254        }
1255
1256        let is_separator = match bytes.get(index + 1) {
1257            Some(next) => *next == b' ',
1258            None => true,
1259        };
1260
1261        if is_separator {
1262            entries.push(&value[start..index]);
1263            start = index + 1;
1264        }
1265    }
1266
1267    entries.push(&value[start..]);
1268    entries
1269}
1270
1271fn apply_header_subs(source: &str, parser: &Parser) -> String {
1272    let span = Span::new(source);
1273
1274    let mut content = Content::from(span);
1275    SubstitutionGroup::Header.apply(&mut content, parser, None);
1276
1277    content.rendered().to_string()
1278}
1279
1280impl std::fmt::Debug for Header<'_> {
1281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1282        f.debug_struct("Header")
1283            .field("title_source", &self.title_source)
1284            .field("title", &self.title)
1285            .field("doctitle", &self.doctitle)
1286            .field("main_title", &self.main_title)
1287            .field("subtitle", &self.subtitle)
1288            .field("id", &self.id)
1289            .field("roles", &self.roles)
1290            .field("attributes", &DebugSliceReference(&self.attributes))
1291            .field("author_line", &self.author_line)
1292            .field("authors", &self.authors)
1293            .field("revision_line", &self.revision_line)
1294            .field("comments", &DebugSliceReference(&self.comments))
1295            .field("source", &self.source)
1296            .finish()
1297    }
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    #![allow(clippy::unwrap_used)]
1303
1304    use crate::tests::prelude::*;
1305
1306    #[test]
1307    fn attributes_iterator_supports_exact_size_double_ended_and_nth() {
1308        // Exercises the opaque `HeaderAttributes` iterator's full surface:
1309        // `ExactSizeIterator` (and, through its default `len`, `size_hint`),
1310        // `DoubleEndedIterator`, and the `nth` override.
1311        let doc = Parser::default().parse(":alpha: 1\n:bravo: 2\n:charlie: 3\n\nbody\n");
1312        let header = doc.header();
1313
1314        // Collect once to learn the order and length without hard-coding a count.
1315        let names: Vec<_> = header
1316            .attributes()
1317            .map(|a| a.name().data().to_string())
1318            .collect();
1319
1320        assert!(names.len() >= 3);
1321        assert_eq!(names.first().map(String::as_str), Some("alpha"));
1322
1323        assert_eq!(header.attributes().len(), names.len());
1324
1325        assert_eq!(
1326            header.attributes().next_back().map(|a| a.name().data()),
1327            names.last().map(String::as_str),
1328        );
1329
1330        assert_eq!(
1331            header.attributes().nth(1).map(|a| a.name().data()),
1332            Some("bravo"),
1333        );
1334    }
1335
1336    #[test]
1337    fn leveloffset_does_not_coerce_an_over_deep_heading_to_the_doctitle() {
1338        // A marker run deeper than `======` is not a valid heading at all, so
1339        // even a negative `:leveloffset:` whose shift would drive its effective
1340        // level to 0 must not coerce it to the document title. It stays body
1341        // content (which the block parser then reports as exceeding the maximum
1342        // heading level), leaving the document with no doctitle.
1343        let doc = Parser::default().parse(":leveloffset: -6\n======= Not A Title");
1344
1345        assert_eq!(doc.doctitle(), None);
1346    }
1347
1348    #[test]
1349    fn impl_clone() {
1350        // Silly test to mark the #[derive(...)] line as covered.
1351        let mut parser = Parser::default();
1352
1353        let h1 = crate::document::Header::parse(crate::Span::new("= Title"), &mut parser)
1354            .unwrap_if_no_warnings();
1355        let h2 = h1.clone();
1356
1357        assert_eq!(h1, h2);
1358    }
1359
1360    #[test]
1361    fn only_title() {
1362        let mut parser = Parser::default();
1363        let mi = crate::document::Header::parse(crate::Span::new("= Just the Title"), &mut parser)
1364            .unwrap_if_no_warnings();
1365
1366        assert_eq!(
1367            mi.item,
1368            Header {
1369                title_source: Some(Span {
1370                    data: "Just the Title",
1371                    line: 1,
1372                    col: 3,
1373                    offset: 2,
1374                }),
1375                title: Some("Just the Title"),
1376                attributes: &[],
1377                author_line: None,
1378                revision_line: None,
1379                comments: &[],
1380                source: Span {
1381                    data: "= Just the Title",
1382                    line: 1,
1383                    col: 1,
1384                    offset: 0,
1385                }
1386            }
1387        );
1388
1389        assert_eq!(
1390            mi.after,
1391            Span {
1392                data: "",
1393                line: 1,
1394                col: 17,
1395                offset: 16
1396            }
1397        );
1398    }
1399
1400    #[test]
1401    fn trims_leading_spaces_in_title() {
1402        // This is totally a judgement call on my part. As far as I can tell,
1403        // the language doesn't describe behavior here.
1404        let mut parser = Parser::default();
1405        let mi =
1406            crate::document::Header::parse(crate::Span::new("=    Just the Title"), &mut parser)
1407                .unwrap_if_no_warnings();
1408
1409        assert_eq!(
1410            mi.item,
1411            Header {
1412                title_source: Some(Span {
1413                    data: "Just the Title",
1414                    line: 1,
1415                    col: 6,
1416                    offset: 5,
1417                }),
1418                title: Some("Just the Title"),
1419                attributes: &[],
1420                author_line: None,
1421                revision_line: None,
1422                comments: &[],
1423                source: Span {
1424                    data: "=    Just the Title",
1425                    line: 1,
1426                    col: 1,
1427                    offset: 0,
1428                }
1429            }
1430        );
1431
1432        assert_eq!(
1433            mi.after,
1434            Span {
1435                data: "",
1436                line: 1,
1437                col: 20,
1438                offset: 19
1439            }
1440        );
1441    }
1442
1443    #[test]
1444    fn trims_trailing_spaces_in_title() {
1445        let mut parser = Parser::default();
1446        let mi =
1447            crate::document::Header::parse(crate::Span::new("= Just the Title   "), &mut parser)
1448                .unwrap_if_no_warnings();
1449
1450        assert_eq!(
1451            mi.item,
1452            Header {
1453                title_source: Some(Span {
1454                    data: "Just the Title",
1455                    line: 1,
1456                    col: 3,
1457                    offset: 2,
1458                }),
1459                title: Some("Just the Title"),
1460                attributes: &[],
1461                author_line: None,
1462                revision_line: None,
1463                comments: &[],
1464                source: Span {
1465                    data: "= Just the Title",
1466                    line: 1,
1467                    col: 1,
1468                    offset: 0,
1469                }
1470            }
1471        );
1472
1473        assert_eq!(
1474            mi.after,
1475            Span {
1476                data: "",
1477                line: 1,
1478                col: 20,
1479                offset: 19
1480            }
1481        );
1482    }
1483
1484    #[test]
1485    fn title_and_attribute() {
1486        let mut parser = Parser::default();
1487
1488        let mi = crate::document::Header::parse(
1489            crate::Span::new("= Just the Title\n:foo: bar\n\nblah"),
1490            &mut parser,
1491        )
1492        .unwrap_if_no_warnings();
1493
1494        assert_eq!(
1495            mi.item,
1496            Header {
1497                title_source: Some(Span {
1498                    data: "Just the Title",
1499                    line: 1,
1500                    col: 3,
1501                    offset: 2,
1502                }),
1503                title: Some("Just the Title"),
1504                attributes: &[Attribute {
1505                    name: Span {
1506                        data: "foo",
1507                        line: 2,
1508                        col: 2,
1509                        offset: 18,
1510                    },
1511                    value_source: Some(Span {
1512                        data: "bar",
1513                        line: 2,
1514                        col: 7,
1515                        offset: 23,
1516                    }),
1517                    value: InterpretedValue::Value("bar"),
1518                    source: Span {
1519                        data: ":foo: bar",
1520                        line: 2,
1521                        col: 1,
1522                        offset: 17,
1523                    }
1524                }],
1525                author_line: None,
1526                revision_line: None,
1527                comments: &[],
1528                source: Span {
1529                    data: "= Just the Title\n:foo: bar",
1530                    line: 1,
1531                    col: 1,
1532                    offset: 0,
1533                }
1534            }
1535        );
1536
1537        assert_eq!(
1538            mi.after,
1539            Span {
1540                data: "blah",
1541                line: 4,
1542                col: 1,
1543                offset: 28
1544            }
1545        );
1546    }
1547
1548    #[test]
1549    fn title_applies_header_substitutions() {
1550        let mut parser = Parser::default();
1551
1552        let mi = crate::document::Header::parse(
1553            crate::Span::new("= The Title & Some{sp}Nonsense\n:foo: bar\n\nblah"),
1554            &mut parser,
1555        )
1556        .unwrap_if_no_warnings();
1557
1558        assert_eq!(
1559            mi.item,
1560            Header {
1561                title_source: Some(Span {
1562                    data: "The Title & Some{sp}Nonsense",
1563                    line: 1,
1564                    col: 3,
1565                    offset: 2,
1566                }),
1567                title: Some("The Title &amp; Some Nonsense"),
1568                attributes: &[Attribute {
1569                    name: Span {
1570                        data: "foo",
1571                        line: 2,
1572                        col: 2,
1573                        offset: 32,
1574                    },
1575                    value_source: Some(Span {
1576                        data: "bar",
1577                        line: 2,
1578                        col: 7,
1579                        offset: 37,
1580                    }),
1581                    value: InterpretedValue::Value("bar"),
1582                    source: Span {
1583                        data: ":foo: bar",
1584                        line: 2,
1585                        col: 1,
1586                        offset: 31,
1587                    }
1588                }],
1589                author_line: None,
1590                revision_line: None,
1591                comments: &[],
1592                source: Span {
1593                    data: "= The Title & Some{sp}Nonsense\n:foo: bar",
1594                    line: 1,
1595                    col: 1,
1596                    offset: 0,
1597                }
1598            }
1599        );
1600
1601        assert_eq!(
1602            mi.after,
1603            Span {
1604                data: "blah",
1605                line: 4,
1606                col: 1,
1607                offset: 42
1608            }
1609        );
1610    }
1611
1612    #[test]
1613    fn attribute_without_title() {
1614        let mut parser = Parser::default();
1615        let mi = crate::document::Header::parse(crate::Span::new(":foo: bar\n\nblah"), &mut parser)
1616            .unwrap_if_no_warnings();
1617
1618        assert_eq!(
1619            mi.item,
1620            Header {
1621                title_source: None,
1622                title: None,
1623                attributes: &[Attribute {
1624                    name: Span {
1625                        data: "foo",
1626                        line: 1,
1627                        col: 2,
1628                        offset: 1,
1629                    },
1630                    value_source: Some(Span {
1631                        data: "bar",
1632                        line: 1,
1633                        col: 7,
1634                        offset: 6,
1635                    }),
1636                    value: InterpretedValue::Value("bar"),
1637                    source: Span {
1638                        data: ":foo: bar",
1639                        line: 1,
1640                        col: 1,
1641                        offset: 0,
1642                    }
1643                }],
1644                author_line: None,
1645                revision_line: None,
1646                comments: &[],
1647                source: Span {
1648                    data: ":foo: bar",
1649                    line: 1,
1650                    col: 1,
1651                    offset: 0,
1652                }
1653            }
1654        );
1655
1656        assert_eq!(
1657            mi.after,
1658            Span {
1659                data: "blah",
1660                line: 3,
1661                col: 1,
1662                offset: 11
1663            }
1664        );
1665    }
1666
1667    #[test]
1668    fn sets_doctitle_attribute() {
1669        let mut parser = Parser::default();
1670        let _doc = parser.parse("= Document Title Goes Here");
1671
1672        assert_eq!(
1673            parser.attribute_value("doctitle"),
1674            InterpretedValue::Value("Document Title Goes Here")
1675        );
1676    }
1677
1678    #[test]
1679    fn sets_author_attributes_from_author_attribute() {
1680        let mut parser = Parser::default();
1681        let _doc = parser.parse(":author: John Q. Smith <john@example.com>");
1682
1683        // Verify that individual author attributes are set.
1684        assert_eq!(
1685            parser.attribute_value("firstname"),
1686            InterpretedValue::Value("John")
1687        );
1688        assert_eq!(
1689            parser.attribute_value("middlename"),
1690            InterpretedValue::Value("Q.")
1691        );
1692        assert_eq!(
1693            parser.attribute_value("lastname"),
1694            InterpretedValue::Value("Smith")
1695        );
1696        assert_eq!(
1697            parser.attribute_value("authorinitials"),
1698            InterpretedValue::Value("JQS")
1699        );
1700        assert_eq!(
1701            parser.attribute_value("email"),
1702            InterpretedValue::Value("john@example.com")
1703        );
1704
1705        // Also verify the original author attribute is still set (with HTML encoding).
1706        assert_eq!(
1707            parser.attribute_value("author"),
1708            InterpretedValue::Value("John Q. Smith &lt;john@example.com&gt;")
1709        );
1710    }
1711
1712    #[test]
1713    fn author_attribute_with_four_or_more_parts_is_partitioned() {
1714        // A value with more than three parts does not match the author pattern,
1715        // so it is partitioned by splitting on whitespace into at most three
1716        // parts. The trailing parts are assigned to `lastname` and repeated
1717        // interior whitespace is condensed.
1718        let mut parser = Parser::default();
1719        let _doc = parser.parse(":author: Leroy  Harold  Scherer,  Jr.");
1720
1721        assert_eq!(
1722            parser.attribute_value("author"),
1723            InterpretedValue::Value("Leroy Harold Scherer, Jr.")
1724        );
1725        assert_eq!(
1726            parser.attribute_value("firstname"),
1727            InterpretedValue::Value("Leroy")
1728        );
1729        assert_eq!(
1730            parser.attribute_value("middlename"),
1731            InterpretedValue::Value("Harold")
1732        );
1733        assert_eq!(
1734            parser.attribute_value("lastname"),
1735            InterpretedValue::Value("Scherer, Jr.")
1736        );
1737        assert_eq!(
1738            parser.attribute_value("authorinitials"),
1739            InterpretedValue::Value("LHS")
1740        );
1741    }
1742
1743    #[test]
1744    fn author_attribute_two_part_fallback_partitions_lastname() {
1745        // A two-part value that does not match the author pattern (here because
1746        // of the comma attached to the first part) still partitions into a first
1747        // and last name via the whitespace split.
1748        let mut parser = Parser::default();
1749        let _doc = parser.parse(":author: Jane, Doe");
1750
1751        assert_eq!(
1752            parser.attribute_value("author"),
1753            InterpretedValue::Value("Jane, Doe")
1754        );
1755        assert_eq!(
1756            parser.attribute_value("firstname"),
1757            InterpretedValue::Value("Jane,")
1758        );
1759        assert_eq!(
1760            parser.attribute_value("middlename"),
1761            InterpretedValue::Unset
1762        );
1763        assert_eq!(
1764            parser.attribute_value("lastname"),
1765            InterpretedValue::Value("Doe")
1766        );
1767    }
1768
1769    #[test]
1770    fn author_attribute_single_part_fallback_is_firstname_only() {
1771        // A single-token value that does not match the author pattern partitions
1772        // to `firstname` alone, with no middle or last name.
1773        let mut parser = Parser::default();
1774        let _doc = parser.parse(":author: Jane,");
1775
1776        assert_eq!(
1777            parser.attribute_value("author"),
1778            InterpretedValue::Value("Jane,")
1779        );
1780        assert_eq!(
1781            parser.attribute_value("firstname"),
1782            InterpretedValue::Value("Jane,")
1783        );
1784        assert_eq!(
1785            parser.attribute_value("middlename"),
1786            InterpretedValue::Unset
1787        );
1788        assert_eq!(parser.attribute_value("lastname"), InterpretedValue::Unset);
1789    }
1790
1791    #[test]
1792    fn author_attribute_four_or_more_parts_with_inline_email() {
1793        // A four-plus-part fallback value that carries a trailing `<email>` must
1794        // split the email off before partitioning, so it lands in `email` rather
1795        // than being absorbed into `lastname`.
1796        let mut parser = Parser::default();
1797        let _doc = parser.parse(":author: Leroy  Harold  Scherer,  Jr. <leroy@example.com>");
1798
1799        assert_eq!(
1800            parser.attribute_value("firstname"),
1801            InterpretedValue::Value("Leroy")
1802        );
1803        assert_eq!(
1804            parser.attribute_value("middlename"),
1805            InterpretedValue::Value("Harold")
1806        );
1807        assert_eq!(
1808            parser.attribute_value("lastname"),
1809            InterpretedValue::Value("Scherer, Jr.")
1810        );
1811        assert_eq!(
1812            parser.attribute_value("email"),
1813            InterpretedValue::Value("leroy@example.com")
1814        );
1815        assert_eq!(
1816            parser.attribute_value("authorinitials"),
1817            InterpretedValue::Value("LHS")
1818        );
1819    }
1820
1821    #[test]
1822    fn author_attribute_reference_expands_and_partitions() {
1823        // A `:author:` value given entirely as an attribute reference is expanded
1824        // and then partitioned by the names-only rules, so it yields the same
1825        // metadata as the equivalent literal four-plus-part name.
1826        let mut parser = Parser::default();
1827        let _doc = parser.parse(":full-name: Leroy Harold Scherer, Jr.\n:author: {full-name}");
1828
1829        assert_eq!(
1830            parser.attribute_value("firstname"),
1831            InterpretedValue::Value("Leroy")
1832        );
1833        assert_eq!(
1834            parser.attribute_value("middlename"),
1835            InterpretedValue::Value("Harold")
1836        );
1837        assert_eq!(
1838            parser.attribute_value("lastname"),
1839            InterpretedValue::Value("Scherer, Jr.")
1840        );
1841        assert_eq!(
1842            parser.attribute_value("authorinitials"),
1843            InterpretedValue::Value("LHS")
1844        );
1845    }
1846
1847    #[test]
1848    fn author_attribute_reference_within_larger_value_expands_and_partitions() {
1849        // The same partitioning applies when the reference is only part of the
1850        // value (so the single-attribute fast path is not taken) and the expanded
1851        // result still fails the author pattern.
1852        let mut parser = Parser::default();
1853        let _doc = parser.parse(":rest: Harold Scherer, Jr.\n:author: Leroy {rest}");
1854
1855        assert_eq!(
1856            parser.attribute_value("firstname"),
1857            InterpretedValue::Value("Leroy")
1858        );
1859        assert_eq!(
1860            parser.attribute_value("middlename"),
1861            InterpretedValue::Value("Harold")
1862        );
1863        assert_eq!(
1864            parser.attribute_value("lastname"),
1865            InterpretedValue::Value("Scherer, Jr.")
1866        );
1867    }
1868
1869    #[test]
1870    fn author_attribute_non_breaking_space_is_not_a_name_separator() {
1871        // Only ASCII whitespace separates name parts. A non-breaking space
1872        // (U+00A0) joining two words keeps them as a single first name, matching
1873        // Ruby's whitespace split.
1874        let mut parser = Parser::default();
1875        let _doc = parser.parse(":author: John\u{a0}Doe Scherer, Jr.");
1876
1877        assert_eq!(
1878            parser.attribute_value("firstname"),
1879            InterpretedValue::Value("John\u{a0}Doe")
1880        );
1881        assert_eq!(
1882            parser.attribute_value("middlename"),
1883            InterpretedValue::Value("Scherer,")
1884        );
1885        assert_eq!(
1886            parser.attribute_value("lastname"),
1887            InterpretedValue::Value("Jr.")
1888        );
1889    }
1890
1891    #[test]
1892    fn sets_author_attributes_from_author_attribute_two_names() {
1893        let mut parser = Parser::default();
1894        let _doc = parser.parse(":author: Jane Doe");
1895
1896        // Verify that individual author attributes are set.
1897        assert_eq!(
1898            parser.attribute_value("firstname"),
1899            InterpretedValue::Value("Jane")
1900        );
1901        assert_eq!(
1902            parser.attribute_value("middlename"),
1903            InterpretedValue::Unset
1904        );
1905        assert_eq!(
1906            parser.attribute_value("lastname"),
1907            InterpretedValue::Value("Doe")
1908        );
1909        assert_eq!(
1910            parser.attribute_value("authorinitials"),
1911            InterpretedValue::Value("JD")
1912        );
1913        assert_eq!(parser.attribute_value("email"), InterpretedValue::Unset);
1914    }
1915
1916    #[test]
1917    fn sets_author_attributes_from_author_attribute_single_name() {
1918        let mut parser = Parser::default();
1919        let _doc = parser.parse(":author: Cher");
1920
1921        // Verify that individual author attributes are set.
1922        assert_eq!(
1923            parser.attribute_value("firstname"),
1924            InterpretedValue::Value("Cher")
1925        );
1926        assert_eq!(
1927            parser.attribute_value("middlename"),
1928            InterpretedValue::Unset
1929        );
1930        assert_eq!(parser.attribute_value("lastname"), InterpretedValue::Unset);
1931        assert_eq!(
1932            parser.attribute_value("authorinitials"),
1933            InterpretedValue::Value("C")
1934        );
1935        assert_eq!(parser.attribute_value("email"), InterpretedValue::Unset);
1936    }
1937
1938    #[test]
1939    fn sets_author_attributes_from_empty_string() {
1940        let mut parser = Parser::default();
1941        let _doc = parser.parse(":author:");
1942
1943        // Verify that individual author attributes are set.
1944        assert_eq!(parser.attribute_value("firstname"), InterpretedValue::Unset);
1945        assert_eq!(
1946            parser.attribute_value("middlename"),
1947            InterpretedValue::Unset
1948        );
1949        assert_eq!(parser.attribute_value("lastname"), InterpretedValue::Unset);
1950        assert_eq!(
1951            parser.attribute_value("authorinitials"),
1952            InterpretedValue::Unset
1953        );
1954        assert_eq!(parser.attribute_value("email"), InterpretedValue::Unset);
1955
1956        assert_eq!(parser.attribute_value("author"), InterpretedValue::Set);
1957    }
1958
1959    #[test]
1960    fn authors_from_author_line() {
1961        let doc = Parser::default().parse("= Title\nKismet R. Lee <kismet@asciidoctor.org>");
1962
1963        assert_eq!(doc.authors().len(), 1);
1964
1965        let author = doc.authors().first().unwrap();
1966        assert_eq!(author.name(), "Kismet R. Lee");
1967        assert_eq!(author.email(), Some("kismet@asciidoctor.org"));
1968        assert_eq!(author.initials(), "KRL");
1969    }
1970
1971    #[test]
1972    fn authors_from_author_attribute() {
1973        // With no author line, a directly-assigned `author` attribute stands in
1974        // for a single author, taking its email from the `email` attribute.
1975        let doc =
1976            Parser::default().parse("= Title\n:author: Jane Q. Public\n:email: jane@example.com");
1977
1978        assert_eq!(doc.authors().len(), 1);
1979
1980        let author = doc.authors().first().unwrap();
1981        assert_eq!(author.name(), "Jane Q. Public");
1982        assert_eq!(author.firstname(), "Jane");
1983        assert_eq!(author.middlename(), Some("Q."));
1984        assert_eq!(author.lastname(), Some("Public"));
1985        assert_eq!(author.email(), Some("jane@example.com"));
1986        assert_eq!(author.initials(), "JQP");
1987    }
1988
1989    #[test]
1990    fn authors_from_author_attribute_with_inline_email() {
1991        // The email may be given inline in the `author` attribute value; the
1992        // resolved author reflects the parsed name and that email.
1993        let doc = Parser::default().parse("= Title\n:author: John Q. Smith <john@example.com>");
1994
1995        assert_eq!(doc.authors().len(), 1);
1996
1997        let author = doc.authors().first().unwrap();
1998        assert_eq!(author.name(), "John Q. Smith");
1999        assert_eq!(author.firstname(), "John");
2000        assert_eq!(author.middlename(), Some("Q."));
2001        assert_eq!(author.lastname(), Some("Smith"));
2002        assert_eq!(author.email(), Some("john@example.com"));
2003        assert_eq!(author.initials(), "JQS");
2004    }
2005
2006    #[test]
2007    fn authors_is_empty_without_author_info() {
2008        let doc = Parser::default().parse("= Title\n\nBody.");
2009
2010        assert!(doc.authors().is_empty());
2011    }
2012
2013    #[test]
2014    fn authorcount_reflects_author_line() {
2015        // The `authorcount` attribute counts the resolved authors, whether they
2016        // come from the author line …
2017        let doc = Parser::default().parse("= Title\nJane Doe; John Smith\n\nBody.");
2018
2019        assert_eq!(doc.authors().len(), 2);
2020        assert_eq!(
2021            doc.attribute_value("authorcount"),
2022            InterpretedValue::Value("2")
2023        );
2024
2025        // … or from a single `:author:` attribute entry.
2026        let doc = Parser::default().parse(":author: Jane Doe\n\nBody.");
2027
2028        assert_eq!(
2029            doc.attribute_value("authorcount"),
2030            InterpretedValue::Value("1")
2031        );
2032
2033        // A document with no author information reports a count of zero.
2034        let doc = Parser::default().parse("= Title\n\nBody.");
2035
2036        assert_eq!(
2037            doc.attribute_value("authorcount"),
2038            InterpretedValue::Value("0")
2039        );
2040    }
2041
2042    #[test]
2043    fn explicit_authorinitials_after_author_still_wins() {
2044        // An explicit `:authorinitials:` entry is honored regardless of whether
2045        // it precedes or follows the `:author:` entry.
2046        let doc = Parser::default().parse(":author: Doc Writer\n:authorinitials: DOC\n\nBody.");
2047
2048        assert_eq!(
2049            doc.attribute_value("authorinitials"),
2050            InterpretedValue::Value("DOC")
2051        );
2052
2053        // A second `:author:` entry after the explicit initials does not clobber
2054        // them.
2055        let doc = Parser::default()
2056            .parse(":author: Jane Roe\n:authorinitials: DOC\n:author: Doc Writer\n\nBody.");
2057
2058        assert_eq!(
2059            doc.attribute_value("author"),
2060            InterpretedValue::Value("Doc Writer")
2061        );
2062        assert_eq!(
2063            doc.attribute_value("authorinitials"),
2064            InterpretedValue::Value("DOC")
2065        );
2066    }
2067
2068    #[test]
2069    fn later_author_entry_redrives_initials_without_explicit_override() {
2070        // Without an explicit `:authorinitials:` entry, a later `:author:`
2071        // overwrites the initials derived from the earlier one.
2072        let doc = Parser::default().parse(":author: Jane Roe\n:author: Doc Writer\n\nBody.");
2073
2074        assert_eq!(
2075            doc.attribute_value("authorinitials"),
2076            InterpretedValue::Value("DW")
2077        );
2078    }
2079
2080    #[test]
2081    fn single_author_entry_sets_combined_authors_attribute() {
2082        // A single `:author:` entry sets the combined `authors` attribute to the
2083        // author's name, matching Asciidoctor (`{authors}` equals `{author}`),
2084        // just as the implicit author line already does.
2085        let doc = Parser::default().parse(":author: Doc Writer\n\nBody.");
2086
2087        assert_eq!(
2088            doc.attribute_value("author"),
2089            InterpretedValue::Value("Doc Writer")
2090        );
2091        assert_eq!(
2092            doc.attribute_value("authors"),
2093            InterpretedValue::Value("Doc Writer")
2094        );
2095
2096        // The name is reconstructed the same way in the `authors` string, and an
2097        // explicit `:authorinitials:` override is still preserved (the reason
2098        // this path populates `authors` inline rather than via the shared
2099        // metadata routine).
2100        let doc = Parser::default()
2101            .parse("= T\n:authorinitials: DOC\n:author: Kismet R. Chameleon\n\nBody.");
2102
2103        assert_eq!(
2104            doc.attribute_value("author"),
2105            InterpretedValue::Value("Kismet R. Chameleon")
2106        );
2107        assert_eq!(
2108            doc.attribute_value("authors"),
2109            InterpretedValue::Value("Kismet R. Chameleon")
2110        );
2111        assert_eq!(
2112            doc.attribute_value("authorinitials"),
2113            InterpretedValue::Value("DOC")
2114        );
2115    }
2116
2117    #[test]
2118    fn author_unset_after_entry_leaves_authors_unset() {
2119        // A later `:author!:` removes the single author, so the combined
2120        // `authors` attribute must not be materialized from the earlier entry.
2121        let doc = Parser::default().parse(":author: Jane Doe\n:author!:\n\nBody.");
2122
2123        assert_eq!(doc.attribute_value("author"), InterpretedValue::Unset);
2124        assert_eq!(doc.attribute_value("authors"), InterpretedValue::Unset);
2125        assert!(doc.authors().is_empty());
2126    }
2127
2128    #[test]
2129    fn explicit_authorinitials_not_preserved_for_indexed_or_authors_forms() {
2130        // The explicit-`:authorinitials:` override is honored only for a single
2131        // `:author:` entry. For the indexed `author_N` form (and, as tested
2132        // elsewhere, the `:authors:` form) the derived initials overwrite it,
2133        // matching Asciidoctor (see [`set_author_metadata`]).
2134        let doc = Parser::default().parse(":authorinitials: DOC\n:author_1: Doc Writer\n\nBody.");
2135
2136        assert_eq!(
2137            doc.attribute_value("author"),
2138            InterpretedValue::Value("Doc Writer")
2139        );
2140        assert_eq!(
2141            doc.attribute_value("authorinitials"),
2142            InterpretedValue::Value("DW")
2143        );
2144    }
2145
2146    #[test]
2147    fn authors_attribute_splits_into_indexed_authors() {
2148        // A semicolon-separated `:authors:` entry populates the author list and
2149        // the derived per-author attributes.
2150        let doc = Parser::default().parse(":authors: Jane Doe; John Q. Smith\n\nBody.");
2151
2152        assert_eq!(doc.authors().len(), 2);
2153        assert_eq!(
2154            doc.attribute_value("authors"),
2155            InterpretedValue::Value("Jane Doe, John Q. Smith")
2156        );
2157        assert_eq!(
2158            doc.attribute_value("author"),
2159            InterpretedValue::Value("Jane Doe")
2160        );
2161        assert_eq!(
2162            doc.attribute_value("author_2"),
2163            InterpretedValue::Value("John Q. Smith")
2164        );
2165        assert_eq!(
2166            doc.attribute_value("middlename_2"),
2167            InterpretedValue::Value("Q.")
2168        );
2169        assert_eq!(
2170            doc.attribute_value("authorinitials_2"),
2171            InterpretedValue::Value("JQS")
2172        );
2173    }
2174
2175    #[test]
2176    fn authors_attribute_attaches_companion_emails_and_base_middlename() {
2177        // Companion `:email_N:` entries attach to each split author (`email_1`
2178        // also fills the base `email`), and the first author's middle name lands
2179        // on the unsuffixed `middlename`.
2180        let doc = Parser::default().parse(
2181            ":authors: Jane Q. Doe; John Smith\n:email_1: jane@example.com\n:email_2: john@example.com\n\nBody.",
2182        );
2183
2184        let authors = doc.authors();
2185        assert_eq!(authors.len(), 2);
2186        assert_eq!(authors.first().unwrap().email(), Some("jane@example.com"));
2187        assert_eq!(authors.get(1).unwrap().email(), Some("john@example.com"));
2188
2189        assert_eq!(
2190            doc.attribute_value("middlename"),
2191            InterpretedValue::Value("Q.")
2192        );
2193        assert_eq!(
2194            doc.attribute_value("email"),
2195            InterpretedValue::Value("jane@example.com")
2196        );
2197        assert_eq!(
2198            doc.attribute_value("email_2"),
2199            InterpretedValue::Value("john@example.com")
2200        );
2201    }
2202
2203    #[test]
2204    fn authors_attribute_semicolon_without_space_is_one_author() {
2205        // A semicolon that is not followed by a space (or the end of the value)
2206        // does not separate authors.
2207        let doc = Parser::default().parse(":authors: Joe Doe;Smith Johnson\n\nBody.");
2208
2209        assert_eq!(doc.authors().len(), 1);
2210        assert_eq!(
2211            doc.attribute_value("authorcount"),
2212            InterpretedValue::Value("1")
2213        );
2214    }
2215
2216    #[test]
2217    fn authors_attribute_single_name_authors_and_trailing_separator() {
2218        // Single-name authors carry no last name, and a trailing `;` (a
2219        // separator at the end of the value) contributes no extra author.
2220        let doc = Parser::default().parse(":authors: Cher; Madonna;\n\nBody.");
2221
2222        assert_eq!(doc.authors().len(), 2);
2223        assert_eq!(
2224            doc.attribute_value("authors"),
2225            InterpretedValue::Value("Cher, Madonna")
2226        );
2227        assert_eq!(
2228            doc.attribute_value("author"),
2229            InterpretedValue::Value("Cher")
2230        );
2231        assert_eq!(doc.attribute_value("lastname"), InterpretedValue::Unset);
2232        assert_eq!(
2233            doc.attribute_value("authorinitials"),
2234            InterpretedValue::Value("C")
2235        );
2236        assert_eq!(
2237            doc.attribute_value("author_2"),
2238            InterpretedValue::Value("Madonna")
2239        );
2240        assert_eq!(doc.attribute_value("lastname_2"), InterpretedValue::Unset);
2241        assert_eq!(
2242            doc.attribute_value("authorcount"),
2243            InterpretedValue::Value("2")
2244        );
2245    }
2246
2247    #[test]
2248    fn authors_attribute_with_only_empty_entries_yields_no_authors() {
2249        // An `:authors:` value that splits into only empty entries resolves to no
2250        // authors, so the derived attributes stay unset and `authorcount` is 0.
2251        let doc = Parser::default().parse(":authors: ;\n\nBody.");
2252
2253        assert!(doc.authors().is_empty());
2254        assert_eq!(doc.attribute_value("author"), InterpretedValue::Unset);
2255        assert_eq!(
2256            doc.attribute_value("authorcount"),
2257            InterpretedValue::Value("0")
2258        );
2259
2260        // With no authors resolved, the raw `authors` value is left as written
2261        // (never rewritten to a comma-joined list) – matching Asciidoctor, whose
2262        // `process_authors` returns only `authorcount` for an all-empty value.
2263        assert_eq!(doc.attribute_value("authors"), InterpretedValue::Value(";"));
2264    }
2265
2266    #[test]
2267    fn author_attribute_takes_precedence_over_authors() {
2268        // A base `:author:` entry wins over a semicolon-separated `:authors:`
2269        // entry (matching Asciidoctor's `if author … elsif authors` order), so
2270        // only the single author is resolved.
2271        let doc = Parser::default()
2272            .parse(":author: Solo Writer\n:authors: Jane Doe; John Smith\n\nBody.");
2273
2274        assert_eq!(doc.authors().len(), 1);
2275        assert_eq!(
2276            doc.attribute_value("author"),
2277            InterpretedValue::Value("Solo Writer")
2278        );
2279        assert_eq!(doc.attribute_value("author_2"), InterpretedValue::Unset);
2280    }
2281
2282    #[test]
2283    fn author_unset_after_being_assigned_yields_no_authors() {
2284        // A later `:author!:` unsets the attribute; the resolved author list
2285        // must reflect the removal, not the earlier assignment.
2286        let doc = Parser::default().parse("= Title\n:author: Jane Doe\n:author!:\n\nBody.");
2287
2288        assert_eq!(doc.attribute_value("author"), InterpretedValue::Unset);
2289        assert!(doc.authors().is_empty());
2290    }
2291
2292    #[test]
2293    fn authors_entry_replacing_a_longer_implicit_list_leaves_stale_attributes() {
2294        // When a differing `:authors:` entry replaces a *longer* implicit author
2295        // line, the reconciliation re-derives only the replacement authors'
2296        // attributes. Attributes the implicit line set that the replacement does
2297        // not are left in place: a trailing `author_N` (and its name parts)
2298        // beyond the shorter list, and the base `email` / `middlename` when the
2299        // replacement's first author omits them.
2300        //
2301        // This is not a gap in the port – it is exactly Asciidoctor's behavior.
2302        // Its `parse_header_metadata` reconciles with `doc_attrs.update
2303        // author_metadata`, a merge that overwrites the keys the replacement
2304        // supplies and never deletes the ones it omits, so `{author_3}` (and the
2305        // stale `email` / `middlename`) survive alongside an `authorcount` that
2306        // reflects the shorter list. Verified byte-for-byte against Asciidoctor
2307        // 2.0.26; there is no upstream test for this shrinking case, so this one
2308        // pins the parity to guard against a future "cleanup" that would clear
2309        // the stale keys and diverge.
2310        let mut parser = Parser::default();
2311        let doc = parser.parse(
2312            "= T\nKismet R. Lee <kismet@example.com>; Junior Writer; Third Author\n:authors: Stuart Rackham; Dan Allen\n",
2313        );
2314
2315        // The typed author list and `authorcount` reflect the two-author
2316        // replacement.
2317        let authors = doc.header().authors();
2318        assert_eq!(authors.len(), 2);
2319        assert_eq!(authors.first().unwrap().name(), "Stuart Rackham");
2320        assert_eq!(authors.get(1).unwrap().name(), "Dan Allen");
2321        assert_eq!(
2322            parser.attribute_value("authorcount"),
2323            InterpretedValue::Value("2")
2324        );
2325
2326        // The replacement authors' attributes are re-derived.
2327        assert_eq!(
2328            parser.attribute_value("authors"),
2329            InterpretedValue::Value("Stuart Rackham, Dan Allen")
2330        );
2331        assert_eq!(
2332            parser.attribute_value("author_1"),
2333            InterpretedValue::Value("Stuart Rackham")
2334        );
2335        assert_eq!(
2336            parser.attribute_value("author_2"),
2337            InterpretedValue::Value("Dan Allen")
2338        );
2339
2340        // Stale attributes from the longer/richer implicit list survive, exactly
2341        // as they do in Asciidoctor: the third author, the first implicit
2342        // author's email, and its middle name.
2343        assert_eq!(
2344            parser.attribute_value("author_3"),
2345            InterpretedValue::Value("Third Author")
2346        );
2347        assert_eq!(
2348            parser.attribute_value("email"),
2349            InterpretedValue::Value("kismet@example.com")
2350        );
2351        assert_eq!(
2352            parser.attribute_value("middlename"),
2353            InterpretedValue::Value("R.")
2354        );
2355    }
2356
2357    #[test]
2358    fn impl_debug() {
2359        let doc = Parser::default().parse("= Example Title\n\nabc\n\ndef");
2360        let header = doc.header();
2361
2362        assert_eq!(
2363            format!("{header:#?}"),
2364            r#"Header {
2365    title_source: Some(
2366        Span {
2367            data: "Example Title",
2368            line: 1,
2369            col: 3,
2370            offset: 2,
2371        },
2372    ),
2373    title: Some(
2374        "Example Title",
2375    ),
2376    doctitle: Some(
2377        "Example Title",
2378    ),
2379    main_title: Some(
2380        "Example Title",
2381    ),
2382    subtitle: None,
2383    id: None,
2384    roles: [],
2385    attributes: &[],
2386    author_line: None,
2387    authors: [],
2388    revision_line: None,
2389    comments: &[],
2390    source: Span {
2391        data: "= Example Title",
2392        line: 1,
2393        col: 1,
2394        offset: 0,
2395    },
2396}"#
2397        );
2398    }
2399
2400    #[test]
2401    fn no_subtitle() {
2402        // A title without a colon-space sequence has no subtitle, and its main
2403        // title equals its full title.
2404        let doc = Parser::default().parse("= Just the Title");
2405        let header = doc.header();
2406
2407        assert_eq!(header.title(), Some("Just the Title"));
2408        assert_eq!(header.main_title(), Some("Just the Title"));
2409        assert_eq!(header.subtitle(), None);
2410    }
2411
2412    #[test]
2413    fn no_title() {
2414        // With no document title at all, every title accessor returns `None`.
2415        let doc = Parser::default().parse(":foo: bar\n\nbody");
2416        let header = doc.header();
2417
2418        assert_eq!(header.title(), None);
2419        assert_eq!(header.main_title(), None);
2420        assert_eq!(header.subtitle(), None);
2421    }
2422
2423    #[test]
2424    fn colon_without_space_is_not_a_separator() {
2425        // The separator is a colon *followed by a space*; a bare colon does not
2426        // partition the title.
2427        let doc = Parser::default().parse("= Ratio 3:1 Explained");
2428        let header = doc.header();
2429
2430        assert_eq!(header.main_title(), Some("Ratio 3:1 Explained"));
2431        assert_eq!(header.subtitle(), None);
2432    }
2433
2434    #[test]
2435    fn subtitle_available_on_document() {
2436        // The subtitle is reachable directly from `Document` as well as from its
2437        // `Header`.
2438        let doc = Parser::default().parse("= Main Title: Subtitle");
2439
2440        assert_eq!(doc.doctitle(), Some("Main Title: Subtitle"));
2441        assert_eq!(doc.subtitle(), Some("Subtitle"));
2442    }
2443
2444    #[test]
2445    fn separator_block_attribute_above_title() {
2446        // A `[separator=::]` block attribute above the title changes the
2447        // subtitle separator for that title.
2448        let doc = Parser::default().parse("[separator=::]\n= Main Title:: Subtitle");
2449        let header = doc.header();
2450
2451        assert_eq!(header.main_title(), Some("Main Title"));
2452        assert_eq!(header.subtitle(), Some("Subtitle"));
2453
2454        // The custom separator replaces the default: a plain colon-space no
2455        // longer partitions the title.
2456        let doc = Parser::default().parse("[separator=::]\n= Main: Title:: Subtitle");
2457        let header = doc.header();
2458
2459        assert_eq!(header.main_title(), Some("Main: Title"));
2460        assert_eq!(header.subtitle(), Some("Subtitle"));
2461    }
2462
2463    #[test]
2464    fn separator_attribute_entry_overrides_block_attribute() {
2465        // When both are present, the later assignment wins in document order.
2466        // Here the `:title-separator:` entry follows the block attribute.
2467        let doc = Parser::default()
2468            .parse("[separator=::]\n= Main Title;; Subtitle\n:title-separator: ;;");
2469        let header = doc.header();
2470
2471        assert_eq!(header.main_title(), Some("Main Title"));
2472        assert_eq!(header.subtitle(), Some("Subtitle"));
2473    }
2474
2475    #[test]
2476    fn unrecognized_block_attribute_above_title_is_consumed() {
2477        // A well-formed block attribute line above the document title is now
2478        // parsed as document metadata, so the title that follows is recognized
2479        // even when the line carries no attribute this crate folds. An
2480        // unrecognized attribute (here `foo`) simply contributes no document
2481        // metadata rather than terminating the header.
2482        let doc = Parser::default().parse("[foo=bar]\n= A Header Title");
2483        let header = doc.header();
2484
2485        assert_eq!(header.title(), Some("A Header Title"));
2486        assert_eq!(header.subtitle(), None);
2487        assert_eq!(doc.attribute_value("foo"), InterpretedValue::Unset);
2488    }
2489
2490    #[test]
2491    fn reftext_block_attribute_above_title() {
2492        // A `[reftext="…"]` block attribute above the title recovers the title
2493        // (previously lost) and folds the value into the document's `reftext`
2494        // attribute, matching the `:reftext:` header attribute.
2495        let doc =
2496            Parser::default().parse("[reftext=\"Links and Stuff\"]\n= Links & Stuff\n\nBody.");
2497        let header = doc.header();
2498
2499        assert_eq!(header.title(), Some("Links &amp; Stuff"));
2500        assert_eq!(
2501            doc.attribute_value("reftext"),
2502            InterpretedValue::Value("Links and Stuff")
2503        );
2504        assert_eq!(rendered_paragraphs(&doc), vec!["Body."]);
2505    }
2506
2507    #[test]
2508    fn id_block_attribute_above_title() {
2509        // The `[#id]` shorthand above the title assigns the document ID and
2510        // still recovers the title.
2511        let doc = Parser::default().parse("[#docid]\n= Document Title\n\nBody.");
2512        let header = doc.header();
2513
2514        assert_eq!(header.title(), Some("Document Title"));
2515        assert_eq!(header.id(), Some("docid"));
2516        assert_eq!(doc.id(), Some("docid"));
2517
2518        // The longhand `[id=…]` form is equivalent.
2519        let doc = Parser::default().parse("[id=docid]\n= Document Title");
2520        assert_eq!(doc.header().id(), Some("docid"));
2521    }
2522
2523    #[test]
2524    fn bracket_anchor_above_title() {
2525        // A `[[id]]` block anchor above the title assigns the document ID and
2526        // recovers the title, exactly as the `[#id]` shorthand does.
2527        let doc = Parser::default().parse("[[idname]]\n= Document Title\n\ncontent");
2528        let header = doc.header();
2529
2530        assert_eq!(header.title(), Some("Document Title"));
2531        assert_eq!(header.id(), Some("idname"));
2532        assert_eq!(doc.id(), Some("idname"));
2533        assert_eq!(rendered_paragraphs(&doc), vec!["content"]);
2534
2535        // The `[[id,reftext]]` form additionally folds its reference text into
2536        // the document's `reftext` attribute, resolving attribute references the
2537        // way a section/block anchor reftext does.
2538        let doc = Parser::default()
2539            .parse(":product: Widgets\n[[guide,{product} Guide]]\n= User Guide\n\ncontent");
2540        let header = doc.header();
2541
2542        assert_eq!(header.title(), Some("User Guide"));
2543        assert_eq!(header.id(), Some("guide"));
2544        assert_eq!(
2545            doc.attribute_value("reftext"),
2546            InterpretedValue::Value("Widgets Guide")
2547        );
2548    }
2549
2550    #[test]
2551    fn bracket_anchor_above_title_requires_a_valid_name() {
2552        // A `[[…]]` line whose anchor name is not a valid XML name is not folded
2553        // as document metadata; the header terminates as it does for any other
2554        // unrecognized line.
2555        let doc = Parser::default().parse("[[bad name]]\n= Document Title\n\ncontent");
2556        let header = doc.header();
2557
2558        assert_eq!(header.title(), None);
2559        assert_eq!(header.id(), None);
2560    }
2561
2562    #[test]
2563    fn stacked_block_attributes_above_title() {
2564        // Multiple block attribute lines may stack above the document title;
2565        // each folds into the document's metadata and the title is still
2566        // recovered (see #821). Here an `[#id]` line and a `[reftext="…"]` line
2567        // both sit above the title.
2568        let doc = Parser::default()
2569            .parse("[#docid]\n[reftext=\"Links and Stuff\"]\n= Links & Stuff\n\nBody.");
2570        let header = doc.header();
2571
2572        assert_eq!(header.title(), Some("Links &amp; Stuff"));
2573        assert_eq!(header.id(), Some("docid"));
2574        assert_eq!(doc.id(), Some("docid"));
2575        assert_eq!(
2576            doc.attribute_value("reftext"),
2577            InterpretedValue::Value("Links and Stuff")
2578        );
2579        assert_eq!(rendered_paragraphs(&doc), vec!["Body."]);
2580    }
2581
2582    #[test]
2583    fn stacked_block_attributes_combine_roles() {
2584        // Roles from stacked block attribute lines all fold into the document's
2585        // `role` attribute (space-joined) and are surfaced through the block
2586        // API, alongside an ID set on a separate line.
2587        let doc = Parser::default().parse("[#docid]\n[.one]\n[.two]\n= Document Title");
2588        let header = doc.header();
2589
2590        assert_eq!(header.title(), Some("Document Title"));
2591        assert_eq!(header.id(), Some("docid"));
2592        assert_eq!(
2593            doc.attribute_value("role"),
2594            InterpretedValue::Value("one two")
2595        );
2596        assert_eq!(header.roles(), vec!["one", "two"]);
2597    }
2598
2599    #[test]
2600    fn stacked_block_attributes_require_a_following_title() {
2601        // Stacked block attribute lines are only folded when a document title
2602        // eventually follows. Without one, the run is left for the block parser
2603        // and no title is recognized.
2604        let doc = Parser::default().parse("[#docid]\n[reftext=\"Stuff\"]\n\nBody.");
2605        let header = doc.header();
2606
2607        assert_eq!(header.title(), None);
2608        assert_eq!(header.id(), None);
2609        assert_eq!(doc.attribute_value("reftext"), InterpretedValue::Unset);
2610    }
2611
2612    #[test]
2613    fn stacked_block_attributes_fold_a_block_anchor() {
2614        // A `[[anchor]]` line is a foldable metadata line like the attribute
2615        // lists around it, so a run mixing the two still folds and the title is
2616        // recognized. The ID follows last-wins semantics across the run (the
2617        // block anchor overrides the earlier `[#docid]`), matching Asciidoctor.
2618        let doc = Parser::default().parse("[#docid]\n[[anchor]]\n= Some Title");
2619        let header = doc.header();
2620
2621        assert_eq!(header.title(), Some("Some Title"));
2622        assert_eq!(header.id(), Some("anchor"));
2623    }
2624
2625    #[test]
2626    fn stacked_block_styles_gate_doctitle_by_effective_style() {
2627        // Whether a `[float]`/`[discrete]` style above a level-0 (`=`) title
2628        // suppresses doctitle promotion is decided by the run's *effective*
2629        // (merged, last-wins) block style – the same value the block parser
2630        // computes via `Attrlist::merge_block_attribute_line` – not by any
2631        // single line in isolation.
2632
2633        // A later non-discrete style overrides an earlier `float`, so the
2634        // effective style is not discrete: the title is still promoted rather
2635        // than disappearing.
2636        let doc = Parser::default().parse("[float]\n[normal]\n= Some Title\n\nbody");
2637        assert_eq!(doc.header().title(), Some("Some Title"));
2638        assert!(all_sections(&doc).is_empty());
2639
2640        // A later `float` overrides an earlier non-discrete style, so the
2641        // effective style is discrete: the heading becomes a level-0 discrete
2642        // floating title and is not promoted to the doctitle.
2643        let doc = Parser::default().parse("[normal]\n[float]\n= Some Title\n\nbody");
2644        assert_eq!(doc.header().title(), None);
2645
2646        let sec = first_section(&doc);
2647        assert_eq!(sec.section_type(), SectionType::Discrete);
2648        assert_eq!(sec.level(), 0);
2649        assert_eq!(sec.section_title(), "Some Title");
2650    }
2651
2652    #[test]
2653    fn rejected_metadata_run_does_not_fire_counter() {
2654        // A block attribute line above the title is only parsed as document
2655        // metadata once a title is confirmed to follow it (via
2656        // `document_title_follows_block_metadata`, which scans structurally and
2657        // never parses an attribute list). Here no title follows, so the
2658        // lookahead fails and the `[reftext=…]` line's attribute list is never
2659        // parsed during header parsing – its embedded `{counter:item}` must not
2660        // fire at header time.
2661        //
2662        // The `reftext` line advances the counter exactly once when the block
2663        // parser reaches it (yielding 1), so the following `{counter:item}`
2664        // reference renders 2 – not 3, which is what a leaked header-time
2665        // evaluation would produce.
2666        let doc =
2667            Parser::default().parse("[reftext=\"See {counter:item}\"]\nBody.\n\n{counter:item}");
2668
2669        assert_eq!(doc.header().title(), None);
2670        assert_eq!(rendered_paragraphs(&doc), vec!["Body.", "2"]);
2671    }
2672
2673    #[test]
2674    fn role_block_attribute_above_title() {
2675        // A `[role=…]` block attribute above the title folds into the document's
2676        // `role` attribute; multiple roles are space-joined. The same role(s)
2677        // are surfaced through the block API, so `Header::roles()` and
2678        // `Document::roles()` agree with the document attribute (see #820).
2679        let doc = Parser::default().parse("[role=special]\n= Document Title\n\nBody.");
2680        let header = doc.header();
2681
2682        assert_eq!(header.title(), Some("Document Title"));
2683        assert_eq!(
2684            doc.attribute_value("role"),
2685            InterpretedValue::Value("special")
2686        );
2687        assert_eq!(header.roles(), vec!["special"]);
2688        assert_eq!(doc.roles(), vec!["special"]);
2689
2690        // The dot shorthand assigns roles too, and they combine.
2691        let doc = Parser::default().parse("[.one.two]\n= Document Title");
2692        assert_eq!(
2693            doc.attribute_value("role"),
2694            InterpretedValue::Value("one two")
2695        );
2696        assert_eq!(doc.header().roles(), vec!["one", "two"]);
2697        assert_eq!(doc.roles(), vec!["one", "two"]);
2698    }
2699
2700    #[test]
2701    fn roles_empty_without_block_attribute() {
2702        // With no role assigned above the title, both the block accessor and the
2703        // header accessor report no roles.
2704        let doc = Parser::default().parse("= Document Title\n\nBody.");
2705
2706        assert!(doc.header().roles().is_empty());
2707        assert!(doc.roles().is_empty());
2708    }
2709
2710    #[test]
2711    fn options_block_attribute_above_title() {
2712        // A `[opts=…]` block attribute above the title sets a `<name>-option`
2713        // document attribute for each option.
2714        let doc = Parser::default().parse("[opts=\"noheader,autowidth\"]\n= Document Title");
2715
2716        assert!(doc.is_attribute_set("noheader-option"));
2717        assert!(doc.is_attribute_set("autowidth-option"));
2718
2719        // The `%` shorthand is equivalent.
2720        let doc = Parser::default().parse("[%hardbreaks]\n= Document Title");
2721        assert!(doc.is_attribute_set("hardbreaks-option"));
2722    }
2723
2724    #[test]
2725    fn bracketed_line_that_is_not_a_separator_attribute_list() {
2726        // A `[...]` line above the title that isn't a well-formed block
2727        // attribute list carrying `separator` is not consumed as a separator. An
2728        // empty block anchor (`[[]]`) and a leading-space form are both rejected,
2729        // so the line terminates the header exactly as any other unrecognized
2730        // line would.
2731        let doc = Parser::default().parse("[[]]\n= Some Title: Subtitle");
2732        let header = doc.header();
2733
2734        assert_eq!(header.title(), None);
2735        assert_eq!(header.subtitle(), None);
2736
2737        let doc = Parser::default().parse("[ separator=::]\n= Main Title:: Subtitle");
2738        let header = doc.header();
2739
2740        assert_eq!(header.title(), None);
2741        assert_eq!(header.subtitle(), None);
2742    }
2743
2744    #[test]
2745    fn empty_title_separator_falls_back_to_default() {
2746        // An explicitly empty `title-separator` falls back to the default
2747        // `:{sp}` separator rather than partitioning on an empty string.
2748        let doc = Parser::default().parse("= Main Title: Subtitle\n:title-separator:");
2749        let header = doc.header();
2750
2751        assert_eq!(header.main_title(), Some("Main Title"));
2752        assert_eq!(header.subtitle(), Some("Subtitle"));
2753    }
2754
2755    #[test]
2756    fn counter_does_not_shadow_title_separator() {
2757        // A counter that happens to be named `title-separator` must not be
2758        // mistaken for the configured separator: partitioning reads the document
2759        // attribute directly, ignoring the counter overlay. Here the title
2760        // creates such a counter, but the default `:{sp}` separator still
2761        // applies.
2762        let doc = Parser::default().parse("= Main Title: Subtitle {counter:title-separator}");
2763        let header = doc.header();
2764
2765        assert_eq!(header.main_title(), Some("Main Title"));
2766        assert_eq!(header.subtitle(), Some("Subtitle 1"));
2767    }
2768
2769    #[test]
2770    fn skips_block_comment_before_author() {
2771        // A `////` block comment ahead of the author line is skipped and
2772        // retained as a single header comment; the author line that follows it
2773        // is parsed normally.
2774        let doc = Parser::default()
2775            .parse("= Title\n////\nAsciidoctor\nrelease artist\n////\nRyan Waldron");
2776        let header = doc.header();
2777
2778        let author = header.authors().first().unwrap();
2779        assert_eq!(author.name(), "Ryan Waldron");
2780
2781        assert_eq!(header.comments().count(), 1);
2782        assert_eq!(
2783            header.comments().next().unwrap().data(),
2784            "////\nAsciidoctor\nrelease artist\n////"
2785        );
2786    }
2787
2788    #[test]
2789    fn skips_block_comment_with_blank_lines() {
2790        // Blank lines inside a header block comment do not terminate the header;
2791        // the whole block is skipped and the author line is still recognized.
2792        let doc = Parser::default().parse("= Title\n////\n\nAsciidoctor\n\n////\nRyan Waldron");
2793        let header = doc.header();
2794
2795        assert_eq!(header.authors().first().unwrap().name(), "Ryan Waldron");
2796        assert_eq!(header.comments().count(), 1);
2797    }
2798
2799    #[test]
2800    fn unterminated_block_comment_consumes_rest_of_header() {
2801        // An unterminated `////` block comment runs to the end of the input,
2802        // mirroring Asciidoctor; nothing after it is parsed as an author line.
2803        let doc = Parser::default().parse("= Title\n////\nAsciidoctor\nRyan Waldron");
2804        let header = doc.header();
2805
2806        assert!(header.authors().is_empty());
2807        assert_eq!(header.comments().count(), 1);
2808    }
2809
2810    #[test]
2811    fn longer_block_comment_delimiter_requires_matching_close() {
2812        // The closing delimiter must repeat the opening line exactly: a `////`
2813        // line does not close a `/////` block, so the block runs on until the
2814        // matching `/////` and the author line after it is recognized.
2815        let doc = Parser::default()
2816            .parse("= Title\n/////\nAsciidoctor\n////\nstill comment\n/////\nRyan Waldron");
2817        let header = doc.header();
2818
2819        assert_eq!(header.authors().first().unwrap().name(), "Ryan Waldron");
2820        assert_eq!(header.comments().count(), 1);
2821    }
2822
2823    #[test]
2824    fn three_slashes_is_not_a_block_comment() {
2825        // A line of exactly three slashes is not a block comment delimiter
2826        // (which requires four or more slashes), so it is not skipped: were it
2827        // mistaken for an unterminated block comment it would swallow the rest
2828        // of the header, but instead the author and revision lines before it
2829        // are captured as before.
2830        let mut parser = Parser::default();
2831        let _ = parser.parse("= Title\nJoe Cool\nv1.0\n///\nstuff");
2832
2833        assert_eq!(
2834            parser.attribute_value("author"),
2835            InterpretedValue::Value("Joe Cool")
2836        );
2837        assert_eq!(
2838            parser.attribute_value("revnumber"),
2839            InterpretedValue::Value("1.0")
2840        );
2841    }
2842
2843    mod markdown_style_document_title {
2844        use crate::tests::prelude::*;
2845
2846        #[test]
2847        fn hash_marker_is_a_document_title() {
2848            let mut parser = Parser::default();
2849            let mi =
2850                crate::document::Header::parse(crate::Span::new("# Just the Title"), &mut parser)
2851                    .unwrap_if_no_warnings();
2852
2853            assert_eq!(
2854                mi.item,
2855                Header {
2856                    title_source: Some(Span {
2857                        data: "Just the Title",
2858                        line: 1,
2859                        col: 3,
2860                        offset: 2,
2861                    }),
2862                    title: Some("Just the Title"),
2863                    attributes: &[],
2864                    author_line: None,
2865                    revision_line: None,
2866                    comments: &[],
2867                    source: Span {
2868                        data: "# Just the Title",
2869                        line: 1,
2870                        col: 1,
2871                        offset: 0,
2872                    }
2873                }
2874            );
2875
2876            assert_eq!(
2877                mi.after,
2878                Span {
2879                    data: "",
2880                    line: 1,
2881                    col: 17,
2882                    offset: 16
2883                }
2884            );
2885        }
2886
2887        #[test]
2888        fn sets_doctitle_attribute() {
2889            let doc = Parser::default().parse("# Doc Title\n\n{doctitle}");
2890            assert_eq!(doc.header().title(), Some("Doc Title"));
2891            assert_eq!(rendered_paragraphs(&doc), vec!["Doc Title"]);
2892        }
2893
2894        #[test]
2895        fn strips_symmetric_close() {
2896            let doc = Parser::default().parse("# Doc Title #");
2897            assert_eq!(doc.header().title(), Some("Doc Title"));
2898        }
2899
2900        #[test]
2901        fn does_not_strip_mismatched_close() {
2902            // The close must repeat the opening marker, so a trailing `=` after
2903            // a `#` title is title text.
2904            let doc = Parser::default().parse("# Doc Title =");
2905            assert_eq!(doc.header().title(), Some("Doc Title ="));
2906        }
2907
2908        #[test]
2909        fn requires_whitespace_after_marker() {
2910            let doc = Parser::default().parse("#Doc Title");
2911
2912            assert_eq!(doc.header().title(), None);
2913            assert_eq!(rendered_paragraphs(&doc), vec!["#Doc Title"]);
2914        }
2915
2916        #[test]
2917        fn carries_the_rest_of_the_header() {
2918            // Everything that may follow an `=` title – attribute entries, the
2919            // author line, the revision line – follows a `#` title too.
2920            let doc = Parser::default()
2921                .parse("# Doc Title\n:foo: bar\nKismet R. Lee <kismet@asciidoctor.org>\nv1.0\n");
2922            let header = doc.header();
2923
2924            assert_eq!(header.title(), Some("Doc Title"));
2925            assert_eq!(header.authors().first().unwrap().firstname(), "Kismet");
2926            assert_eq!(header.revision_line().unwrap().revnumber().unwrap(), "1.0");
2927        }
2928
2929        #[test]
2930        fn partitions_subtitle() {
2931            let doc = Parser::default().parse("# Main Title: Subtitle");
2932            let header = doc.header();
2933
2934            assert_eq!(header.main_title(), Some("Main Title"));
2935            assert_eq!(header.subtitle(), Some("Subtitle"));
2936        }
2937
2938        #[test]
2939        fn separator_block_attribute_above_title() {
2940            // The `[separator=…]` line is only intercepted when a document title
2941            // follows it; a `#` title qualifies just as an `=` title does.
2942            let doc = Parser::default().parse("[separator=::]\n# Main Title:: Subtitle");
2943            let header = doc.header();
2944
2945            assert_eq!(header.main_title(), Some("Main Title"));
2946            assert_eq!(header.subtitle(), Some("Subtitle"));
2947        }
2948
2949        #[test]
2950        fn markdown_title_followed_by_markdown_sections() {
2951            let doc = Parser::default().parse("# Doc Title\n\n## Section One\n\nblah blah\n");
2952
2953            assert_eq!(doc.header().title(), Some("Doc Title"));
2954
2955            let section = first_section(&doc);
2956
2957            assert_eq!(section.level(), 1);
2958            assert_eq!(section.section_title(), "Section One");
2959        }
2960    }
2961}