Skip to main content

asciidoc_parser/document/
header.rs

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