Skip to main content

asciidoc_parser/document/
header.rs

1use std::slice::Iter;
2
3use crate::{
4    HasSpan, Parser, Span,
5    attributes::{Attrlist, AttrlistContext},
6    content::{Content, SubstitutionGroup},
7    document::{Attribute, Author, AuthorLine, InterpretedValue, RevisionLine},
8    internal::debug::DebugSliceReference,
9    span::MatchedItem,
10    warnings::{MatchAndWarnings, Warning, WarningType},
11};
12
13/// An AsciiDoc document may begin with a document header. The document header
14/// encapsulates the document title, author and revision information,
15/// document-wide attributes, and other document metadata.
16#[derive(Clone, Eq, PartialEq)]
17pub struct Header<'src> {
18    title_source: Option<Span<'src>>,
19    title: Option<String>,
20    main_title: Option<String>,
21    subtitle: Option<String>,
22    attributes: Vec<Attribute<'src>>,
23    author_line: Option<AuthorLine<'src>>,
24    authors: Vec<Author>,
25    revision_line: Option<RevisionLine<'src>>,
26    comments: Vec<Span<'src>>,
27    source: Span<'src>,
28}
29
30impl<'src> Header<'src> {
31    pub(crate) fn parse(
32        mut source: Span<'src>,
33        parser: &mut Parser,
34    ) -> MatchAndWarnings<'src, MatchedItem<'src, Self>> {
35        let original_source = source.discard_empty_lines();
36
37        let mut title_source: Option<Span<'src>> = None;
38        let mut title: Option<String> = None;
39        let mut attributes: Vec<Attribute> = vec![];
40        let mut author_line: Option<AuthorLine<'src>> = None;
41        let mut author_attribute: Option<Author> = None;
42        let mut revision_line: Option<RevisionLine<'src>> = None;
43        let mut comments: Vec<Span<'src>> = vec![];
44        let mut warnings: Vec<Warning<'src>> = vec![];
45
46        // Aside from the title line, items can appear in almost any order.
47        while !source.is_empty() {
48            let line_mi = source.take_normalized_line();
49            let line = line_mi.item;
50
51            // A blank line after the title ends the header.
52            if line.is_empty() {
53                if title.is_some() {
54                    break;
55                }
56                source = line_mi.after;
57            } else if line.starts_with("//") && !line.starts_with("///") {
58                comments.push(line);
59                source = line_mi.after;
60            } else if line.starts_with(':')
61                && let Some(attr) = Attribute::parse(source, parser)
62            {
63                // Special handling for :author: attribute to populate individual author
64                // attributes.
65                if attr.item.name().data().eq_ignore_ascii_case("author")
66                    && let Some(raw_value) = attr.item.raw_value()
67                    && let Some(author) = Author::parse(raw_value.data(), parser)
68                {
69                    // Set individual author attributes.
70                    parser.set_attribute_by_value_from_header("firstname", author.firstname());
71                    if let Some(middlename) = author.middlename() {
72                        parser.set_attribute_by_value_from_header("middlename", middlename);
73                    }
74                    if let Some(lastname) = author.lastname() {
75                        parser.set_attribute_by_value_from_header("lastname", lastname);
76                    }
77                    parser.set_attribute_by_value_from_header("authorinitials", author.initials());
78                    if let Some(email) = author.email() {
79                        parser.set_attribute_by_value_from_header("email", email);
80                    }
81
82                    // Retain the author parsed from the raw (pre-substitution)
83                    // value so the resolved author list does not have to
84                    // re-parse the HTML-encoded `author` attribute. A later
85                    // `:author:` entry overrides an earlier one.
86                    author_attribute = Some(author);
87                }
88
89                parser.set_attribute_from_header(&attr.item, &mut warnings);
90                attributes.push(attr.item);
91                source = attr.after;
92            } else if title.is_none()
93                && line.starts_with('[')
94                && line.ends_with(']')
95                && line_mi.after.take_normalized_line().item.starts_with("= ")
96                && let Some((separator, separator_warnings)) =
97                    parse_separator_attribute(line, parser)
98            {
99                warnings.extend(separator_warnings);
100                // A `separator` block attribute directly above the document title
101                // sets the subtitle separator. It behaves exactly like assigning
102                // the `title-separator` document attribute at this point in the
103                // header, so both mechanisms share the same partitioning logic
104                // and follow document order when both are present.
105                //
106                // The line is only intercepted when a document title immediately
107                // follows; otherwise it is block metadata for the body (e.g. a
108                // table's `separator`) and is left for the block parser.
109                parser.set_attribute_by_value_from_header("title-separator", separator);
110                source = line_mi.after;
111            } else if title.is_none() && line.starts_with("= ") {
112                let title_span = line.discard(2).discard_whitespace();
113                let title_str = apply_header_subs(title_span.data(), parser);
114
115                parser.set_attribute_by_value_from_header("doctitle", &title_str);
116
117                title = Some(title_str);
118                title_source = Some(title_span);
119                source = line_mi.after;
120            } else if title.is_some() && author_line.is_none() {
121                author_line = Some(AuthorLine::parse(line, parser));
122                source = line_mi.after;
123            } else if title.is_some() && author_line.is_some() && revision_line.is_none() {
124                revision_line = Some(RevisionLine::parse(line, parser));
125                source = line_mi.after;
126            } else {
127                if title.is_some() {
128                    warnings.push(Warning {
129                        source: line,
130                        warning: WarningType::DocumentHeaderNotTerminated,
131                        origin: None,
132                    });
133                }
134                break;
135            }
136        }
137
138        let after = source.discard_empty_lines();
139        let source = original_source.trim_remainder(source);
140
141        // Partition the (fully substituted) document title into a main title and
142        // an optional subtitle. This happens after the header has been fully
143        // parsed so that a `title-separator` attribute takes effect even when it
144        // is defined below the document title line.
145        let (main_title, subtitle) = match &title {
146            Some(title) => {
147                let (main_title, subtitle) = partition_title(title, parser);
148                (Some(main_title), subtitle)
149            }
150            None => (None, None),
151        };
152
153        // Resolve the document's author list. The author line, when present, is
154        // the source of truth; otherwise the list is derived from the `author`
155        // and indexed `author_N` document attributes (see [`resolve_authors`]).
156        let authors = resolve_authors(author_line.as_ref(), author_attribute, parser);
157
158        MatchAndWarnings {
159            item: MatchedItem {
160                item: Self {
161                    title_source,
162                    title,
163                    main_title,
164                    subtitle,
165                    attributes,
166                    author_line,
167                    authors,
168                    revision_line,
169                    comments,
170                    source: source.trim_trailing_whitespace(),
171                },
172                after,
173            },
174            warnings,
175        }
176    }
177
178    /// Return a [`Span`] describing the raw document title, if there was one.
179    pub fn title_source(&'src self) -> Option<Span<'src>> {
180        self.title_source
181    }
182
183    /// Return the document's title, if there was one, having applied header
184    /// substitutions.
185    ///
186    /// If the title contains a subtitle (see [`subtitle`]), this returns the
187    /// full, combined title. Use [`main_title`] to obtain only the portion
188    /// preceding the subtitle.
189    ///
190    /// [`subtitle`]: Self::subtitle
191    /// [`main_title`]: Self::main_title
192    pub fn title(&self) -> Option<&str> {
193        self.title.as_deref()
194    }
195
196    /// Return the main portion of the document title, if there was a title.
197    ///
198    /// When the document title contains a subtitle separator (a colon followed
199    /// by a space, by default), the title is partitioned into a main title and
200    /// a [`subtitle`]. This returns the portion preceding the final separator.
201    /// When there is no subtitle, this is identical to [`title`].
202    ///
203    /// [`subtitle`]: Self::subtitle
204    /// [`title`]: Self::title
205    pub fn main_title(&self) -> Option<&str> {
206        self.main_title.as_deref()
207    }
208
209    /// Return the document's subtitle, if the title contained one.
210    ///
211    /// A subtitle is the text following the final subtitle separator in the
212    /// document title. The separator defaults to a colon followed by a space
213    /// (`:{sp}`) and can be overridden with the `title-separator` document
214    /// attribute. Returns `None` when the title has no subtitle.
215    pub fn subtitle(&self) -> Option<&str> {
216        self.subtitle.as_deref()
217    }
218
219    /// Return an iterator over the attributes in this header.
220    pub fn attributes(&'src self) -> Iter<'src, Attribute<'src>> {
221        self.attributes.iter()
222    }
223
224    /// Returns the author line, if found.
225    pub fn author_line(&self) -> Option<&AuthorLine<'src>> {
226        self.author_line.as_ref()
227    }
228
229    /// Returns the document's authors.
230    ///
231    /// Authors may be declared on the [author line] or via the `author` /
232    /// `author_N` (and companion `email_N`, …) document attributes; this
233    /// returns the resolved list regardless of which mechanism was used. When
234    /// the document has no author information, the slice is empty.
235    ///
236    /// [author line]: https://docs.asciidoctor.org/asciidoc/latest/document/author-line/
237    pub fn authors(&self) -> &[Author] {
238        &self.authors
239    }
240
241    /// Returns the revision line, if found.
242    pub fn revision_line(&self) -> Option<&RevisionLine<'src>> {
243        self.revision_line.as_ref()
244    }
245
246    /// Return an iterator over the comments in this header.
247    pub fn comments(&'src self) -> Iter<'src, Span<'src>> {
248        self.comments.iter()
249    }
250}
251
252impl<'src> HasSpan<'src> for Header<'src> {
253    fn span(&self) -> Span<'src> {
254        self.source
255    }
256}
257
258/// Extract the value of a `separator` attribute from a block attribute line
259/// (e.g. `[separator=::]`) appearing above the document title.
260///
261/// The `line` is expected to begin with `[` and end with `]`. Returns the
262/// `separator` value together with any warnings raised while parsing the
263/// attribute list if the line is a well-formed block attribute list that
264/// contains a `separator`, and `None` otherwise (so the caller can fall through
265/// to its normal handling of the line). The warnings are only surfaced when the
266/// line is actually consumed as a separator; otherwise the line is left for the
267/// block parser, which reports them on its own path.
268fn parse_separator_attribute<'src>(
269    line: Span<'src>,
270    parser: &Parser,
271) -> Option<(String, Vec<Warning<'src>>)> {
272    // Drop the enclosing square brackets now that the caller has confirmed they
273    // are present.
274    let inner = line.slice(1..line.len() - 1);
275
276    // Reject forms that are not block attribute lists, mirroring the checks used
277    // when parsing block metadata elsewhere: a leading space or tab, an empty
278    // list, or a `[[anchor]]` block anchor.
279    if inner.is_empty()
280        || inner.starts_with(' ')
281        || inner.starts_with('\t')
282        || (inner.starts_with('[') && inner.ends_with(']'))
283    {
284        return None;
285    }
286
287    let MatchAndWarnings {
288        item: MatchedItem {
289            item: attrlist,
290            after: _,
291        },
292        warnings,
293    } = Attrlist::parse(inner, parser, AttrlistContext::Block);
294
295    let separator = attrlist
296        .named_attribute("separator")
297        .map(|attr| attr.value().to_string())?;
298
299    Some((separator, warnings))
300}
301
302/// Partition a document title into its main title and optional subtitle.
303///
304/// The separator is the value of the `title-separator` document attribute
305/// (defaulting to `:`) with a single space appended. The separator is searched
306/// for from the end of the title, so only the last occurrence partitions the
307/// title. When the separator is not present, the entire title is the main
308/// title and there is no subtitle.
309fn partition_title(title: &str, parser: &Parser) -> (String, Option<String>) {
310    // Read the configured `title-separator` document attribute directly. Unlike
311    // `Parser::attribute_value`, this bypasses the counter overlay: the title
312    // separator is a configuration attribute, never a counter, and Asciidoctor
313    // likewise resolves it with a plain attribute lookup.
314    let separator = match parser.effective_attribute("title-separator") {
315        Some(av) => match &av.value {
316            InterpretedValue::Value(value) if !value.is_empty() => value.clone(),
317            _ => ":".to_string(),
318        },
319        None => ":".to_string(),
320    };
321
322    let separator = format!("{separator} ");
323
324    match title.rfind(&separator) {
325        Some(index) => {
326            let main_title = title[..index].to_string();
327            let subtitle = title[index + separator.len()..].to_string();
328            (main_title, Some(subtitle))
329        }
330        None => (title.to_string(), None),
331    }
332}
333
334/// Resolves the document's author list.
335///
336/// When an [`AuthorLine`] is present it is authoritative (and has already
337/// populated the `author_N` attributes). Otherwise the list is reconstructed
338/// from document attributes, mirroring Asciidoctor's `parse_header_metadata`
339/// reconciliation: a directly-assigned `author` attribute stands in for a
340/// single author, and failing that a contiguous run of indexed `author_N`
341/// attributes (`author_1`, `author_2`, …) each contributes one author. In
342/// either case the email is taken from the companion `email`/`email_N`
343/// attribute, reflecting its final value.
344///
345/// `author_attribute` is the author already parsed from the raw `author`
346/// attribute value (see the header parse loop); it is reused rather than
347/// re-parsing the HTML-encoded stored value.
348fn resolve_authors(
349    author_line: Option<&AuthorLine>,
350    author_attribute: Option<Author>,
351    parser: &Parser,
352) -> Vec<Author> {
353    if let Some(author_line) = author_line {
354        return author_line.authors().cloned().collect();
355    }
356
357    let value = |name: &str| match parser.attribute_value(name) {
358        InterpretedValue::Value(value) => Some(value),
359        _ => None,
360    };
361
362    // A directly-assigned `author` attribute describes a single author — but
363    // only while it remains set. A later `:author!:` unsets the attribute
364    // without carrying a raw value to refresh `author_attribute`, so consult
365    // the attribute's final state rather than trusting the cached parse.
366    if value("author").is_some()
367        && let Some(author) = author_attribute
368    {
369        return vec![author.with_email(value("email"))];
370    }
371
372    // Otherwise, walk the indexed `author_N` attributes until one is missing.
373    let mut authors = vec![];
374    let mut index = 1;
375
376    while let Some(name) = value(&format!("author_{index}")) {
377        if let Some(author) = Author::parse(&name, parser) {
378            authors.push(author.with_email(value(&format!("email_{index}"))));
379        }
380
381        index += 1;
382    }
383
384    authors
385}
386
387fn apply_header_subs(source: &str, parser: &Parser) -> String {
388    let span = Span::new(source);
389
390    let mut content = Content::from(span);
391    SubstitutionGroup::Header.apply(&mut content, parser, None);
392
393    content.rendered().to_string()
394}
395
396impl std::fmt::Debug for Header<'_> {
397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398        f.debug_struct("Header")
399            .field("title_source", &self.title_source)
400            .field("title", &self.title)
401            .field("main_title", &self.main_title)
402            .field("subtitle", &self.subtitle)
403            .field("attributes", &DebugSliceReference(&self.attributes))
404            .field("author_line", &self.author_line)
405            .field("authors", &self.authors)
406            .field("revision_line", &self.revision_line)
407            .field("comments", &DebugSliceReference(&self.comments))
408            .field("source", &self.source)
409            .finish()
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    #![allow(clippy::unwrap_used)]
416
417    use crate::tests::prelude::*;
418
419    #[test]
420    fn impl_clone() {
421        // Silly test to mark the #[derive(...)] line as covered.
422        let mut parser = Parser::default();
423
424        let h1 = crate::document::Header::parse(crate::Span::new("= Title"), &mut parser)
425            .unwrap_if_no_warnings();
426        let h2 = h1.clone();
427
428        assert_eq!(h1, h2);
429    }
430
431    #[test]
432    fn only_title() {
433        let mut parser = Parser::default();
434        let mi = crate::document::Header::parse(crate::Span::new("= Just the Title"), &mut parser)
435            .unwrap_if_no_warnings();
436
437        assert_eq!(
438            mi.item,
439            Header {
440                title_source: Some(Span {
441                    data: "Just the Title",
442                    line: 1,
443                    col: 3,
444                    offset: 2,
445                }),
446                title: Some("Just the Title"),
447                attributes: &[],
448                author_line: None,
449                revision_line: None,
450                comments: &[],
451                source: Span {
452                    data: "= Just the Title",
453                    line: 1,
454                    col: 1,
455                    offset: 0,
456                }
457            }
458        );
459
460        assert_eq!(
461            mi.after,
462            Span {
463                data: "",
464                line: 1,
465                col: 17,
466                offset: 16
467            }
468        );
469    }
470
471    #[test]
472    fn trims_leading_spaces_in_title() {
473        // This is totally a judgement call on my part. As far as I can tell,
474        // the language doesn't describe behavior here.
475        let mut parser = Parser::default();
476        let mi =
477            crate::document::Header::parse(crate::Span::new("=    Just the Title"), &mut parser)
478                .unwrap_if_no_warnings();
479
480        assert_eq!(
481            mi.item,
482            Header {
483                title_source: Some(Span {
484                    data: "Just the Title",
485                    line: 1,
486                    col: 6,
487                    offset: 5,
488                }),
489                title: Some("Just the Title"),
490                attributes: &[],
491                author_line: None,
492                revision_line: None,
493                comments: &[],
494                source: Span {
495                    data: "=    Just the Title",
496                    line: 1,
497                    col: 1,
498                    offset: 0,
499                }
500            }
501        );
502
503        assert_eq!(
504            mi.after,
505            Span {
506                data: "",
507                line: 1,
508                col: 20,
509                offset: 19
510            }
511        );
512    }
513
514    #[test]
515    fn trims_trailing_spaces_in_title() {
516        let mut parser = Parser::default();
517        let mi =
518            crate::document::Header::parse(crate::Span::new("= Just the Title   "), &mut parser)
519                .unwrap_if_no_warnings();
520
521        assert_eq!(
522            mi.item,
523            Header {
524                title_source: Some(Span {
525                    data: "Just the Title",
526                    line: 1,
527                    col: 3,
528                    offset: 2,
529                }),
530                title: Some("Just the Title"),
531                attributes: &[],
532                author_line: None,
533                revision_line: None,
534                comments: &[],
535                source: Span {
536                    data: "= Just the Title",
537                    line: 1,
538                    col: 1,
539                    offset: 0,
540                }
541            }
542        );
543
544        assert_eq!(
545            mi.after,
546            Span {
547                data: "",
548                line: 1,
549                col: 20,
550                offset: 19
551            }
552        );
553    }
554
555    #[test]
556    fn title_and_attribute() {
557        let mut parser = Parser::default();
558
559        let mi = crate::document::Header::parse(
560            crate::Span::new("= Just the Title\n:foo: bar\n\nblah"),
561            &mut parser,
562        )
563        .unwrap_if_no_warnings();
564
565        assert_eq!(
566            mi.item,
567            Header {
568                title_source: Some(Span {
569                    data: "Just the Title",
570                    line: 1,
571                    col: 3,
572                    offset: 2,
573                }),
574                title: Some("Just the Title"),
575                attributes: &[Attribute {
576                    name: Span {
577                        data: "foo",
578                        line: 2,
579                        col: 2,
580                        offset: 18,
581                    },
582                    value_source: Some(Span {
583                        data: "bar",
584                        line: 2,
585                        col: 7,
586                        offset: 23,
587                    }),
588                    value: InterpretedValue::Value("bar"),
589                    source: Span {
590                        data: ":foo: bar",
591                        line: 2,
592                        col: 1,
593                        offset: 17,
594                    }
595                }],
596                author_line: None,
597                revision_line: None,
598                comments: &[],
599                source: Span {
600                    data: "= Just the Title\n:foo: bar",
601                    line: 1,
602                    col: 1,
603                    offset: 0,
604                }
605            }
606        );
607
608        assert_eq!(
609            mi.after,
610            Span {
611                data: "blah",
612                line: 4,
613                col: 1,
614                offset: 28
615            }
616        );
617    }
618
619    #[test]
620    fn title_applies_header_substitutions() {
621        let mut parser = Parser::default();
622
623        let mi = crate::document::Header::parse(
624            crate::Span::new("= The Title & Some{sp}Nonsense\n:foo: bar\n\nblah"),
625            &mut parser,
626        )
627        .unwrap_if_no_warnings();
628
629        assert_eq!(
630            mi.item,
631            Header {
632                title_source: Some(Span {
633                    data: "The Title & Some{sp}Nonsense",
634                    line: 1,
635                    col: 3,
636                    offset: 2,
637                }),
638                title: Some("The Title &amp; Some Nonsense"),
639                attributes: &[Attribute {
640                    name: Span {
641                        data: "foo",
642                        line: 2,
643                        col: 2,
644                        offset: 32,
645                    },
646                    value_source: Some(Span {
647                        data: "bar",
648                        line: 2,
649                        col: 7,
650                        offset: 37,
651                    }),
652                    value: InterpretedValue::Value("bar"),
653                    source: Span {
654                        data: ":foo: bar",
655                        line: 2,
656                        col: 1,
657                        offset: 31,
658                    }
659                }],
660                author_line: None,
661                revision_line: None,
662                comments: &[],
663                source: Span {
664                    data: "= The Title & Some{sp}Nonsense\n:foo: bar",
665                    line: 1,
666                    col: 1,
667                    offset: 0,
668                }
669            }
670        );
671
672        assert_eq!(
673            mi.after,
674            Span {
675                data: "blah",
676                line: 4,
677                col: 1,
678                offset: 42
679            }
680        );
681    }
682
683    #[test]
684    fn attribute_without_title() {
685        let mut parser = Parser::default();
686        let mi = crate::document::Header::parse(crate::Span::new(":foo: bar\n\nblah"), &mut parser)
687            .unwrap_if_no_warnings();
688
689        assert_eq!(
690            mi.item,
691            Header {
692                title_source: None,
693                title: None,
694                attributes: &[Attribute {
695                    name: Span {
696                        data: "foo",
697                        line: 1,
698                        col: 2,
699                        offset: 1,
700                    },
701                    value_source: Some(Span {
702                        data: "bar",
703                        line: 1,
704                        col: 7,
705                        offset: 6,
706                    }),
707                    value: InterpretedValue::Value("bar"),
708                    source: Span {
709                        data: ":foo: bar",
710                        line: 1,
711                        col: 1,
712                        offset: 0,
713                    }
714                }],
715                author_line: None,
716                revision_line: None,
717                comments: &[],
718                source: Span {
719                    data: ":foo: bar",
720                    line: 1,
721                    col: 1,
722                    offset: 0,
723                }
724            }
725        );
726
727        assert_eq!(
728            mi.after,
729            Span {
730                data: "blah",
731                line: 3,
732                col: 1,
733                offset: 11
734            }
735        );
736    }
737
738    #[test]
739    fn sets_doctitle_attribute() {
740        let mut parser = Parser::default();
741        let _doc = parser.parse("= Document Title Goes Here");
742
743        assert_eq!(
744            parser.attribute_value("doctitle"),
745            InterpretedValue::Value("Document Title Goes Here")
746        );
747    }
748
749    #[test]
750    fn sets_author_attributes_from_author_attribute() {
751        let mut parser = Parser::default();
752        let _doc = parser.parse(":author: John Q. Smith <john@example.com>");
753
754        // Verify that individual author attributes are set.
755        assert_eq!(
756            parser.attribute_value("firstname"),
757            InterpretedValue::Value("John")
758        );
759        assert_eq!(
760            parser.attribute_value("middlename"),
761            InterpretedValue::Value("Q.")
762        );
763        assert_eq!(
764            parser.attribute_value("lastname"),
765            InterpretedValue::Value("Smith")
766        );
767        assert_eq!(
768            parser.attribute_value("authorinitials"),
769            InterpretedValue::Value("JQS")
770        );
771        assert_eq!(
772            parser.attribute_value("email"),
773            InterpretedValue::Value("john@example.com")
774        );
775
776        // Also verify the original author attribute is still set (with HTML encoding).
777        assert_eq!(
778            parser.attribute_value("author"),
779            InterpretedValue::Value("John Q. Smith &lt;john@example.com&gt;")
780        );
781    }
782
783    #[test]
784    fn sets_author_attributes_from_author_attribute_two_names() {
785        let mut parser = Parser::default();
786        let _doc = parser.parse(":author: Jane Doe");
787
788        // Verify that individual author attributes are set.
789        assert_eq!(
790            parser.attribute_value("firstname"),
791            InterpretedValue::Value("Jane")
792        );
793        assert_eq!(
794            parser.attribute_value("middlename"),
795            InterpretedValue::Unset
796        );
797        assert_eq!(
798            parser.attribute_value("lastname"),
799            InterpretedValue::Value("Doe")
800        );
801        assert_eq!(
802            parser.attribute_value("authorinitials"),
803            InterpretedValue::Value("JD")
804        );
805        assert_eq!(parser.attribute_value("email"), InterpretedValue::Unset);
806    }
807
808    #[test]
809    fn sets_author_attributes_from_author_attribute_single_name() {
810        let mut parser = Parser::default();
811        let _doc = parser.parse(":author: Cher");
812
813        // Verify that individual author attributes are set.
814        assert_eq!(
815            parser.attribute_value("firstname"),
816            InterpretedValue::Value("Cher")
817        );
818        assert_eq!(
819            parser.attribute_value("middlename"),
820            InterpretedValue::Unset
821        );
822        assert_eq!(parser.attribute_value("lastname"), InterpretedValue::Unset);
823        assert_eq!(
824            parser.attribute_value("authorinitials"),
825            InterpretedValue::Value("C")
826        );
827        assert_eq!(parser.attribute_value("email"), InterpretedValue::Unset);
828    }
829
830    #[test]
831    fn sets_author_attributes_from_empty_string() {
832        let mut parser = Parser::default();
833        let _doc = parser.parse(":author:");
834
835        // Verify that individual author attributes are set.
836        assert_eq!(parser.attribute_value("firstname"), InterpretedValue::Unset);
837        assert_eq!(
838            parser.attribute_value("middlename"),
839            InterpretedValue::Unset
840        );
841        assert_eq!(parser.attribute_value("lastname"), InterpretedValue::Unset);
842        assert_eq!(
843            parser.attribute_value("authorinitials"),
844            InterpretedValue::Unset
845        );
846        assert_eq!(parser.attribute_value("email"), InterpretedValue::Unset);
847
848        assert_eq!(parser.attribute_value("author"), InterpretedValue::Set);
849    }
850
851    #[test]
852    fn authors_from_author_line() {
853        let doc = Parser::default().parse("= Title\nKismet R. Lee <kismet@asciidoctor.org>");
854
855        assert_eq!(doc.authors().len(), 1);
856
857        let author = doc.authors().first().unwrap();
858        assert_eq!(author.name(), "Kismet R. Lee");
859        assert_eq!(author.email(), Some("kismet@asciidoctor.org"));
860        assert_eq!(author.initials(), "KRL");
861    }
862
863    #[test]
864    fn authors_from_author_attribute() {
865        // With no author line, a directly-assigned `author` attribute stands in
866        // for a single author, taking its email from the `email` attribute.
867        let doc =
868            Parser::default().parse("= Title\n:author: Jane Q. Public\n:email: jane@example.com");
869
870        assert_eq!(doc.authors().len(), 1);
871
872        let author = doc.authors().first().unwrap();
873        assert_eq!(author.name(), "Jane Q. Public");
874        assert_eq!(author.firstname(), "Jane");
875        assert_eq!(author.middlename(), Some("Q."));
876        assert_eq!(author.lastname(), Some("Public"));
877        assert_eq!(author.email(), Some("jane@example.com"));
878        assert_eq!(author.initials(), "JQP");
879    }
880
881    #[test]
882    fn authors_from_author_attribute_with_inline_email() {
883        // The email may be given inline in the `author` attribute value; the
884        // resolved author reflects the parsed name and that email.
885        let doc = Parser::default().parse("= Title\n:author: John Q. Smith <john@example.com>");
886
887        assert_eq!(doc.authors().len(), 1);
888
889        let author = doc.authors().first().unwrap();
890        assert_eq!(author.name(), "John Q. Smith");
891        assert_eq!(author.firstname(), "John");
892        assert_eq!(author.middlename(), Some("Q."));
893        assert_eq!(author.lastname(), Some("Smith"));
894        assert_eq!(author.email(), Some("john@example.com"));
895        assert_eq!(author.initials(), "JQS");
896    }
897
898    #[test]
899    fn authors_is_empty_without_author_info() {
900        let doc = Parser::default().parse("= Title\n\nBody.");
901
902        assert!(doc.authors().is_empty());
903    }
904
905    #[test]
906    fn author_unset_after_being_assigned_yields_no_authors() {
907        // A later `:author!:` unsets the attribute; the resolved author list
908        // must reflect the removal, not the earlier assignment.
909        let doc = Parser::default().parse("= Title\n:author: Jane Doe\n:author!:\n\nBody.");
910
911        assert_eq!(doc.attribute_value("author"), InterpretedValue::Unset);
912        assert!(doc.authors().is_empty());
913    }
914
915    #[test]
916    fn impl_debug() {
917        let doc = Parser::default().parse("= Example Title\n\nabc\n\ndef");
918        let header = doc.header();
919
920        assert_eq!(
921            format!("{header:#?}"),
922            r#"Header {
923    title_source: Some(
924        Span {
925            data: "Example Title",
926            line: 1,
927            col: 3,
928            offset: 2,
929        },
930    ),
931    title: Some(
932        "Example Title",
933    ),
934    main_title: Some(
935        "Example Title",
936    ),
937    subtitle: None,
938    attributes: &[],
939    author_line: None,
940    authors: [],
941    revision_line: None,
942    comments: &[],
943    source: Span {
944        data: "= Example Title",
945        line: 1,
946        col: 1,
947        offset: 0,
948    },
949}"#
950        );
951    }
952
953    #[test]
954    fn no_subtitle() {
955        // A title without a colon-space sequence has no subtitle, and its main
956        // title equals its full title.
957        let doc = Parser::default().parse("= Just the Title");
958        let header = doc.header();
959
960        assert_eq!(header.title(), Some("Just the Title"));
961        assert_eq!(header.main_title(), Some("Just the Title"));
962        assert_eq!(header.subtitle(), None);
963    }
964
965    #[test]
966    fn no_title() {
967        // With no document title at all, every title accessor returns `None`.
968        let doc = Parser::default().parse(":foo: bar\n\nbody");
969        let header = doc.header();
970
971        assert_eq!(header.title(), None);
972        assert_eq!(header.main_title(), None);
973        assert_eq!(header.subtitle(), None);
974    }
975
976    #[test]
977    fn colon_without_space_is_not_a_separator() {
978        // The separator is a colon *followed by a space*; a bare colon does not
979        // partition the title.
980        let doc = Parser::default().parse("= Ratio 3:1 Explained");
981        let header = doc.header();
982
983        assert_eq!(header.main_title(), Some("Ratio 3:1 Explained"));
984        assert_eq!(header.subtitle(), None);
985    }
986
987    #[test]
988    fn subtitle_available_on_document() {
989        // The subtitle is reachable directly from `Document` as well as from its
990        // `Header`.
991        let doc = Parser::default().parse("= Main Title: Subtitle");
992
993        assert_eq!(doc.doctitle(), Some("Main Title: Subtitle"));
994        assert_eq!(doc.subtitle(), Some("Subtitle"));
995    }
996
997    #[test]
998    fn separator_block_attribute_above_title() {
999        // A `[separator=::]` block attribute above the title changes the
1000        // subtitle separator for that title.
1001        let doc = Parser::default().parse("[separator=::]\n= Main Title:: Subtitle");
1002        let header = doc.header();
1003
1004        assert_eq!(header.main_title(), Some("Main Title"));
1005        assert_eq!(header.subtitle(), Some("Subtitle"));
1006
1007        // The custom separator replaces the default: a plain colon-space no
1008        // longer partitions the title.
1009        let doc = Parser::default().parse("[separator=::]\n= Main: Title:: Subtitle");
1010        let header = doc.header();
1011
1012        assert_eq!(header.main_title(), Some("Main: Title"));
1013        assert_eq!(header.subtitle(), Some("Subtitle"));
1014    }
1015
1016    #[test]
1017    fn separator_attribute_entry_overrides_block_attribute() {
1018        // When both are present, the later assignment wins in document order.
1019        // Here the `:title-separator:` entry follows the block attribute.
1020        let doc = Parser::default()
1021            .parse("[separator=::]\n= Main Title;; Subtitle\n:title-separator: ;;");
1022        let header = doc.header();
1023
1024        assert_eq!(header.main_title(), Some("Main Title"));
1025        assert_eq!(header.subtitle(), Some("Subtitle"));
1026    }
1027
1028    #[test]
1029    fn non_separator_block_attribute_terminates_header() {
1030        // A block attribute line above the title that isn't a `separator`
1031        // terminates the header without a title, preserving prior behavior.
1032        let doc = Parser::default().parse("[foo=bar]\n= Not A Header Title");
1033        let header = doc.header();
1034
1035        assert_eq!(header.title(), None);
1036        assert_eq!(header.subtitle(), None);
1037    }
1038
1039    #[test]
1040    fn bracketed_line_that_is_not_a_separator_attribute_list() {
1041        // A `[...]` line above the title that isn't a well-formed block
1042        // attribute list carrying `separator` is not consumed as a separator. A
1043        // block anchor (`[[...]]`) and a leading-space form are both rejected,
1044        // so the line terminates the header exactly as any other unrecognized
1045        // line would.
1046        let doc = Parser::default().parse("[[anchor]]\n= Some Title: Subtitle");
1047        let header = doc.header();
1048
1049        assert_eq!(header.title(), None);
1050        assert_eq!(header.subtitle(), None);
1051
1052        let doc = Parser::default().parse("[ separator=::]\n= Main Title:: Subtitle");
1053        let header = doc.header();
1054
1055        assert_eq!(header.title(), None);
1056        assert_eq!(header.subtitle(), None);
1057    }
1058
1059    #[test]
1060    fn empty_title_separator_falls_back_to_default() {
1061        // An explicitly empty `title-separator` falls back to the default
1062        // `:{sp}` separator rather than partitioning on an empty string.
1063        let doc = Parser::default().parse("= Main Title: Subtitle\n:title-separator:");
1064        let header = doc.header();
1065
1066        assert_eq!(header.main_title(), Some("Main Title"));
1067        assert_eq!(header.subtitle(), Some("Subtitle"));
1068    }
1069
1070    #[test]
1071    fn counter_does_not_shadow_title_separator() {
1072        // A counter that happens to be named `title-separator` must not be
1073        // mistaken for the configured separator: partitioning reads the document
1074        // attribute directly, ignoring the counter overlay. Here the title
1075        // creates such a counter, but the default `:{sp}` separator still
1076        // applies.
1077        let doc = Parser::default().parse("= Main Title: Subtitle {counter:title-separator}");
1078        let header = doc.header();
1079
1080        assert_eq!(header.main_title(), Some("Main Title"));
1081        assert_eq!(header.subtitle(), Some("Subtitle 1"));
1082    }
1083}