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