Skip to main content

asciidoc_parser/document/
author_line.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4
5use crate::{
6    HasSpan, Parser, Span,
7    document::{Author, set_author_metadata},
8    internal::opaque_iter::opaque_slice_iter,
9};
10
11opaque_slice_iter! {
12    /// An iterator over the [`Author`]s in an [`AuthorLine`], returned by
13    /// [`AuthorLine::authors`].
14    pub struct Authors<'a> yielding Author;
15}
16
17/// The author line is directly after the document title line in the document
18/// header. When the content on this line is structured correctly, the processor
19/// assigns the content to the built-in author and email attributes.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct AuthorLine<'src> {
22    authors: Vec<Author>,
23    source: Span<'src>,
24}
25
26impl<'src> AuthorLine<'src> {
27    pub(crate) fn parse(source: Span<'src>, parser: &mut Parser) -> Self {
28        let authors: Vec<Author> = split_authors(source.data())
29            .into_iter()
30            .filter_map(|raw_author| Author::parse(raw_author, parser, false))
31            .collect();
32
33        // Populate the derived author document attributes from the implicit
34        // author line – the unsuffixed first-author keys (`author`,
35        // `firstname`, ...), the `author_N` companions, the `_1` companions
36        // (mirrored once a second author appears), and the combined,
37        // comma-joined `authors` attribute – exactly as Asciidoctor's
38        // `process_authors` does. Setting them here, during header parsing,
39        // keeps `{author}`, `{authors}`, etc. available to later header lines.
40        if !authors.is_empty() {
41            set_author_metadata(parser, &authors);
42        }
43
44        Self { authors, source }
45    }
46
47    /// Return an iterator over the authors in this author line.
48    pub fn authors(&'src self) -> Authors<'src> {
49        Authors::new(&self.authors)
50    }
51}
52
53/// Matches a numeric HTML character reference – decimal (`&#174;`) or
54/// hexadecimal (`&#xAE;`). The terminating semicolon of such a reference must
55/// never be treated as an author separator.
56///
57/// Only numeric references are recognized here. A named reference such as
58/// `&reg;` cannot be distinguished structurally from arbitrary `&word;` text
59/// (the crate does not carry a table of valid entity names), so treating every
60/// `&word;` as a reference would suppress genuine separators – e.g. the
61/// semicolon in `Alice &Development; Bob`. Numeric references are unambiguous,
62/// and they are what the implicit author line needs to guard (see issue #757).
63static NUMERIC_CHARACTER_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
64    #[allow(clippy::unwrap_used)]
65    Regex::new(r"&#(?:[0-9]+|[xX][0-9a-fA-F]+);").unwrap()
66});
67
68/// Split the implicit author line into raw author entries.
69///
70/// Following Asciidoctor, a semicolon separates authors only when it is
71/// immediately followed by a space or the end of the line. A semicolon that is
72/// followed by any other character (as in `Joe Doe;Smith Johnson`) is part of a
73/// single author's name. Blank entries – produced by a trailing separator or an
74/// empty middle entry – are left in place; [`Author::parse`] trims each entry
75/// and discards the empty ones.
76///
77/// Semicolons that terminate a numeric HTML character reference (such as
78/// `&#174;`) are never treated as separators, even when followed by a space, so
79/// a name like `AsciiDoc&#174; WG` is not split apart.
80fn split_authors(data: &str) -> Vec<&str> {
81    // Byte offsets of the semicolons that terminate a numeric character
82    // reference; these are excluded from consideration as separators.
83    let char_ref_terminators: Vec<usize> = NUMERIC_CHARACTER_REFERENCE
84        .find_iter(data)
85        .map(|m| m.end() - 1)
86        .collect();
87
88    let bytes = data.as_bytes();
89    let mut authors: Vec<&str> = Vec::new();
90    let mut start = 0;
91
92    for (index, c) in data.char_indices() {
93        if c != ';' || char_ref_terminators.contains(&index) {
94            continue;
95        }
96
97        // A semicolon is a separator only when followed by a space or the end of
98        // the line.
99        let is_separator = match bytes.get(index + 1) {
100            Some(next) => *next == b' ',
101            None => true,
102        };
103
104        if is_separator {
105            authors.push(&data[start..index]);
106            start = index + 1;
107        }
108    }
109
110    authors.push(&data[start..]);
111    authors
112}
113
114impl<'src> HasSpan<'src> for AuthorLine<'src> {
115    fn span(&self) -> Span<'src> {
116        self.source
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    #![allow(clippy::unwrap_used)]
123
124    use crate::{parser::ModificationContext, tests::prelude::*};
125
126    #[test]
127    fn empty_line() {
128        let mut parser = Parser::default();
129
130        let al = crate::document::AuthorLine::parse(crate::Span::new(""), &mut parser);
131
132        assert_eq!(
133            &al,
134            AuthorLine {
135                authors: &[],
136                source: Span {
137                    data: "",
138                    line: 1,
139                    col: 1,
140                    offset: 0,
141                },
142            }
143        );
144    }
145
146    #[test]
147    fn implicit_author_name_is_always_header_substituted() {
148        // The header substitution group (special characters + attribute
149        // references) is applied to the implicit author-line name consistently,
150        // whether or not the line contains an attribute reference. Previously the
151        // name was returned raw unless it happened to carry an attribute
152        // reference, which forced a downstream converter to guess whether the
153        // value was already substituted.
154        let author_name = |src: &str| {
155            let mut parser = Parser::default();
156            let doc = parser.parse(src);
157
158            doc.header()
159                .author_line()
160                .unwrap()
161                .authors()
162                .next()
163                .unwrap()
164                .name()
165                .to_string()
166        };
167
168        // A plain line with literal special characters: escaped, even though it
169        // has no attribute reference.
170        assert_eq!(author_name("= T\n<x> Blogs\n\nbody\n"), "&lt;x&gt; Blogs");
171
172        // Special characters and an attribute reference together: the literal
173        // `<x>` is escaped and `{v}` is resolved.
174        assert_eq!(
175            author_name(":v: RESOLVED\n= T\n(C) <x> {v} Blogs\n\nbody\n"),
176            "(C) &lt;x&gt; RESOLVED Blogs"
177        );
178
179        // A plain line with no special characters is unchanged.
180        assert_eq!(author_name("= T\n(C) Blogs\n\nbody\n"), "(C) Blogs");
181    }
182
183    #[test]
184    fn attr_reference_value_is_inserted_verbatim() {
185        // The header substitution group runs special characters *before*
186        // attribute references (Asciidoctor's `HEADER_SUBS = [:specialcharacters,
187        // :attributes]`), so an attribute reference's value is inserted verbatim
188        // rather than being escaped a second time. Here the literal text carries
189        // no special characters, so the resolved value's `<`, `>`, and `&` are
190        // left as they are.
191        let mut parser = Parser::default().with_intrinsic_attribute(
192            "weird-content",
193            "Complex <weird> & stuff",
194            ModificationContext::Anywhere,
195        );
196
197        let al = crate::document::AuthorLine::parse(
198            crate::Span::new("Some {weird-content} pattern"),
199            &mut parser,
200        );
201
202        assert_eq!(
203            al,
204            AuthorLine {
205                authors: &[Author {
206                    name: "Some Complex <weird> & stuff pattern",
207                    firstname: "Some Complex <weird> & stuff pattern",
208                    middlename: None,
209                    lastname: None,
210                    email: None,
211                },],
212                source: Span {
213                    data: "Some {weird-content} pattern",
214                    line: 1,
215                    col: 1,
216                    offset: 0,
217                },
218            }
219        );
220    }
221
222    #[test]
223    fn empty_author() {
224        let mut parser = Parser::default();
225
226        let al = crate::document::AuthorLine::parse(
227            crate::Span::new("Author One; ; Author Three"),
228            &mut parser,
229        );
230
231        assert_eq!(
232            al,
233            AuthorLine {
234                authors: &[
235                    Author {
236                        name: "Author One",
237                        firstname: "Author",
238                        middlename: None,
239                        lastname: Some("One",),
240                        email: None,
241                    },
242                    Author {
243                        name: "Author Three",
244                        firstname: "Author",
245                        middlename: None,
246                        lastname: Some("Three",),
247                        email: None,
248                    },
249                ],
250                source: Span {
251                    data: "Author One; ; Author Three",
252                    line: 1,
253                    col: 1,
254                    offset: 0,
255                },
256            }
257        );
258    }
259
260    #[test]
261    fn one_simple_author() {
262        let mut parser = Parser::default();
263
264        let al = crate::document::AuthorLine::parse(
265            crate::Span::new("Kismet R. Lee <kismet@asciidoctor.org>"),
266            &mut parser,
267        );
268
269        assert_eq!(
270            &al,
271            AuthorLine {
272                authors: &[Author {
273                    name: "Kismet R. Lee",
274                    firstname: "Kismet",
275                    middlename: Some("R.",),
276                    lastname: Some("Lee",),
277                    email: Some("kismet@asciidoctor.org",),
278                },],
279                source: Span {
280                    data: "Kismet R. Lee <kismet@asciidoctor.org>",
281                    line: 1,
282                    col: 1,
283                    offset: 0,
284                },
285            }
286        );
287    }
288
289    #[test]
290    fn author_without_middle_name() {
291        let mut parser = Parser::default();
292
293        let al = crate::document::AuthorLine::parse(
294            crate::Span::new("Doc Writer <doc@example.com>"),
295            &mut parser,
296        );
297
298        assert_eq!(
299            al,
300            AuthorLine {
301                authors: &[Author {
302                    name: "Doc Writer",
303                    firstname: "Doc",
304                    middlename: None,
305                    lastname: Some("Writer",),
306                    email: Some("doc@example.com",),
307                },],
308                source: Span {
309                    data: "Doc Writer <doc@example.com>",
310                    line: 1,
311                    col: 1,
312                    offset: 0,
313                },
314            }
315        );
316    }
317
318    #[test]
319    fn too_many_names() {
320        let mut parser = Parser::default();
321
322        let al = crate::document::AuthorLine::parse(
323            crate::Span::new("Four Names Not Supported <doc@example.com>"),
324            &mut parser,
325        );
326
327        assert_eq!(
328            al,
329            AuthorLine {
330                authors: &[Author {
331                    // A line with four or more names doesn't match the author
332                    // pattern, so the whole line becomes the name – with the
333                    // header substitution group applied, escaping the literal
334                    // angle brackets.
335                    name: "Four Names Not Supported &lt;doc@example.com&gt;",
336                    firstname: "Four Names Not Supported &lt;doc@example.com&gt;",
337                    middlename: None,
338                    lastname: None,
339                    email: None,
340                },],
341                source: Span {
342                    data: "Four Names Not Supported <doc@example.com>",
343                    line: 1,
344                    col: 1,
345                    offset: 0,
346                },
347            }
348        );
349    }
350
351    #[test]
352    fn fallback_condenses_whitespace_and_keeps_underscores_literal() {
353        // A line that does not match the author pattern (here because of the comma)
354        // is stored verbatim as the author with runs of spaces condensed. Unlike the
355        // matching path, underscores are left literal rather than replaced with
356        // spaces (matching Asciidoctor's `tr_s ' ', ' '` fallback).
357        let mut parser = Parser::default();
358
359        let al =
360            crate::document::AuthorLine::parse(crate::Span::new("Jane_    Q, Doe"), &mut parser);
361
362        assert_eq!(
363            al,
364            AuthorLine {
365                authors: &[Author {
366                    name: "Jane_ Q, Doe",
367                    firstname: "Jane_ Q, Doe",
368                    middlename: None,
369                    lastname: None,
370                    email: None,
371                },],
372                source: Span {
373                    data: "Jane_    Q, Doe",
374                    line: 1,
375                    col: 1,
376                    offset: 0,
377                },
378            }
379        );
380    }
381
382    #[test]
383    fn one_name() {
384        let mut parser = Parser::default();
385
386        let al = crate::document::AuthorLine::parse(
387            crate::Span::new("John <john@example.com>"),
388            &mut parser,
389        );
390
391        assert_eq!(
392            al,
393            AuthorLine {
394                authors: &[Author {
395                    name: "John",
396                    firstname: "John",
397                    middlename: None,
398                    lastname: None,
399                    email: Some("john@example.com",),
400                },],
401                source: Span {
402                    data: "John <john@example.com>",
403                    line: 1,
404                    col: 1,
405                    offset: 0,
406                },
407            }
408        );
409    }
410
411    #[test]
412    fn underscore_join() {
413        let mut parser = Parser::default();
414
415        let al =
416            crate::document::AuthorLine::parse(crate::Span::new("Mary_Sue Brontë"), &mut parser);
417
418        assert_eq!(
419            al,
420            AuthorLine {
421                authors: &[Author {
422                    name: "Mary Sue Brontë", // Underscore replaced with space
423                    firstname: "Mary Sue",   // Underscore replaced with space
424                    middlename: None,
425                    lastname: Some("Brontë",),
426                    email: None,
427                },],
428                source: Span {
429                    data: "Mary_Sue Brontë",
430                    line: 1,
431                    col: 1,
432                    offset: 0,
433                },
434            }
435        );
436    }
437
438    #[test]
439    fn greek() {
440        let mut parser = Parser::default();
441
442        let al = crate::document::AuthorLine::parse(
443            crate::Span::new("Αλέξανδρος Παπαδόπουλος"),
444            &mut parser,
445        );
446
447        assert_eq!(
448            al,
449            AuthorLine {
450                authors: &[Author {
451                    name: "Αλέξανδρος Παπαδόπουλος",
452                    firstname: "Αλέξανδρος",
453                    middlename: None,
454                    lastname: Some("Παπαδόπουλος",),
455                    email: None,
456                },],
457                source: Span {
458                    data: "Αλέξανδρος Παπαδόπουλος",
459                    line: 1,
460                    col: 1,
461                    offset: 0,
462                },
463            }
464        );
465    }
466
467    #[test]
468    fn japanese() {
469        let mut parser = Parser::default();
470
471        let al = crate::document::AuthorLine::parse(crate::Span::new("山田太郎"), &mut parser);
472
473        assert_eq!(
474            al,
475            AuthorLine {
476                authors: &[Author {
477                    name: "山田太郎",
478                    firstname: "山田太郎",
479                    middlename: None,
480                    lastname: None,
481                    email: None,
482                },],
483                source: Span {
484                    data: "山田太郎",
485                    line: 1,
486                    col: 1,
487                    offset: 0,
488                },
489            }
490        );
491    }
492
493    #[test]
494    fn arabic() {
495        let mut parser = Parser::default();
496
497        let al = crate::document::AuthorLine::parse(crate::Span::new("عبد_الله"), &mut parser);
498
499        assert_eq!(
500            al,
501            AuthorLine {
502                authors: &[Author {
503                    name: "عبد الله",      // Underscore replaced with space
504                    firstname: "عبد الله", // Underscore replaced with space
505                    middlename: None,
506                    lastname: None,
507                    email: None,
508                },],
509                source: Span {
510                    data: "عبد_الله",
511                    line: 1,
512                    col: 1,
513                    offset: 0,
514                },
515            }
516        );
517    }
518
519    #[test]
520    fn underscore_replacement_in_all_name_parts() {
521        let mut parser = Parser::default();
522
523        let al = crate::document::AuthorLine::parse(
524            crate::Span::new("John_Paul Mary_Jane Smith_Jones <email@example.com>"),
525            &mut parser,
526        );
527
528        assert_eq!(
529            al,
530            AuthorLine {
531                authors: &[Author {
532                    name: "John Paul Mary Jane Smith Jones", // Underscore replaced with space
533                    firstname: "John Paul",                  // Underscore replaced with space
534                    middlename: Some("Mary Jane"),           // Underscore replaced with space
535                    lastname: Some("Smith Jones"),           // Underscore replaced with space
536                    email: Some("email@example.com"),
537                },],
538                source: Span {
539                    data: "John_Paul Mary_Jane Smith_Jones <email@example.com>",
540                    line: 1,
541                    col: 1,
542                    offset: 0,
543                },
544            }
545        );
546    }
547
548    #[test]
549    fn multiple_underscores_in_name_parts() {
550        let mut parser = Parser::default();
551
552        let al =
553            crate::document::AuthorLine::parse(crate::Span::new("A_B_C D_E_F G_H_I"), &mut parser);
554
555        assert_eq!(
556            al,
557            AuthorLine {
558                authors: &[Author {
559                    name: "A B C D E F G H I", // Multiple underscores replaced with spaces
560                    firstname: "A B C",        // Multiple underscores replaced with spaces
561                    middlename: Some("D E F"), // Multiple underscores replaced with spaces
562                    lastname: Some("G H I"),   // Multiple underscores replaced with spaces
563                    email: None,
564                },],
565                source: Span {
566                    data: "A_B_C D_E_F G_H_I",
567                    line: 1,
568                    col: 1,
569                    offset: 0,
570                },
571            }
572        );
573    }
574
575    #[test]
576    fn underscore_replacement_with_attribute_substitution() {
577        let mut parser = Parser::default()
578            .with_intrinsic_attribute("first-part", "John_Paul", ModificationContext::Anywhere)
579            .with_intrinsic_attribute("last-part", "Smith_Jones", ModificationContext::Anywhere);
580
581        let al = crate::document::AuthorLine::parse(
582            crate::Span::new("{first-part} {last-part} <email@example.com>"),
583            &mut parser,
584        );
585
586        // Note: This test documents the current behavior where attribute substitution
587        // happens after parsing, which results in HTML encoding of the angle brackets.
588        // The underscore replacement should still work on the attribute-substituted
589        // values.
590        assert_eq!(
591            al,
592            AuthorLine {
593                authors: &[Author {
594                    name: "John Paul Smith Jones &lt;email@example.com&gt;", /* Underscore
595                                                                              * replaced
596                                                                              * with space */
597                    firstname: "John Paul Smith Jones &lt;email@example.com&gt;", /* Underscore
598                                                                                   * replaced with
599                                                                                   * space */
600                    middlename: None,
601                    lastname: None,
602                    email: None,
603                },],
604                source: Span {
605                    data: "{first-part} {last-part} <email@example.com>",
606                    line: 1,
607                    col: 1,
608                    offset: 0,
609                },
610            }
611        );
612    }
613
614    #[test]
615    fn attr_sub_email() {
616        let mut parser = Parser::default()
617            .with_intrinsic_attribute(
618                "jane-email",
619                "jane@example.com",
620                ModificationContext::Anywhere,
621            )
622            .with_intrinsic_attribute(
623                "john-email",
624                "john@example.com",
625                ModificationContext::Anywhere,
626            );
627
628        let al = crate::document::AuthorLine::parse(
629            crate::Span::new("Jane Smith <{jane-email}>; John Doe <{john-email}>"),
630            &mut parser,
631        );
632
633        assert_eq!(
634            al,
635            AuthorLine {
636                authors: &[
637                    Author {
638                        name: "Jane Smith",
639                        firstname: "Jane",
640                        middlename: None,
641                        lastname: Some("Smith",),
642                        email: Some("jane@example.com",),
643                    },
644                    Author {
645                        name: "John Doe",
646                        firstname: "John",
647                        middlename: None,
648                        lastname: Some("Doe",),
649                        email: Some("john@example.com",),
650                    },
651                ],
652                source: Span {
653                    data: "Jane Smith <{jane-email}>; John Doe <{john-email}>",
654                    line: 1,
655                    col: 1,
656                    offset: 0,
657                },
658            }
659        );
660    }
661
662    #[test]
663    fn attr_sub_applied_after_parsing() {
664        // This is to demonstrate compatibility with Ruby asciidoctor behavior. In that
665        // implementation, the attribute substitution is applied *after* parsing for
666        // individual authors, which results in the unexpected treatment that the entire
667        // list is one author with mangled results.
668        let mut parser = Parser::default().with_intrinsic_attribute(
669            "author-list",
670            "Jane Smith <jane@example.com>; John Doe <john@example.com>",
671            ModificationContext::Anywhere,
672        );
673
674        let al = crate::document::AuthorLine::parse(crate::Span::new("{author-list}"), &mut parser);
675
676        assert_eq!(
677            al,
678            AuthorLine {
679                authors: &[Author {
680                    name: "Jane Smith <jane@example.com>; John Doe <john@example.com>",
681                    firstname: "Jane Smith <jane@example.com>; John Doe <john@example.com>",
682                    middlename: None,
683                    lastname: None,
684                    email: None,
685                },],
686                source: Span {
687                    data: "{author-list}",
688                    line: 1,
689                    col: 1,
690                    offset: 0,
691                },
692            }
693        );
694    }
695
696    #[test]
697    fn attr_sub_for_individual_author() {
698        let mut parser = Parser::default().with_intrinsic_attribute(
699            "full-author",
700            "John Doe <john@example.com>",
701            ModificationContext::Anywhere,
702        );
703
704        let al = crate::document::AuthorLine::parse(crate::Span::new("{full-author}"), &mut parser);
705
706        assert_eq!(
707            al,
708            AuthorLine {
709                authors: &[Author {
710                    name: "John Doe <john@example.com>",
711                    firstname: "John Doe <john@example.com>",
712                    middlename: None,
713                    lastname: None,
714                    email: None,
715                },],
716                source: Span {
717                    data: "{full-author}",
718                    line: 1,
719                    col: 1,
720                    offset: 0,
721                },
722            }
723        );
724    }
725
726    #[test]
727    fn err_individual_name_components_as_attributes() {
728        // This approach doesn't work in Ruby AsciiDoctor either.
729        let mut parser = Parser::default()
730            .with_intrinsic_attribute("first-name", "Jane", ModificationContext::Anywhere)
731            .with_intrinsic_attribute("last-name", "Smith", ModificationContext::Anywhere)
732            .with_intrinsic_attribute(
733                "author-email",
734                "jane@example.com",
735                ModificationContext::Anywhere,
736            );
737
738        let al = crate::document::AuthorLine::parse(
739            crate::Span::new("{first-name} {last-name} <{author-email}>"),
740            &mut parser,
741        );
742
743        assert_eq!(
744            al,
745            AuthorLine {
746                authors: &[Author {
747                    name: "Jane Smith &lt;jane@example.com&gt;",
748                    firstname: "Jane Smith &lt;jane@example.com&gt;",
749                    middlename: None,
750                    lastname: None,
751                    email: None,
752                },],
753                source: Span {
754                    data: "{first-name} {last-name} <{author-email}>",
755                    line: 1,
756                    col: 1,
757                    offset: 0,
758                },
759            }
760        );
761    }
762
763    #[test]
764    fn sets_author_attributes_single_author_with_all_parts() {
765        let mut parser = Parser::default();
766        let _doc = parser.parse("= Document Title\nKismet R. Lee <kismet@asciidoctor.org>");
767
768        // Primary author attributes
769        assert_eq!(
770            parser.attribute_value("author"),
771            InterpretedValue::Value("Kismet R. Lee")
772        );
773        assert_eq!(
774            parser.attribute_value("firstname"),
775            InterpretedValue::Value("Kismet")
776        );
777        assert_eq!(
778            parser.attribute_value("middlename"),
779            InterpretedValue::Value("R.")
780        );
781        assert_eq!(
782            parser.attribute_value("lastname"),
783            InterpretedValue::Value("Lee")
784        );
785        assert_eq!(
786            parser.attribute_value("authorinitials"),
787            InterpretedValue::Value("KRL")
788        );
789        assert_eq!(
790            parser.attribute_value("email"),
791            InterpretedValue::Value("kismet@asciidoctor.org")
792        );
793    }
794
795    #[test]
796    fn sets_author_attributes_single_author_without_middle_name() {
797        let mut parser = Parser::default();
798        let _doc = parser.parse("= Document Title\nDoc Writer <doc@example.com>");
799
800        assert_eq!(
801            parser.attribute_value("author"),
802            InterpretedValue::Value("Doc Writer")
803        );
804        assert_eq!(
805            parser.attribute_value("firstname"),
806            InterpretedValue::Value("Doc")
807        );
808        assert_eq!(
809            parser.attribute_value("middlename"),
810            InterpretedValue::Unset
811        );
812        assert_eq!(
813            parser.attribute_value("lastname"),
814            InterpretedValue::Value("Writer")
815        );
816        assert_eq!(
817            parser.attribute_value("authorinitials"),
818            InterpretedValue::Value("DW")
819        );
820        assert_eq!(
821            parser.attribute_value("email"),
822            InterpretedValue::Value("doc@example.com")
823        );
824    }
825
826    #[test]
827    fn sets_author_attributes_single_author_first_name_only() {
828        let mut parser = Parser::default();
829        let _doc = parser.parse("= Document Title\nJohn <john@example.com>");
830
831        assert_eq!(
832            parser.attribute_value("author"),
833            InterpretedValue::Value("John")
834        );
835        assert_eq!(
836            parser.attribute_value("firstname"),
837            InterpretedValue::Value("John")
838        );
839        assert_eq!(
840            parser.attribute_value("middlename"),
841            InterpretedValue::Unset
842        );
843        assert_eq!(parser.attribute_value("lastname"), InterpretedValue::Unset);
844        assert_eq!(
845            parser.attribute_value("authorinitials"),
846            InterpretedValue::Value("J")
847        );
848        assert_eq!(
849            parser.attribute_value("email"),
850            InterpretedValue::Value("john@example.com")
851        );
852    }
853
854    #[test]
855    fn sets_author_attributes_single_author_without_email() {
856        let mut parser = Parser::default();
857        let _doc = parser.parse("= Document Title\nMary Sue Brontë");
858
859        assert_eq!(
860            parser.attribute_value("author"),
861            InterpretedValue::Value("Mary Sue Brontë")
862        );
863        assert_eq!(
864            parser.attribute_value("firstname"),
865            InterpretedValue::Value("Mary")
866        );
867        assert_eq!(
868            parser.attribute_value("middlename"),
869            InterpretedValue::Value("Sue")
870        );
871        assert_eq!(
872            parser.attribute_value("lastname"),
873            InterpretedValue::Value("Brontë")
874        );
875        assert_eq!(
876            parser.attribute_value("authorinitials"),
877            InterpretedValue::Value("MSB")
878        );
879        assert_eq!(parser.attribute_value("email"), InterpretedValue::Unset);
880    }
881
882    #[test]
883    fn sets_author_attributes_multiple_authors() {
884        let mut parser = Parser::default();
885        let _doc = parser
886            .parse("= Document Title\nJane Smith <jane@example.com>; John Doe <john@example.com>");
887
888        // First author (primary)
889        assert_eq!(
890            parser.attribute_value("author"),
891            InterpretedValue::Value("Jane Smith")
892        );
893        assert_eq!(
894            parser.attribute_value("firstname"),
895            InterpretedValue::Value("Jane")
896        );
897        assert_eq!(
898            parser.attribute_value("lastname"),
899            InterpretedValue::Value("Smith")
900        );
901        assert_eq!(
902            parser.attribute_value("authorinitials"),
903            InterpretedValue::Value("JS")
904        );
905        assert_eq!(
906            parser.attribute_value("email"),
907            InterpretedValue::Value("jane@example.com")
908        );
909
910        // First author, also exposed under explicit `_1`-suffixed names because
911        // more than one author is present.
912        assert_eq!(
913            parser.attribute_value("author_1"),
914            InterpretedValue::Value("Jane Smith")
915        );
916        assert_eq!(
917            parser.attribute_value("firstname_1"),
918            InterpretedValue::Value("Jane")
919        );
920        assert_eq!(
921            parser.attribute_value("lastname_1"),
922            InterpretedValue::Value("Smith")
923        );
924        assert_eq!(
925            parser.attribute_value("authorinitials_1"),
926            InterpretedValue::Value("JS")
927        );
928        assert_eq!(
929            parser.attribute_value("email_1"),
930            InterpretedValue::Value("jane@example.com")
931        );
932
933        // Second author
934        assert_eq!(
935            parser.attribute_value("author_2"),
936            InterpretedValue::Value("John Doe")
937        );
938        assert_eq!(
939            parser.attribute_value("firstname_2"),
940            InterpretedValue::Value("John")
941        );
942        assert_eq!(
943            parser.attribute_value("lastname_2"),
944            InterpretedValue::Value("Doe")
945        );
946        assert_eq!(
947            parser.attribute_value("authorinitials_2"),
948            InterpretedValue::Value("JD")
949        );
950        assert_eq!(
951            parser.attribute_value("email_2"),
952            InterpretedValue::Value("john@example.com")
953        );
954
955        // Verify middlename attributes are unset for all authors.
956        assert_eq!(
957            parser.attribute_value("middlename"),
958            InterpretedValue::Unset
959        );
960        assert_eq!(
961            parser.attribute_value("middlename_1"),
962            InterpretedValue::Unset
963        );
964        assert_eq!(
965            parser.attribute_value("middlename_2"),
966            InterpretedValue::Unset
967        );
968
969        // The combined, comma-joined `authors` attribute lists every author.
970        assert_eq!(
971            parser.attribute_value("authors"),
972            InterpretedValue::Value("Jane Smith, John Doe")
973        );
974    }
975
976    #[test]
977    fn single_author_sets_combined_authors_but_no_indexed_names() {
978        let mut parser = Parser::default();
979        let _doc = parser.parse("= Document Title\nKismet R. Lee <kismet@asciidoctor.org>");
980
981        // For a single author, the combined `authors` attribute equals the
982        // primary `author` attribute ...
983        assert_eq!(
984            parser.attribute_value("authors"),
985            InterpretedValue::Value("Kismet R. Lee")
986        );
987
988        // ... and no `_1`-suffixed names are derived (those appear only when
989        // more than one author is present).
990        assert_eq!(parser.attribute_value("author_1"), InterpretedValue::Unset);
991        assert_eq!(
992            parser.attribute_value("firstname_1"),
993            InterpretedValue::Unset
994        );
995        assert_eq!(
996            parser.attribute_value("lastname_1"),
997            InterpretedValue::Unset
998        );
999    }
1000
1001    #[test]
1002    fn sets_author_attributes_unicode_names() {
1003        let mut parser = Parser::default();
1004        let _doc = parser.parse("= Document Title\nΑλέξανδρος Μ. Παπαδόπουλος");
1005
1006        assert_eq!(
1007            parser.attribute_value("author"),
1008            InterpretedValue::Value("Αλέξανδρος Μ. Παπαδόπουλος")
1009        );
1010        assert_eq!(
1011            parser.attribute_value("firstname"),
1012            InterpretedValue::Value("Αλέξανδρος")
1013        );
1014        assert_eq!(
1015            parser.attribute_value("middlename"),
1016            InterpretedValue::Value("Μ.")
1017        );
1018        assert_eq!(
1019            parser.attribute_value("lastname"),
1020            InterpretedValue::Value("Παπαδόπουλος")
1021        );
1022        assert_eq!(
1023            parser.attribute_value("authorinitials"),
1024            InterpretedValue::Value("ΑΜΠ")
1025        );
1026    }
1027
1028    #[test]
1029    fn semicolon_in_character_reference_not_treated_as_separator() {
1030        let mut parser = Parser::default();
1031
1032        let al = crate::document::AuthorLine::parse(
1033            crate::Span::new("AsciiDoc&#174;{empty} WG; Another Author"),
1034            &mut parser,
1035        );
1036
1037        assert_eq!(
1038            al,
1039            AuthorLine {
1040                authors: &[
1041                    Author {
1042                        name: "AsciiDoc&#174; WG",
1043                        firstname: "AsciiDoc&#174;",
1044                        middlename: None,
1045                        lastname: Some("WG"),
1046                        email: None,
1047                    },
1048                    Author {
1049                        name: "Another Author",
1050                        firstname: "Another",
1051                        middlename: None,
1052                        lastname: Some("Author"),
1053                        email: None,
1054                    },
1055                ],
1056                source: Span {
1057                    data: "AsciiDoc&#174;{empty} WG; Another Author",
1058                    line: 1,
1059                    col: 1,
1060                    offset: 0,
1061                },
1062            }
1063        );
1064    }
1065
1066    #[test]
1067    fn skips_blank_and_trailing_author_entries() {
1068        // https://github.com/asciidoc-rs/asciidoc-parser/issues/757: the author
1069        // line is split on a semicolon followed by a space or the end of the
1070        // line, so the blank middle entry and the trailing bare `;` are both
1071        // dropped instead of leaving the trailing `;` attached to the last
1072        // author.
1073        let mut parser = Parser::default();
1074
1075        let al = crate::document::AuthorLine::parse(
1076            crate::Span::new("Doc Writer; ; John Smith <john.smith@asciidoc.org>;"),
1077            &mut parser,
1078        );
1079
1080        assert_eq!(
1081            al,
1082            AuthorLine {
1083                authors: &[
1084                    Author {
1085                        name: "Doc Writer",
1086                        firstname: "Doc",
1087                        middlename: None,
1088                        lastname: Some("Writer"),
1089                        email: None,
1090                    },
1091                    Author {
1092                        name: "John Smith",
1093                        firstname: "John",
1094                        middlename: None,
1095                        lastname: Some("Smith"),
1096                        email: Some("john.smith@asciidoc.org"),
1097                    },
1098                ],
1099                source: Span {
1100                    data: "Doc Writer; ; John Smith <john.smith@asciidoc.org>;",
1101                    line: 1,
1102                    col: 1,
1103                    offset: 0,
1104                },
1105            }
1106        );
1107    }
1108
1109    #[test]
1110    fn semicolon_not_followed_by_space_is_single_author() {
1111        // A semicolon that is not followed by a space (or end of line) does not
1112        // separate authors.
1113        let mut parser = Parser::default();
1114
1115        let al = crate::document::AuthorLine::parse(
1116            crate::Span::new("Joe Doe;Smith Johnson"),
1117            &mut parser,
1118        );
1119
1120        assert_eq!(al.authors().len(), 1);
1121    }
1122
1123    #[test]
1124    fn character_reference_followed_by_space_not_treated_as_separator() {
1125        // The terminating `;` of a character reference must not split the author
1126        // even when it is followed by a space.
1127        let mut parser = Parser::default();
1128
1129        let al = crate::document::AuthorLine::parse(
1130            crate::Span::new("AsciiDoc&#174; WG; Another Author"),
1131            &mut parser,
1132        );
1133
1134        assert_eq!(
1135            al,
1136            AuthorLine {
1137                authors: &[
1138                    Author {
1139                        name: "AsciiDoc&#174; WG",
1140                        firstname: "AsciiDoc&#174;",
1141                        middlename: None,
1142                        lastname: Some("WG"),
1143                        email: None,
1144                    },
1145                    Author {
1146                        name: "Another Author",
1147                        firstname: "Another",
1148                        middlename: None,
1149                        lastname: Some("Author"),
1150                        email: None,
1151                    },
1152                ],
1153                source: Span {
1154                    data: "AsciiDoc&#174; WG; Another Author",
1155                    line: 1,
1156                    col: 1,
1157                    offset: 0,
1158                },
1159            }
1160        );
1161    }
1162
1163    #[test]
1164    fn invalid_named_entity_does_not_suppress_separator() {
1165        // A literal `&word;` sequence is not a numeric character reference, so
1166        // its semicolon still separates authors when followed by a space. Only
1167        // numeric references (`&#nnn;`) guard against splitting.
1168        let mut parser = Parser::default();
1169
1170        let al = crate::document::AuthorLine::parse(
1171            crate::Span::new("Alice &Development; Bob"),
1172            &mut parser,
1173        );
1174
1175        assert_eq!(
1176            al,
1177            AuthorLine {
1178                authors: &[
1179                    Author {
1180                        // The bare `&` is not a numeric character reference, so
1181                        // the header substitution group escapes it – unlike a
1182                        // `&#nnn;` reference, which is preserved.
1183                        name: "Alice &amp;Development",
1184                        firstname: "Alice",
1185                        middlename: None,
1186                        lastname: Some("&amp;Development"),
1187                        email: None,
1188                    },
1189                    Author {
1190                        name: "Bob",
1191                        firstname: "Bob",
1192                        middlename: None,
1193                        lastname: None,
1194                        email: None,
1195                    },
1196                ],
1197                source: Span {
1198                    data: "Alice &Development; Bob",
1199                    line: 1,
1200                    col: 1,
1201                    offset: 0,
1202                },
1203            }
1204        );
1205    }
1206
1207    #[test]
1208    fn comprehensive_author_attribute_test() {
1209        // This test verifies that all author attribute types work correctly for
1210        // multiple authors, including edge cases like missing middle names and emails.
1211
1212        let mut parser = Parser::default();
1213        let doc = parser.parse("= Document Title\nFirst Second Last <first@example.com>; Only First; A B C <abc@example.com>; No Email Guy");
1214
1215        assert_eq!(
1216            parser.attribute_value("author"),
1217            InterpretedValue::Value("First Second Last")
1218        );
1219
1220        assert_eq!(
1221            parser.attribute_value("firstname"),
1222            InterpretedValue::Value("First")
1223        );
1224
1225        assert_eq!(
1226            parser.attribute_value("middlename"),
1227            InterpretedValue::Value("Second")
1228        );
1229
1230        assert_eq!(
1231            parser.attribute_value("lastname"),
1232            InterpretedValue::Value("Last")
1233        );
1234
1235        assert_eq!(
1236            parser.attribute_value("authorinitials"),
1237            InterpretedValue::Value("FSL")
1238        );
1239
1240        assert_eq!(
1241            parser.attribute_value("email"),
1242            InterpretedValue::Value("first@example.com")
1243        );
1244
1245        assert_eq!(
1246            parser.attribute_value("author_2"),
1247            InterpretedValue::Value("Only First")
1248        );
1249
1250        assert_eq!(
1251            parser.attribute_value("firstname_2"),
1252            InterpretedValue::Value("Only")
1253        );
1254
1255        assert_eq!(
1256            parser.attribute_value("middlename_2"),
1257            InterpretedValue::Unset
1258        );
1259
1260        assert_eq!(
1261            parser.attribute_value("lastname_2"),
1262            InterpretedValue::Value("First")
1263        );
1264
1265        assert_eq!(
1266            parser.attribute_value("authorinitials_2"),
1267            InterpretedValue::Value("OF")
1268        );
1269
1270        assert_eq!(parser.attribute_value("email_2"), InterpretedValue::Unset);
1271
1272        assert_eq!(
1273            parser.attribute_value("author_3"),
1274            InterpretedValue::Value("A B C")
1275        );
1276
1277        assert_eq!(
1278            parser.attribute_value("firstname_3"),
1279            InterpretedValue::Value("A")
1280        );
1281
1282        assert_eq!(
1283            parser.attribute_value("middlename_3"),
1284            InterpretedValue::Value("B")
1285        );
1286
1287        assert_eq!(
1288            parser.attribute_value("lastname_3"),
1289            InterpretedValue::Value("C")
1290        );
1291
1292        assert_eq!(
1293            parser.attribute_value("authorinitials_3"),
1294            InterpretedValue::Value("ABC")
1295        );
1296
1297        assert_eq!(
1298            parser.attribute_value("email_3"),
1299            InterpretedValue::Value("abc@example.com")
1300        );
1301
1302        assert_eq!(
1303            parser.attribute_value("author_4"),
1304            InterpretedValue::Value("No Email Guy")
1305        );
1306
1307        assert_eq!(
1308            parser.attribute_value("firstname_4"),
1309            InterpretedValue::Value("No")
1310        );
1311
1312        assert_eq!(
1313            parser.attribute_value("middlename_4"),
1314            InterpretedValue::Value("Email")
1315        );
1316
1317        assert_eq!(
1318            parser.attribute_value("lastname_4"),
1319            InterpretedValue::Value("Guy")
1320        );
1321
1322        assert_eq!(
1323            parser.attribute_value("authorinitials_4"),
1324            InterpretedValue::Value("NEG")
1325        );
1326
1327        assert_eq!(parser.attribute_value("email_4"), InterpretedValue::Unset);
1328
1329        assert_eq!(
1330            doc,
1331            Document {
1332                header: Header {
1333                    title_source: Some(Span {
1334                        data: "Document Title",
1335                        line: 1,
1336                        col: 3,
1337                        offset: 2,
1338                    },),
1339                    title: Some("Document Title",),
1340                    attributes: &[],
1341                    author_line: Some(AuthorLine {
1342                        authors: &[
1343                            Author {
1344                                name: "First Second Last",
1345                                firstname: "First",
1346                                middlename: Some("Second",),
1347                                lastname: Some("Last",),
1348                                email: Some("first@example.com",),
1349                            },
1350                            Author {
1351                                name: "Only First",
1352                                firstname: "Only",
1353                                middlename: None,
1354                                lastname: Some("First",),
1355                                email: None,
1356                            },
1357                            Author {
1358                                name: "A B C",
1359                                firstname: "A",
1360                                middlename: Some("B",),
1361                                lastname: Some("C",),
1362                                email: Some("abc@example.com",),
1363                            },
1364                            Author {
1365                                name: "No Email Guy",
1366                                firstname: "No",
1367                                middlename: Some("Email",),
1368                                lastname: Some("Guy",),
1369                                email: None,
1370                            },
1371                        ],
1372                        source: Span {
1373                            data: "First Second Last <first@example.com>; Only First; A B C <abc@example.com>; No Email Guy",
1374                            line: 2,
1375                            col: 1,
1376                            offset: 17,
1377                        },
1378                    },),
1379                    revision_line: None,
1380                    comments: &[],
1381                    source: Span {
1382                        data: "= Document Title\nFirst Second Last <first@example.com>; Only First; A B C <abc@example.com>; No Email Guy",
1383                        line: 1,
1384                        col: 1,
1385                        offset: 0,
1386                    },
1387                },
1388                blocks: &[],
1389                source: Span {
1390                    data: "= Document Title\nFirst Second Last <first@example.com>; Only First; A B C <abc@example.com>; No Email Guy",
1391                    line: 1,
1392                    col: 1,
1393                    offset: 0,
1394                },
1395                warnings: &[],
1396                source_map: SourceMap(&[]),
1397                catalog: Catalog::default(),
1398            }
1399        );
1400    }
1401}