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