Skip to main content

asciidoc_parser/document/
author.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4
5use crate::{Parser, Span, content::Content};
6
7/// Represents a single author as (typically) described on the [author line].
8///
9/// The attributes `firstname`, `middlename`, `lastname`, and `authorinitials`
10/// are automatically derived from the full value of the author string. When
11/// assigned implicitly via the author line, the value includes all of the
12/// characters and words prior to the semicolon (`;`), angle bracket (`<`), or
13/// the end of the line. Note that when using the implicit author line, the full
14/// name can have a maximum of three space-separated names. If it has more, then
15/// the full name is assigned to the `firstname` attribute. You can adjoin names
16/// using an underscore (`_`) character.
17///
18/// [author line]: https://docs.asciidoctor.org/asciidoc/latest/document/author-line/
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Author {
21    name: String,
22    firstname: String,
23    middlename: Option<String>,
24    lastname: Option<String>,
25    email: Option<String>,
26}
27
28impl Author {
29    /// Parse a single author from `source`.
30    ///
31    /// `names_only` distinguishes the two contexts in which Asciidoctor parses
32    /// an author. The implicit author line (`names_only == false`) recognizes
33    /// at most three space-separated names via the [`AUTHOR`] pattern and,
34    /// failing that, stores the whole line as the author. An author
35    /// supplied through an attribute entry such as `:author:` (`names_only
36    /// == true`) is instead partitioned by splitting on whitespace into at
37    /// most three parts, so a name with four or more parts still assigns
38    /// its trailing parts to `lastname` (see issue #758).
39    pub(crate) fn parse(source: &str, parser: &Parser, names_only: bool) -> Option<Self> {
40        let source = source.trim();
41        if source.is_empty() {
42            return None;
43        }
44
45        // Parse the raw input first to extract components, then apply attribute
46        // substitution to individual components afterwards. Special case: If the entire
47        // input is a single attribute reference, treat the expanded result as a single
48        // name.
49        let is_single_attribute = source.trim().starts_with('{')
50            && source.trim().ends_with('}')
51            && source.matches('{').count() == 1;
52
53        if is_single_attribute {
54            // Entire input is a single attribute reference: Expand and treat as single
55            // name.
56            let expanded_source = apply_author_subs(source, parser);
57
58            if names_only {
59                // An attribute-entry value is partitioned *after* its references
60                // are expanded, so a reference that resolves to a multi-part name
61                // (or one with a trailing email) yields the same metadata as the
62                // equivalent literal value.
63                Some(partition_names_only(&expanded_source))
64            } else {
65                let name_with_spaces = replace_underscores_with_spaces(expanded_source);
66                Some(Self {
67                    name: name_with_spaces.clone(),
68                    firstname: name_with_spaces,
69                    middlename: None,
70                    lastname: None,
71                    email: None,
72                })
73            }
74        } else if let Some(captures) = AUTHOR.captures(source) {
75            // Raw input matches author pattern: Extract components then apply
76            // substitutions.
77
78            // Extract raw components first.
79            let firstname =
80                replace_underscores_with_spaces(apply_author_subs(&captures[1], parser));
81            let mut middlename = captures
82                .get(2)
83                .map(|m| replace_underscores_with_spaces(apply_author_subs(m.as_str(), parser)));
84            let mut lastname = captures
85                .get(3)
86                .map(|m| replace_underscores_with_spaces(apply_author_subs(m.as_str(), parser)));
87            let email = captures
88                .get(4)
89                .map(|m| apply_author_subs(m.as_str(), parser));
90
91            if middlename.is_some() && lastname.is_none() {
92                lastname = middlename;
93                middlename = None;
94            }
95
96            // Reconstruct the full name from its parsed parts so that any interior
97            // whitespace that appeared between the names in the source is condensed
98            // to a single space (matching Asciidoctor).
99            let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
100
101            Some(Self {
102                name,
103                firstname,
104                middlename,
105                lastname,
106                email,
107            })
108        } else if source.contains('{') {
109            // Input contains attributes that prevent regex match: Expand first, then try
110            // parsing.
111            let expanded_source = apply_author_subs(source, parser);
112
113            if let Some(captures) = AUTHOR.captures(&expanded_source) {
114                // After expansion, it matches the pattern: Parse normally.
115                let firstname = replace_underscores_with_spaces(captures[1].to_string());
116                let mut middlename = captures
117                    .get(2)
118                    .map(|m| replace_underscores_with_spaces(m.as_str().to_string()));
119                let mut lastname = captures
120                    .get(3)
121                    .map(|m| replace_underscores_with_spaces(m.as_str().to_string()));
122                let email = captures.get(4).map(|m| m.as_str().to_string());
123
124                if middlename.is_some() && lastname.is_none() {
125                    lastname = middlename;
126                    middlename = None;
127                }
128
129                // Reconstruct the full name from its parsed parts so interior
130                // whitespace between the names is condensed (matching Asciidoctor).
131                let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
132
133                Some(Self {
134                    name,
135                    firstname,
136                    middlename,
137                    lastname,
138                    email,
139                })
140            } else if names_only {
141                // An attribute-entry value that still fails the pattern after
142                // expansion is partitioned by the names-only rules, so a
143                // reference resolving to a four-plus-part name behaves like its
144                // literal equivalent. The expanded value is used before any HTML
145                // encoding so a trailing `<email>` can still be split off.
146                Some(partition_names_only(&expanded_source))
147            } else {
148                // Even after expansion, doesn't match: Treat as single name with HTML encoding.
149                let mut expanded_name = expanded_source;
150
151                if expanded_name.contains('<') && expanded_name.contains('>') {
152                    let span = crate::Span::new(&expanded_name);
153                    let mut content = crate::content::Content::from(span);
154                    crate::content::SubstitutionStep::SpecialCharacters.apply(
155                        &mut content,
156                        parser,
157                        None,
158                    );
159                    expanded_name = content.rendered().to_string();
160                }
161
162                let name_with_spaces = replace_underscores_with_spaces(expanded_name);
163                Some(Self {
164                    name: name_with_spaces.clone(),
165                    firstname: name_with_spaces,
166                    middlename: None,
167                    lastname: None,
168                    email: None,
169                })
170            }
171        } else if names_only {
172            // Input comes from an attribute entry (e.g. `:author:`) and does not
173            // match the author pattern – typically a name with four or more parts
174            // or one containing punctuation such as a comma. Asciidoctor still
175            // partitions it by splitting on whitespace into at most three parts,
176            // assigning any trailing parts to `lastname`.
177            Some(partition_names_only(source))
178        } else {
179            // Input doesn't contain attributes and doesn't match the author pattern.
180            // Asciidoctor stores the whole line as the author, condensing interior
181            // whitespace and keeping any angle brackets literal. Underscores are left
182            // literal here: Asciidoctor only converts underscore-joined names while
183            // partitioning a *matching* line, not in this fallback.
184            let name = condense_whitespace(source);
185            Some(Self {
186                name: name.clone(),
187                firstname: name,
188                middlename: None,
189                lastname: None,
190                email: None,
191            })
192        }
193    }
194
195    /// Parse the single author described by an `:author:` attribute entry.
196    ///
197    /// `raw` is the entry's raw (pre-substitution) value and `substituted` is
198    /// its substituted value (the stored attribute value). When the entry is a
199    /// whole-value `pass:[…]` macro its substituted value is the resolved
200    /// content, so that value is partitioned – with any generated markup
201    /// stripped – rather than the raw macro syntax (see
202    /// [`parse_substituted_names_only`]). Every other value is partitioned from
203    /// the raw value as before, so plain names, attribute references, and
204    /// inline emails are unaffected.
205    ///
206    /// [`parse_substituted_names_only`]: Self::parse_substituted_names_only
207    pub(crate) fn parse_from_entry(
208        raw: &str,
209        substituted: Option<&str>,
210        parser: &Parser,
211    ) -> Option<Self> {
212        if crate::document::is_attribute_entry_pass_macro(raw) {
213            substituted.and_then(Self::parse_substituted_names_only)
214        } else {
215            Self::parse(raw, parser, true)
216        }
217    }
218
219    /// Parse a single author from a value that has *already* been through
220    /// attribute-value substitution and now carries generated inline HTML –
221    /// typically the rendered output of a `pass:[…]` macro in an `:author:`
222    /// entry.
223    ///
224    /// This mirrors the `<`-branch of Asciidoctor's `process_authors` under
225    /// `names_only`: the full rendered value – with name-joiner underscores
226    /// turned to spaces – becomes the author's `name`, while the name parts are
227    /// partitioned from the value with its HTML tags removed, so the formatting
228    /// does not leak into `firstname`/`middlename`/`lastname`. As in
229    /// Asciidoctor, no email is split off here – an email supplied through a
230    /// companion `:email:` entry is attached later.
231    pub(crate) fn parse_substituted_names_only(substituted: &str) -> Option<Self> {
232        let substituted = substituted.trim();
233        if substituted.is_empty() {
234            return None;
235        }
236
237        let name = replace_underscores_with_spaces(substituted.to_string());
238        let stripped = strip_xml_tags(substituted);
239
240        let mut segments = split_whitespace_max3(&stripped);
241
242        if segments.is_empty() {
243            // The value was nothing but markup: keep the rendered value as the
244            // single name.
245            return Some(Self {
246                firstname: name.clone(),
247                name,
248                middlename: None,
249                lastname: None,
250                email: None,
251            });
252        }
253
254        let firstname = replace_underscores_with_spaces(segments.remove(0));
255        let (middlename, lastname) = match segments.len() {
256            0 => (None, None),
257
258            1 => (
259                None,
260                Some(replace_underscores_with_spaces(segments.remove(0))),
261            ),
262
263            _ => (
264                Some(replace_underscores_with_spaces(segments.remove(0))),
265                Some(replace_underscores_with_spaces(segments.remove(0))),
266            ),
267        };
268
269        Some(Self {
270            name,
271            firstname,
272            middlename,
273            lastname,
274            email: None,
275        })
276    }
277
278    /// Overrides the author's email address, unless `email` is `None`.
279    ///
280    /// Used when an author is assembled from `author_N` document attributes,
281    /// where the name and the companion `email_N` attribute are parsed
282    /// separately.
283    pub(crate) fn with_email(mut self, email: Option<String>) -> Self {
284        if let Some(email) = email {
285            self.email = Some(email);
286        }
287
288        self
289    }
290
291    /// Returns the full name of the author.
292    ///
293    /// The name includes the entire author declaration except for email.
294    pub fn name(&self) -> &str {
295        &self.name
296    }
297
298    /// Returns the first, forename, or given name of the author.
299    ///
300    /// The first space-separated name in the value of the `author` attribute is
301    /// automatically assigned to `firstname`.
302    pub fn firstname(&self) -> &str {
303        &self.firstname
304    }
305
306    /// Returns the middle name or initial of the author.
307    ///
308    /// If author contains three space-separated names, the second name is
309    /// assigned to the `middlename` attribute.
310    pub fn middlename(&self) -> Option<&str> {
311        self.middlename.as_deref()
312    }
313
314    /// Returns the last, surname, or family name of the author.
315    ///
316    /// If the author name contains two or three space-separated names, the last
317    /// of those names is assigned to the `lastname` attribute.
318    pub fn lastname(&self) -> Option<&str> {
319        self.lastname.as_deref()
320    }
321
322    /// Returns the email address or URL associated with the author.
323    ///
324    /// When assigned via the author line, it’s enclosed in a pair of angle
325    /// brackets (`< >`). A URL can be used in place of the email address.
326    pub fn email(&self) -> Option<&str> {
327        self.email.as_deref()
328    }
329
330    /// Returns the initials of the author.
331    ///
332    /// The first character of the `firstname`, `middlename`, and `lastname`
333    /// attribute values are assigned to the `authorinitials` attribute. The
334    /// value of the `authorinitials` attribute will consist of three characters
335    /// or less depending on how many parts are in the author’s name.
336    pub fn initials(&self) -> String {
337        format!(
338            "{first}{middle}{last}",
339            first = first_char_or_empty_string(&self.firstname),
340            middle = opt_first_char_or_empty_string(self.middlename.as_deref()),
341            last = opt_first_char_or_empty_string(self.lastname.as_deref()),
342        )
343    }
344}
345
346/// Populates the derived author document attributes from a resolved author
347/// list, mirroring Asciidoctor's `process_authors`.
348///
349/// The first author sets the unsuffixed keys (`author`, `firstname`,
350/// `authorinitials`, …); each subsequent author sets its `_N` companions. Once
351/// a second author appears, the first author is also mirrored onto its `_1`
352/// companions. The `authors` attribute is rewritten to the comma-joined list of
353/// resolved author names.
354///
355/// This intentionally does **not** honor `authorinitials_from_entry`: the
356/// derived `authorinitials` always overwrites an explicit `:authorinitials:`
357/// entry for the `authors` and `author_N` forms. Only a single `:author:` entry
358/// (handled inline in [`Header::parse`](crate::document::Header)) preserves an
359/// explicit override, exactly as Asciidoctor does – its `authorinitials`
360/// deletion guard lives only in the `author` branch of `process_authors`, not
361/// the `authors`/indexed branches.
362pub(crate) fn set_author_metadata(parser: &mut Parser, authors: &[Author]) {
363    for (idx, author) in authors.iter().enumerate() {
364        set_author_keys(parser, author, if idx == 0 { None } else { Some(idx + 1) });
365
366        // The `_1` companions are only assigned once a second author is seen.
367        if idx == 1
368            && let Some(first) = authors.first()
369        {
370            set_author_keys(parser, first, Some(1));
371        }
372    }
373
374    let joined = authors
375        .iter()
376        .map(Author::name)
377        .collect::<Vec<_>>()
378        .join(", ");
379
380    parser.set_attribute_by_value_from_header("authors", joined);
381}
382
383/// Sets the author attributes for a single author, either as the unsuffixed
384/// keys (`index` is `None`) or the `_N` companions (`index` is `Some(n)`).
385fn set_author_keys(parser: &mut Parser, author: &Author, index: Option<usize>) {
386    let key = |name: &str| match index {
387        None => name.to_string(),
388        Some(n) => format!("{name}_{n}"),
389    };
390
391    parser.set_attribute_by_value_from_header(key("author"), author.name());
392
393    parser.set_attribute_by_value_from_header(key("firstname"), author.firstname());
394
395    if let Some(middlename) = author.middlename() {
396        parser.set_attribute_by_value_from_header(key("middlename"), middlename);
397    }
398
399    if let Some(lastname) = author.lastname() {
400        parser.set_attribute_by_value_from_header(key("lastname"), lastname);
401    }
402
403    parser.set_attribute_by_value_from_header(key("authorinitials"), author.initials());
404
405    if let Some(email) = author.email() {
406        parser.set_attribute_by_value_from_header(key("email"), email);
407    }
408}
409
410fn first_char_or_empty_string(s: &str) -> String {
411    s.chars().next().map_or(String::new(), |c| c.to_string())
412}
413
414fn opt_first_char_or_empty_string(s: Option<&str>) -> String {
415    s.map(first_char_or_empty_string).unwrap_or_default()
416}
417
418/// Replace underscores with spaces in a name component.
419fn replace_underscores_with_spaces(name: String) -> String {
420    name.replace('_', " ")
421}
422
423/// Remove HTML tags from `source`, mirroring Asciidoctor's `XmlSanitizeRx`
424/// (`/<[^>]+>/`). Used to partition an author value whose substitution produced
425/// inline markup (see [`Author::parse_substituted_names_only`]).
426fn strip_xml_tags(source: &str) -> String {
427    XML_TAG.replace_all(source, "").into_owned()
428}
429
430/// Join an author's parsed name parts with a single space.
431///
432/// Asciidoctor reconstructs the full name from its partitioned parts, which
433/// condenses any interior whitespace that appeared between the names in the
434/// source down to a single space.
435fn join_name_parts(firstname: &str, middlename: Option<&str>, lastname: Option<&str>) -> String {
436    let mut name = String::from(firstname);
437
438    if let Some(middlename) = middlename {
439        name.push(' ');
440        name.push_str(middlename);
441    }
442
443    if let Some(lastname) = lastname {
444        name.push(' ');
445        name.push_str(lastname);
446    }
447
448    name
449}
450
451/// Partition an author value that does not match the [`AUTHOR`] pattern using
452/// Asciidoctor's `names_only` rules (the path taken for an attribute-entry
453/// value such as `:author:`).
454///
455/// A trailing `<email>` (or URL) is first split off so it is not absorbed into
456/// the name – mirroring the email group of the author pattern and Asciidoctor's
457/// XML sanitization of a names-only value. The remaining name is then split on
458/// whitespace into at most three segments (Ruby's `String#split(nil, 3)`, which
459/// also drops leading whitespace). The trailing segment retains its interior
460/// text – so a four-plus-part name keeps its later parts in `lastname` – but
461/// has repeating spaces condensed to a single space. Each segment then has
462/// underscore joiners replaced with spaces.
463///
464/// `source` may already have attribute references substituted (the expanded
465/// value of a reference such as `{full-name}`); in that case any email it
466/// carries is likewise already substituted.
467fn partition_names_only(source: &str) -> Author {
468    let source = source.trim();
469
470    let (name_source, email) = match NAMES_ONLY_EMAIL.captures(source) {
471        Some(captures) => (
472            captures.get(1).map_or(source, |m| m.as_str()),
473            Some(captures[2].to_string()),
474        ),
475        None => (source, None),
476    };
477
478    let mut segments = split_whitespace_max3(name_source);
479
480    let firstname = replace_underscores_with_spaces(segments.remove(0));
481    let (middlename, lastname) = match segments.len() {
482        0 => (None, None),
483        1 => (
484            None,
485            Some(replace_underscores_with_spaces(segments.remove(0))),
486        ),
487        _ => (
488            Some(replace_underscores_with_spaces(segments.remove(0))),
489            Some(replace_underscores_with_spaces(segments.remove(0))),
490        ),
491    };
492
493    let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
494
495    Author {
496        name,
497        firstname,
498        middlename,
499        lastname,
500        email,
501    }
502}
503
504/// Split `source` on runs of whitespace into at most three segments, mirroring
505/// Ruby's `String#split(nil, 3)`. Leading whitespace is dropped and the first
506/// two whitespace runs delimit the first two segments; the remainder becomes
507/// the third segment, with its repeating spaces condensed to a single space
508/// (Ruby's `String#squeeze ' '`).
509///
510/// Only ASCII whitespace is treated as a delimiter, matching Ruby's split
511/// (which does not break on non-breaking or other Unicode spaces), so a name
512/// joined by such a space stays a single segment. The returned vector always
513/// has at least one element because the caller has already rejected empty
514/// input.
515fn split_whitespace_max3(source: &str) -> Vec<String> {
516    let is_ascii_ws = |c: char| c.is_ascii_whitespace();
517
518    let mut segments: Vec<String> = Vec::with_capacity(3);
519    let mut rest = source;
520
521    for _ in 0..2 {
522        rest = rest.trim_start_matches(is_ascii_ws);
523        match rest.find(is_ascii_ws) {
524            Some(index) => {
525                segments.push(rest[..index].to_string());
526                rest = &rest[index..];
527            }
528            None => break,
529        }
530    }
531
532    rest = rest.trim_start_matches(is_ascii_ws);
533    if !rest.is_empty() {
534        segments.push(condense_whitespace(rest));
535    }
536
537    segments
538}
539
540/// Condense runs of spaces into a single space, mirroring Ruby's
541/// `String#tr_s(' ', ' ')`, which Asciidoctor applies to an author line that
542/// does not match the author pattern.
543fn condense_whitespace(s: &str) -> String {
544    let mut result = String::with_capacity(s.len());
545    let mut prev_was_space = false;
546
547    for c in s.chars() {
548        if c == ' ' {
549            if !prev_was_space {
550                result.push(' ');
551            }
552            prev_was_space = true;
553        } else {
554            result.push(c);
555            prev_was_space = false;
556        }
557    }
558
559    result
560}
561
562/// Matches a single HTML tag, mirroring Asciidoctor's `XmlSanitizeRx`.
563static XML_TAG: LazyLock<Regex> = LazyLock::new(|| {
564    #[allow(clippy::unwrap_used)]
565    Regex::new(r"<[^>]+>").unwrap()
566});
567
568static AUTHOR: LazyLock<Regex> = LazyLock::new(|| {
569    #[allow(clippy::unwrap_used)]
570    Regex::new(
571        r#"(?x)
572            ^
573
574            # Group 1: First name (required)
575            ([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*)
576
577            # Group 2: Middle name (optional)
578            (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
579
580            # Group 3: Last name (optional)
581            (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
582
583            # Group 4: Email address (optional)
584            (?:\ +<([^>]+)>)?
585
586            $
587        "#,
588    )
589    .unwrap()
590});
591
592/// Splits a names-only author value into its name portion (group 1) and a
593/// trailing `<email>` (group 2). The name must contain at least one
594/// non-whitespace character and is followed by whitespace before the bracketed
595/// email, matching the email group of [`AUTHOR`] for a value that otherwise
596/// fails the full pattern (e.g. a name with four or more parts).
597static NAMES_ONLY_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
598    #[allow(clippy::unwrap_used)]
599    Regex::new(r"^(.*\S)\s+<([^>]+)>$").unwrap()
600});
601
602/// Returns whether `source` matches the author pattern – at most three
603/// space-separated names with an optional trailing `<email>`.
604///
605/// The `:author:` attribute-entry path uses this to tell whether a plain-name
606/// value was partitioned by the fallback whitespace split (a name with four or
607/// more parts, or one containing punctuation such as a comma) rather than by
608/// the pattern. Only in the fallback case is the stored `author` value replaced
609/// with the reconstructed, whitespace-condensed name (issue #758).
610pub(crate) fn matches_author_pattern(source: &str) -> bool {
611    AUTHOR.is_match(source.trim())
612}
613
614fn apply_author_subs(source: &str, parser: &Parser) -> String {
615    let span = Span::new(source);
616    let mut content = Content::from(span);
617
618    use crate::content::SubstitutionStep;
619
620    // Apply attribute references first.
621    SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
622
623    // Apply HTML encoding:
624    // - Single attribute reference (like {full-author}): No HTML encoding.
625    // - Single attribute in email position (like <{email}>): No HTML encoding.
626    // - Multiple attributes or complex patterns: HTML encoding.
627    // - Don't HTML encode if the content only has pre-existing HTML entities.
628    let is_simple_single_attribute = source.trim().starts_with('{')
629        && source.trim().ends_with('}')
630        && source.matches('{').count() == 1;
631
632    let has_multiple_attributes = source.matches('{').count() > 1;
633
634    // Check if we should apply HTML encoding.
635    let rendered = content.rendered();
636    let has_angle_brackets = rendered.contains('<') && rendered.contains('>');
637    let has_unencoded_ampersand = rendered.contains('&') && !rendered.contains("&amp;");
638
639    if !is_simple_single_attribute
640        && has_multiple_attributes
641        && (has_angle_brackets || has_unencoded_ampersand)
642    {
643        SubstitutionStep::SpecialCharacters.apply(&mut content, parser, None);
644    }
645
646    content.rendered().to_string()
647}
648
649#[cfg(test)]
650mod tests {
651    #![allow(clippy::unwrap_used)]
652
653    use super::Author;
654
655    // The `<`-branch of Asciidoctor's `process_authors` (`names_only`), reached
656    // when an `:author:` attribute value's substitution produced inline HTML.
657    // The rendered markup – with name-joiner underscores turned to spaces –
658    // becomes `name`, while the name parts are partitioned from the tag-stripped
659    // text so the formatting does not leak into them. No email is split off.
660    mod parse_substituted_names_only {
661        use super::Author;
662
663        #[test]
664        fn empty_input_is_none() {
665            assert!(Author::parse_substituted_names_only("").is_none());
666            assert!(Author::parse_substituted_names_only("   ").is_none());
667        }
668
669        #[test]
670        fn markup_with_no_text_keeps_rendered_value_as_single_name() {
671            // Stripping the tags leaves nothing to partition, so the rendered
672            // markup stands as the whole name and `firstname`.
673            let author = Author::parse_substituted_names_only("<a href=\"x\"></a>").unwrap();
674            assert_eq!(author.name(), "<a href=\"x\"></a>");
675            assert_eq!(author.firstname(), "<a href=\"x\"></a>");
676            assert_eq!(author.middlename(), None);
677            assert_eq!(author.lastname(), None);
678            assert_eq!(author.email(), None);
679        }
680
681        #[test]
682        fn single_name_part() {
683            let author = Author::parse_substituted_names_only("<strong>Solo</strong>").unwrap();
684            assert_eq!(author.name(), "<strong>Solo</strong>");
685            assert_eq!(author.firstname(), "Solo");
686            assert_eq!(author.middlename(), None);
687            assert_eq!(author.lastname(), None);
688        }
689
690        #[test]
691        fn first_and_last_name() {
692            let author = Author::parse_substituted_names_only("<em>Kismet</em> Chameleon").unwrap();
693            assert_eq!(author.firstname(), "Kismet");
694            assert_eq!(author.middlename(), None);
695            assert_eq!(author.lastname(), Some("Chameleon"));
696        }
697
698        #[test]
699        fn first_middle_and_last_name() {
700            let author =
701                Author::parse_substituted_names_only("<em>Kismet</em> R. Chameleon").unwrap();
702            assert_eq!(author.firstname(), "Kismet");
703            assert_eq!(author.middlename(), Some("R."));
704            assert_eq!(author.lastname(), Some("Chameleon"));
705        }
706
707        #[test]
708        fn underscores_join_name_parts_and_the_rendered_name() {
709            // Underscores act as name joiners: within a part they become a
710            // space, and the full `name` has them replaced too.
711            let author = Author::parse_substituted_names_only("<b>Ze_Project</b> team").unwrap();
712            assert_eq!(author.name(), "<b>Ze Project</b> team");
713            assert_eq!(author.firstname(), "Ze Project");
714            assert_eq!(author.lastname(), Some("team"));
715            assert_eq!(author.email(), None);
716        }
717    }
718
719    // The entry dispatcher routes a *whole-value* `pass:[…]` macro to its
720    // substituted value (the resolved content) and every other value to the
721    // raw-value partitioning, so the literal macro syntax never leaks into the
722    // name parts.
723    mod parse_from_entry {
724        use super::Author;
725        use crate::Parser;
726
727        #[test]
728        fn pass_macro_with_markup_is_partitioned_from_the_substituted_value() {
729            // The raw is the macro syntax; the substituted value is its rendered
730            // output, which is what gets partitioned (tags stripped).
731            let parser = Parser::default();
732            let author = Author::parse_from_entry(
733                "pass:n[https://example.org/x[Ze *team*]]",
734                Some("<a href=\"https://example.org/x\">Ze <strong>team</strong></a>"),
735                &parser,
736            )
737            .unwrap();
738
739            assert_eq!(author.firstname(), "Ze");
740            assert_eq!(author.lastname(), Some("team"));
741        }
742
743        #[test]
744        fn pass_macro_resolving_to_plain_text_uses_the_substituted_value() {
745            // A pass macro whose result has no markup must still be partitioned
746            // from the substituted value, never the raw `pass:…[…]` syntax.
747            let parser = Parser::default();
748            let author =
749                Author::parse_from_entry("pass:n[Doc Writer]", Some("Doc Writer"), &parser)
750                    .unwrap();
751
752            assert_eq!(author.name(), "Doc Writer");
753            assert_eq!(author.firstname(), "Doc");
754            assert_eq!(author.lastname(), Some("Writer"));
755        }
756
757        #[test]
758        fn non_pass_value_uses_raw_partitioning() {
759            // A value that is not a whole-value pass macro is partitioned from
760            // the raw value as before (the substituted value is not consulted).
761            let parser = Parser::default();
762            let author =
763                Author::parse_from_entry("Doc Writer", Some("Doc Writer"), &parser).unwrap();
764
765            assert_eq!(author.firstname(), "Doc");
766            assert_eq!(author.lastname(), Some("Writer"));
767        }
768
769        #[test]
770        fn empty_pass_macro_yields_no_author() {
771            // `pass:[]` resolves to an empty value, which describes no author.
772            let parser = Parser::default();
773            assert!(Author::parse_from_entry("pass:[]", Some(""), &parser).is_none());
774        }
775    }
776}