Skip to main content

asciidoc_parser/document/
header.rs

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