Skip to main content

asciidoc_parser/document/
header.rs

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