Skip to main content

asciidoc_parser/document/
header.rs

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