Skip to main content

asciidoc_parser/document/
author_line.rs

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