Skip to main content

asciidoc_parser/document/
header.rs

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