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/// # Rendered versus raw values
19///
20/// The [`name`], [`firstname`], [`middlename`], [`lastname`], and [`email`]
21/// accessors return the value that populates the corresponding document
22/// attribute (`author`, `firstname`, …). As in Asciidoctor, that value has the
23/// header substitution group applied – so a literal `<`, `>`, or `&` written on
24/// the author line is escaped (`&lt;`, `&gt;`, `&amp;`), matching what
25/// `{author}` and the rendered byline emit and what Asciidoctor's own
26/// `doc.author` / `authors[0].name` return.
27///
28/// Each accessor has a `raw_` counterpart ([`raw_name`], [`raw_firstname`],
29/// [`raw_middlename`], [`raw_lastname`], [`raw_email`]) that returns the same
30/// value *before* that escaping – attribute references still resolved, but the
31/// literal special characters left as they were written. This mirrors
32/// Asciidoctor's internal, pre-substitution `metadata` hash and lets a consumer
33/// that escapes for HTML itself do so exactly once, rather than double-escaping
34/// an already-escaped value.
35///
36/// [author line]: https://docs.asciidoctor.org/asciidoc/latest/document/author-line/
37/// [`name`]: Self::name
38/// [`firstname`]: Self::firstname
39/// [`middlename`]: Self::middlename
40/// [`lastname`]: Self::lastname
41/// [`email`]: Self::email
42/// [`raw_name`]: Self::raw_name
43/// [`raw_firstname`]: Self::raw_firstname
44/// [`raw_middlename`]: Self::raw_middlename
45/// [`raw_lastname`]: Self::raw_lastname
46/// [`raw_email`]: Self::raw_email
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct Author {
49    name: String,
50    firstname: String,
51    middlename: Option<String>,
52    lastname: Option<String>,
53    email: Option<String>,
54
55    // The `raw_*` fields hold the same values as their rendered counterparts
56    // above, but before the header substitution group escapes any literal `<`,
57    // `>`, or `&` written on the author line. Attribute references are still
58    // resolved. See the type-level "Rendered versus raw values" documentation.
59    raw_name: String,
60    raw_firstname: String,
61    raw_middlename: Option<String>,
62    raw_lastname: Option<String>,
63    raw_email: Option<String>,
64}
65
66impl Author {
67    /// Parse a single author from `source`.
68    ///
69    /// `names_only` distinguishes the two contexts in which Asciidoctor parses
70    /// an author. The implicit author line (`names_only == false`) recognizes
71    /// at most three space-separated names via the [`AUTHOR`] pattern and,
72    /// failing that, stores the whole line as the author. An author
73    /// supplied through an attribute entry such as `:author:` (`names_only
74    /// == true`) is instead partitioned by splitting on whitespace into at
75    /// most three parts, so a name with four or more parts still assigns
76    /// its trailing parts to `lastname` (see issue #758).
77    pub(crate) fn parse(source: &str, parser: &Parser, names_only: bool) -> Option<Self> {
78        let source = source.trim();
79        if source.is_empty() {
80            return None;
81        }
82
83        // Parse the raw input first to extract components, then apply attribute
84        // substitution to individual components afterwards. Special case: If the entire
85        // input is a single attribute reference, treat the expanded result as a single
86        // name.
87        let is_single_attribute = source.trim().starts_with('{')
88            && source.trim().ends_with('}')
89            && source.matches('{').count() == 1;
90
91        if is_single_attribute {
92            // Entire input is a single attribute reference: Expand and treat as single
93            // name.
94            let expanded_source = apply_author_subs(source, parser);
95
96            // The raw value expands the reference without applying special
97            // characters. A single reference carries no literal special
98            // characters of its own, so the raw and rendered expansions differ
99            // only if the referenced attribute's own value was already escaped.
100            let raw_expanded = resolve_attribute_references(source, parser);
101
102            if names_only {
103                // An attribute-entry value is partitioned *after* its references
104                // are expanded, so a reference that resolves to a multi-part name
105                // (or one with a trailing email) yields the same metadata as the
106                // equivalent literal value.
107                Some(partition_names_only(&expanded_source, &raw_expanded))
108            } else {
109                Some(single_name_author(
110                    replace_underscores_with_spaces(expanded_source),
111                    replace_underscores_with_spaces(raw_expanded),
112                ))
113            }
114        } else if let Some(captures) = AUTHOR.captures(source) {
115            // Raw input matches author pattern: Extract each component and apply
116            // substitutions to it. The rendered components escape literal special
117            // characters (`apply_author_subs`); the raw components resolve
118            // attribute references only, leaving those characters as written.
119            let (name, firstname, middlename, lastname, email) =
120                matched_parts(&captures, |s| apply_author_subs(s, parser));
121
122            let (raw_name, raw_firstname, raw_middlename, raw_lastname, raw_email) =
123                matched_parts(&captures, |s| resolve_attribute_references(s, parser));
124
125            Some(Self {
126                name,
127                firstname,
128                middlename,
129                lastname,
130                email,
131                raw_name,
132                raw_firstname,
133                raw_middlename,
134                raw_lastname,
135                raw_email,
136            })
137        } else if source.contains('{') {
138            // Input contains attributes that prevent regex match: Expand first, then try
139            // parsing.
140            let expanded_source = apply_author_subs(source, parser);
141            let raw_expanded = resolve_attribute_references(source, parser);
142
143            if let Some(captures) = AUTHOR.captures(&expanded_source) {
144                // After expansion, it matches the pattern: Parse normally. The
145                // components are already substituted, so the transform is the
146                // identity here.
147                let (name, firstname, middlename, lastname, email) =
148                    matched_parts(&captures, str::to_string);
149
150                // Derive the raw components from the raw expansion, but only when
151                // it matches the same pattern *and* agrees on whether a trailing
152                // `<email>` is present, so the two representations keep the same
153                // structure and differ only in escaping. They can disagree only
154                // if a literal author-line bracket was escaped in the rendered
155                // value; in that case fall back to the rendered components.
156                let (raw_name, raw_firstname, raw_middlename, raw_lastname, raw_email) =
157                    match AUTHOR.captures(&raw_expanded) {
158                        Some(raw_captures) if raw_captures.get(4).is_some() == email.is_some() => {
159                            matched_parts(&raw_captures, str::to_string)
160                        }
161
162                        _ => (
163                            name.clone(),
164                            firstname.clone(),
165                            middlename.clone(),
166                            lastname.clone(),
167                            email.clone(),
168                        ),
169                    };
170
171                Some(Self {
172                    name,
173                    firstname,
174                    middlename,
175                    lastname,
176                    email,
177                    raw_name,
178                    raw_firstname,
179                    raw_middlename,
180                    raw_lastname,
181                    raw_email,
182                })
183            } else if names_only {
184                // An attribute-entry value that still fails the pattern after
185                // expansion is partitioned by the names-only rules, so a
186                // reference resolving to a four-plus-part name behaves like its
187                // literal equivalent. The expanded value is used before any HTML
188                // encoding so a trailing `<email>` can still be split off.
189                Some(partition_names_only(&expanded_source, &raw_expanded))
190            } else {
191                // Even after expansion the value does not match the author
192                // pattern, so it becomes a single name. The rendered value has the
193                // header substitution group applied (escaping any literal `<`,
194                // `>`, or `&`), mirroring Asciidoctor's `apply_header_subs`; the
195                // raw value expands the references without that escaping.
196                Some(single_name_author(
197                    replace_underscores_with_spaces(expanded_source),
198                    replace_underscores_with_spaces(raw_expanded),
199                ))
200            }
201        } else if names_only {
202            // Input comes from an attribute entry (e.g. `:author:`) and does not
203            // match the author pattern – typically a name with four or more parts
204            // or one containing punctuation such as a comma. Asciidoctor still
205            // partitions it by splitting on whitespace into at most three parts,
206            // assigning any trailing parts to `lastname`. This path applies no
207            // special-characters substitution, so the raw and rendered values are
208            // identical.
209            Some(partition_names_only(source, source))
210        } else {
211            // Input doesn't contain attributes and doesn't match the author
212            // pattern. Asciidoctor stores the whole line as the author,
213            // condensing interior whitespace. Underscores are left literal here:
214            // Asciidoctor only converts underscore-joined names while
215            // partitioning a *matching* line, not in this fallback.
216            //
217            // The rendered value has the header substitution group applied, so any
218            // literal `<`, `>`, or `&` is escaped – matching Asciidoctor's
219            // `apply_header_subs`. The raw value keeps those characters as written.
220            let raw_name = condense_whitespace(source);
221            let name = apply_author_special_characters(&raw_name, parser);
222            Some(single_name_author(name, raw_name))
223        }
224    }
225
226    /// Parse the single author described by an `:author:` attribute entry.
227    ///
228    /// `raw` is the entry's raw (pre-substitution) value and `substituted` is
229    /// its substituted value (the stored attribute value). When the entry is a
230    /// whole-value `pass:[…]` macro its substituted value is the resolved
231    /// content, so that value is partitioned – with any generated markup
232    /// stripped – rather than the raw macro syntax (see
233    /// [`parse_substituted_names_only`]). Every other value is partitioned from
234    /// the raw value as before, so plain names, attribute references, and
235    /// inline emails are unaffected.
236    ///
237    /// [`parse_substituted_names_only`]: Self::parse_substituted_names_only
238    pub(crate) fn parse_from_entry(
239        raw: &str,
240        substituted: Option<&str>,
241        parser: &Parser,
242    ) -> Option<Self> {
243        if crate::document::is_attribute_entry_pass_macro(raw) {
244            substituted.and_then(Self::parse_substituted_names_only)
245        } else {
246            Self::parse(raw, parser, true)
247        }
248    }
249
250    /// Parse a single author from a value that has *already* been through
251    /// attribute-value substitution and now carries generated inline HTML –
252    /// typically the rendered output of a `pass:[…]` macro in an `:author:`
253    /// entry.
254    ///
255    /// This mirrors the `<`-branch of Asciidoctor's `process_authors` under
256    /// `names_only`: the full rendered value – with name-joiner underscores
257    /// turned to spaces – becomes the author's `name`, while the name parts are
258    /// partitioned from the value with its HTML tags removed, so the formatting
259    /// does not leak into `firstname`/`middlename`/`lastname`. As in
260    /// Asciidoctor, no email is split off here – an email supplied through a
261    /// companion `:email:` entry is attached later.
262    pub(crate) fn parse_substituted_names_only(substituted: &str) -> Option<Self> {
263        let substituted = substituted.trim();
264        if substituted.is_empty() {
265            return None;
266        }
267
268        let name = replace_underscores_with_spaces(substituted.to_string());
269        let stripped = strip_xml_tags(substituted);
270
271        let mut segments = split_whitespace_max3(&stripped);
272
273        if segments.is_empty() {
274            // The value was nothing but markup: keep the rendered value as the
275            // single name.
276            return Some(single_name_author(name.clone(), name));
277        }
278
279        let firstname = replace_underscores_with_spaces(segments.remove(0));
280        let (middlename, lastname) = match segments.len() {
281            0 => (None, None),
282
283            1 => (
284                None,
285                Some(replace_underscores_with_spaces(segments.remove(0))),
286            ),
287
288            _ => (
289                Some(replace_underscores_with_spaces(segments.remove(0))),
290                Some(replace_underscores_with_spaces(segments.remove(0))),
291            ),
292        };
293
294        // This path partitions an already-substituted value, so it applies no
295        // further special-characters substitution: the raw values equal the
296        // rendered ones.
297        Some(Self {
298            raw_name: name.clone(),
299            raw_firstname: firstname.clone(),
300            raw_middlename: middlename.clone(),
301            raw_lastname: lastname.clone(),
302            raw_email: None,
303            name,
304            firstname,
305            middlename,
306            lastname,
307            email: None,
308        })
309    }
310
311    /// Overrides the author's email address, unless `email` is `None`.
312    ///
313    /// Used when an author is assembled from `author_N` document attributes,
314    /// where the name and the companion `email_N` attribute are parsed
315    /// separately.
316    pub(crate) fn with_email(mut self, email: Option<String>) -> Self {
317        if let Some(email) = email {
318            // The email arrives from a companion `email_N` attribute value, which
319            // carries no author-line special-characters escaping of its own, so
320            // the raw and rendered emails are the same.
321            self.raw_email = Some(email.clone());
322            self.email = Some(email);
323        }
324
325        self
326    }
327
328    /// Returns the full name of the author.
329    ///
330    /// The name includes the entire author declaration except for email.
331    pub fn name(&self) -> &str {
332        &self.name
333    }
334
335    /// Returns the first, forename, or given name of the author.
336    ///
337    /// The first space-separated name in the value of the `author` attribute is
338    /// automatically assigned to `firstname`.
339    pub fn firstname(&self) -> &str {
340        &self.firstname
341    }
342
343    /// Returns the middle name or initial of the author.
344    ///
345    /// If author contains three space-separated names, the second name is
346    /// assigned to the `middlename` attribute.
347    pub fn middlename(&self) -> Option<&str> {
348        self.middlename.as_deref()
349    }
350
351    /// Returns the last, surname, or family name of the author.
352    ///
353    /// If the author name contains two or three space-separated names, the last
354    /// of those names is assigned to the `lastname` attribute.
355    pub fn lastname(&self) -> Option<&str> {
356        self.lastname.as_deref()
357    }
358
359    /// Returns the email address or URL associated with the author.
360    ///
361    /// When assigned via the author line, it’s enclosed in a pair of angle
362    /// brackets (`< >`). A URL can be used in place of the email address.
363    pub fn email(&self) -> Option<&str> {
364        self.email.as_deref()
365    }
366
367    /// Returns the full name of the author *before* the header substitution
368    /// group escapes any literal special characters.
369    ///
370    /// This is [`name`](Self::name) with attribute references resolved but any
371    /// literal `<`, `>`, or `&` written on the author line left unescaped,
372    /// mirroring Asciidoctor's internal `metadata` hash. Use it when the value
373    /// will be HTML-escaped downstream, so the escaping happens exactly once.
374    pub fn raw_name(&self) -> &str {
375        &self.raw_name
376    }
377
378    /// Returns the first name of the author *before* the header substitution
379    /// group escapes any literal special characters.
380    ///
381    /// See [`raw_name`](Self::raw_name) for how the raw value differs from
382    /// [`firstname`](Self::firstname).
383    pub fn raw_firstname(&self) -> &str {
384        &self.raw_firstname
385    }
386
387    /// Returns the middle name of the author *before* the header substitution
388    /// group escapes any literal special characters.
389    ///
390    /// See [`raw_name`](Self::raw_name) for how the raw value differs from
391    /// [`middlename`](Self::middlename).
392    pub fn raw_middlename(&self) -> Option<&str> {
393        self.raw_middlename.as_deref()
394    }
395
396    /// Returns the last name of the author *before* the header substitution
397    /// group escapes any literal special characters.
398    ///
399    /// See [`raw_name`](Self::raw_name) for how the raw value differs from
400    /// [`lastname`](Self::lastname).
401    pub fn raw_lastname(&self) -> Option<&str> {
402        self.raw_lastname.as_deref()
403    }
404
405    /// Returns the email address of the author *before* the header substitution
406    /// group escapes any literal special characters.
407    ///
408    /// See [`raw_name`](Self::raw_name) for how the raw value differs from
409    /// [`email`](Self::email).
410    pub fn raw_email(&self) -> Option<&str> {
411        self.raw_email.as_deref()
412    }
413
414    /// Returns the initials of the author.
415    ///
416    /// The first character of the `firstname`, `middlename`, and `lastname`
417    /// attribute values are assigned to the `authorinitials` attribute. The
418    /// value of the `authorinitials` attribute will consist of three characters
419    /// or less depending on how many parts are in the author’s name.
420    pub fn initials(&self) -> String {
421        format!(
422            "{first}{middle}{last}",
423            first = first_char_or_empty_string(&self.firstname),
424            middle = opt_first_char_or_empty_string(self.middlename.as_deref()),
425            last = opt_first_char_or_empty_string(self.lastname.as_deref()),
426        )
427    }
428}
429
430/// Populates the derived author document attributes from a resolved author
431/// list, mirroring Asciidoctor's `process_authors`.
432///
433/// The first author sets the unsuffixed keys (`author`, `firstname`,
434/// `authorinitials`, …); each subsequent author sets its `_N` companions. Once
435/// a second author appears, the first author is also mirrored onto its `_1`
436/// companions. The `authors` attribute is rewritten to the comma-joined list of
437/// resolved author names.
438///
439/// This intentionally does **not** honor `authorinitials_from_entry`: the
440/// derived `authorinitials` always overwrites an explicit `:authorinitials:`
441/// entry for the `authors` and `author_N` forms. Only a single `:author:` entry
442/// (handled inline in [`Header::parse`](crate::document::Header)) preserves an
443/// explicit override, exactly as Asciidoctor does – its `authorinitials`
444/// deletion guard lives only in the `author` branch of `process_authors`, not
445/// the `authors`/indexed branches.
446pub(crate) fn set_author_metadata(parser: &mut Parser, authors: &[Author]) {
447    for (idx, author) in authors.iter().enumerate() {
448        set_author_keys(parser, author, if idx == 0 { None } else { Some(idx + 1) });
449
450        // The `_1` companions are only assigned once a second author is seen.
451        if idx == 1
452            && let Some(first) = authors.first()
453        {
454            set_author_keys(parser, first, Some(1));
455        }
456    }
457
458    let joined = authors
459        .iter()
460        .map(Author::name)
461        .collect::<Vec<_>>()
462        .join(", ");
463
464    parser.set_attribute_by_value_from_header("authors", joined);
465}
466
467/// Sets the author attributes for a single author, either as the unsuffixed
468/// keys (`index` is `None`) or the `_N` companions (`index` is `Some(n)`).
469fn set_author_keys(parser: &mut Parser, author: &Author, index: Option<usize>) {
470    let key = |name: &str| match index {
471        None => name.to_string(),
472        Some(n) => format!("{name}_{n}"),
473    };
474
475    parser.set_attribute_by_value_from_header(key("author"), author.name());
476
477    parser.set_attribute_by_value_from_header(key("firstname"), author.firstname());
478
479    if let Some(middlename) = author.middlename() {
480        parser.set_attribute_by_value_from_header(key("middlename"), middlename);
481    }
482
483    if let Some(lastname) = author.lastname() {
484        parser.set_attribute_by_value_from_header(key("lastname"), lastname);
485    }
486
487    parser.set_attribute_by_value_from_header(key("authorinitials"), author.initials());
488
489    if let Some(email) = author.email() {
490        parser.set_attribute_by_value_from_header(key("email"), email);
491    }
492}
493
494fn first_char_or_empty_string(s: &str) -> String {
495    s.chars().next().map_or(String::new(), |c| c.to_string())
496}
497
498fn opt_first_char_or_empty_string(s: Option<&str>) -> String {
499    s.map(first_char_or_empty_string).unwrap_or_default()
500}
501
502/// Replace underscores with spaces in a name component.
503fn replace_underscores_with_spaces(name: String) -> String {
504    name.replace('_', " ")
505}
506
507/// Remove HTML tags from `source`, mirroring Asciidoctor's `XmlSanitizeRx`
508/// (`/<[^>]+>/`). Used to partition an author value whose substitution produced
509/// inline markup (see [`Author::parse_substituted_names_only`]).
510fn strip_xml_tags(source: &str) -> String {
511    XML_TAG.replace_all(source, "").into_owned()
512}
513
514/// Join an author's parsed name parts with a single space.
515///
516/// Asciidoctor reconstructs the full name from its partitioned parts, which
517/// condenses any interior whitespace that appeared between the names in the
518/// source down to a single space.
519fn join_name_parts(firstname: &str, middlename: Option<&str>, lastname: Option<&str>) -> String {
520    let mut name = String::from(firstname);
521
522    if let Some(middlename) = middlename {
523        name.push(' ');
524        name.push_str(middlename);
525    }
526
527    if let Some(lastname) = lastname {
528        name.push(' ');
529        name.push_str(lastname);
530    }
531
532    name
533}
534
535/// Partition an author value that does not match the [`AUTHOR`] pattern using
536/// Asciidoctor's `names_only` rules (the path taken for an attribute-entry
537/// value such as `:author:`).
538///
539/// A trailing `<email>` (or URL) is first split off so it is not absorbed into
540/// the name – mirroring the email group of the author pattern and Asciidoctor's
541/// XML sanitization of a names-only value. The remaining name is then split on
542/// whitespace into at most three segments (Ruby's `String#split(nil, 3)`, which
543/// also drops leading whitespace). The trailing segment retains its interior
544/// text – so a four-plus-part name keeps its later parts in `lastname` – but
545/// has repeating spaces condensed to a single space. Each segment then has
546/// underscore joiners replaced with spaces.
547///
548/// `escaped_source` may already have attribute references substituted (the
549/// expanded value of a reference such as `{full-name}`); in that case any email
550/// it carries is likewise already substituted.
551///
552/// `raw_source` is the same value expanded *without* the special-characters
553/// substitution and is partitioned independently to populate the author's
554/// `raw_*` fields (see the [`Author`] documentation). For an
555/// attribute entry that carries no literal special characters the two arguments
556/// are identical.
557fn partition_names_only(escaped_source: &str, raw_source: &str) -> Author {
558    // Partition the rendered value first, then partition the raw value using the
559    // *rendered* value's trailing-email decision so the two representations keep
560    // the same structure and differ only in escaping. A literal author-line
561    // bracket is escaped in the rendered value and so is not recognized as an
562    // email delimiter; the raw value, whose bracket is still literal, must follow
563    // that same decision rather than splitting an email off on its own.
564    let escaped = partition_parts(escaped_source, EmailSplit::Detect);
565
566    let raw = partition_parts(
567        raw_source,
568        if escaped.email.is_some() {
569            EmailSplit::Detect
570        } else {
571            EmailSplit::Suppress
572        },
573    );
574
575    Author {
576        name: escaped.name,
577        firstname: escaped.firstname,
578        middlename: escaped.middlename,
579        lastname: escaped.lastname,
580        email: escaped.email,
581        raw_name: raw.name,
582        raw_firstname: raw.firstname,
583        raw_middlename: raw.middlename,
584        raw_lastname: raw.lastname,
585        raw_email: raw.email,
586    }
587}
588
589/// The five partitioned strings produced from a single names-only author value.
590struct NameParts {
591    name: String,
592    firstname: String,
593    middlename: Option<String>,
594    lastname: Option<String>,
595    email: Option<String>,
596}
597
598/// Whether [`partition_parts`] may split a trailing `<email>` off its input.
599enum EmailSplit {
600    /// Split a trailing `<email>` off when one is present (the rendered value's
601    /// own decision).
602    Detect,
603
604    /// Never split an email off; the whole value is treated as the name. Used
605    /// for the raw value when the rendered value kept its bracket escaped, so
606    /// the two stay structurally aligned.
607    Suppress,
608}
609
610/// Partition a single names-only author value into its parts, following the
611/// rules described on [`partition_names_only`].
612fn partition_parts(source: &str, email_split: EmailSplit) -> NameParts {
613    let source = source.trim();
614
615    let (name_source, email) = match email_split {
616        EmailSplit::Detect => match NAMES_ONLY_EMAIL.captures(source) {
617            Some(captures) => (
618                captures.get(1).map_or(source, |m| m.as_str()),
619                Some(captures[2].to_string()),
620            ),
621            None => (source, None),
622        },
623
624        EmailSplit::Suppress => (source, None),
625    };
626
627    let mut segments = split_whitespace_max3(name_source);
628
629    let firstname = replace_underscores_with_spaces(segments.remove(0));
630    let (middlename, lastname) = match segments.len() {
631        0 => (None, None),
632        1 => (
633            None,
634            Some(replace_underscores_with_spaces(segments.remove(0))),
635        ),
636        _ => (
637            Some(replace_underscores_with_spaces(segments.remove(0))),
638            Some(replace_underscores_with_spaces(segments.remove(0))),
639        ),
640    };
641
642    let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
643
644    NameParts {
645        name,
646        firstname,
647        middlename,
648        lastname,
649        email,
650    }
651}
652
653/// Extract the author components from a successful [`AUTHOR`] match, applying
654/// `transform` to each captured fragment.
655///
656/// The rendered components pass `apply_author_subs` (special characters, then
657/// attribute references); the raw components pass
658/// [`resolve_attribute_references`] (attribute references only), so the two
659/// share the match's structure but differ in whether literal special characters
660/// are escaped. Returns `(name, firstname, middlename, lastname, email)`,
661/// promoting a lone middle name to the last name and reconstructing `name` from
662/// the parts so interior whitespace is condensed to a single space (matching
663/// Asciidoctor).
664fn matched_parts<F>(
665    captures: &regex::Captures,
666    transform: F,
667) -> (
668    String,
669    String,
670    Option<String>,
671    Option<String>,
672    Option<String>,
673)
674where
675    F: Fn(&str) -> String,
676{
677    let firstname = replace_underscores_with_spaces(transform(&captures[1]));
678
679    let mut middlename = captures
680        .get(2)
681        .map(|m| replace_underscores_with_spaces(transform(m.as_str())));
682
683    let mut lastname = captures
684        .get(3)
685        .map(|m| replace_underscores_with_spaces(transform(m.as_str())));
686
687    let email = captures.get(4).map(|m| transform(m.as_str()));
688
689    if middlename.is_some() && lastname.is_none() {
690        lastname = middlename;
691        middlename = None;
692    }
693
694    let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
695
696    (name, firstname, middlename, lastname, email)
697}
698
699/// Build a single-name [`Author`] – one whose whole value is the `name` with no
700/// separate first/middle/last/email parts – from its rendered and raw forms.
701fn single_name_author(name: String, raw_name: String) -> Author {
702    Author {
703        firstname: name.clone(),
704        name,
705        middlename: None,
706        lastname: None,
707        email: None,
708        raw_firstname: raw_name.clone(),
709        raw_name,
710        raw_middlename: None,
711        raw_lastname: None,
712        raw_email: None,
713    }
714}
715
716/// Split `source` on runs of whitespace into at most three segments, mirroring
717/// Ruby's `String#split(nil, 3)`. Leading whitespace is dropped and the first
718/// two whitespace runs delimit the first two segments; the remainder becomes
719/// the third segment, with its repeating spaces condensed to a single space
720/// (Ruby's `String#squeeze ' '`).
721///
722/// Only ASCII whitespace is treated as a delimiter, matching Ruby's split
723/// (which does not break on non-breaking or other Unicode spaces), so a name
724/// joined by such a space stays a single segment. The returned vector always
725/// has at least one element because the caller has already rejected empty
726/// input.
727fn split_whitespace_max3(source: &str) -> Vec<String> {
728    let is_ascii_ws = |c: char| c.is_ascii_whitespace();
729
730    let mut segments: Vec<String> = Vec::with_capacity(3);
731    let mut rest = source;
732
733    for _ in 0..2 {
734        rest = rest.trim_start_matches(is_ascii_ws);
735        match rest.find(is_ascii_ws) {
736            Some(index) => {
737                segments.push(rest[..index].to_string());
738                rest = &rest[index..];
739            }
740            None => break,
741        }
742    }
743
744    rest = rest.trim_start_matches(is_ascii_ws);
745    if !rest.is_empty() {
746        segments.push(condense_whitespace(rest));
747    }
748
749    segments
750}
751
752/// Condense runs of spaces into a single space, mirroring Ruby's
753/// `String#tr_s(' ', ' ')`, which Asciidoctor applies to an author line that
754/// does not match the author pattern.
755fn condense_whitespace(s: &str) -> String {
756    let mut result = String::with_capacity(s.len());
757    let mut prev_was_space = false;
758
759    for c in s.chars() {
760        if c == ' ' {
761            if !prev_was_space {
762                result.push(' ');
763            }
764            prev_was_space = true;
765        } else {
766            result.push(c);
767            prev_was_space = false;
768        }
769    }
770
771    result
772}
773
774/// Matches a single HTML tag, mirroring Asciidoctor's `XmlSanitizeRx`.
775static XML_TAG: LazyLock<Regex> = LazyLock::new(|| {
776    #[allow(clippy::unwrap_used)]
777    Regex::new(r"<[^>]+>").unwrap()
778});
779
780static AUTHOR: LazyLock<Regex> = LazyLock::new(|| {
781    #[allow(clippy::unwrap_used)]
782    Regex::new(
783        r#"(?x)
784            ^
785
786            # Group 1: First name (required)
787            ([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*)
788
789            # Group 2: Middle name (optional)
790            (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
791
792            # Group 3: Last name (optional)
793            (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
794
795            # Group 4: Email address (optional)
796            (?:\ +<([^>]+)>)?
797
798            $
799        "#,
800    )
801    .unwrap()
802});
803
804/// Splits a names-only author value into its name portion (group 1) and a
805/// trailing `<email>` (group 2). The name must contain at least one
806/// non-whitespace character and is followed by whitespace before the bracketed
807/// email, matching the email group of [`AUTHOR`] for a value that otherwise
808/// fails the full pattern (e.g. a name with four or more parts).
809static NAMES_ONLY_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
810    #[allow(clippy::unwrap_used)]
811    Regex::new(r"^(.*\S)\s+<([^>]+)>$").unwrap()
812});
813
814/// Returns whether `source` matches the author pattern – at most three
815/// space-separated names with an optional trailing `<email>`.
816///
817/// The `:author:` attribute-entry path uses this to tell whether a plain-name
818/// value was partitioned by the fallback whitespace split (a name with four or
819/// more parts, or one containing punctuation such as a comma) rather than by
820/// the pattern. Only in the fallback case is the stored `author` value replaced
821/// with the reconstructed, whitespace-condensed name (issue #758).
822pub(crate) fn matches_author_pattern(source: &str) -> bool {
823    AUTHOR.is_match(source.trim())
824}
825
826/// Apply the header substitution group to an author-line fragment: special
827/// characters first, then attribute references – matching Asciidoctor's
828/// `apply_header_subs` (`HEADER_SUBS = [:specialcharacters, :attributes]`).
829///
830/// Running special characters *before* attribute references escapes any literal
831/// `<`, `>`, or `&` in the fragment while leaving the expanded value of an
832/// attribute reference untouched, so an attribute whose value already contains
833/// markup (or a character reference) is inserted verbatim rather than escaped a
834/// second time – exactly as Asciidoctor's header subs behave. Numeric character
835/// references in the literal text are preserved (see
836/// [`apply_author_special_characters`]).
837fn apply_author_subs(source: &str, parser: &Parser) -> String {
838    use crate::content::SubstitutionStep;
839
840    let with_special_characters = apply_author_special_characters(source, parser);
841
842    let span = Span::new(&with_special_characters);
843    let mut content = Content::from(span);
844
845    SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
846
847    content.rendered().to_string()
848}
849
850/// Resolve attribute references in `source` *without* applying the special-
851/// characters substitution.
852///
853/// This yields the raw form of an author fragment – attribute references
854/// expanded, but any literal `<`, `>`, or `&` left as written – which populates
855/// the [`Author`] `raw_*` fields. It is the attribute-references half of
856/// [`apply_author_subs`], mirroring the value Asciidoctor keeps in its
857/// internal, pre-substitution `metadata` hash.
858///
859/// The rendered value is always resolved from the same source first (via
860/// [`apply_author_subs`]), which records any `attribute-missing` warning, so
861/// this second, raw-value pass discards the warnings it would otherwise
862/// duplicate.
863fn resolve_attribute_references(source: &str, parser: &Parser) -> String {
864    use crate::content::SubstitutionStep;
865
866    let warnings_before = parser.substitution_warnings_len();
867
868    let span = Span::new(source);
869    let mut content = Content::from(span);
870
871    SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
872
873    parser.truncate_substitution_warnings(warnings_before);
874
875    content.rendered().to_string()
876}
877
878/// Apply the special-characters substitution to `source`, escaping every
879/// literal `<`, `>`, and `&` – except the leading `&` of a numeric HTML
880/// character reference, which is left intact so a name such as `AsciiDoc&#174;`
881/// keeps its ® entity rather than degrading to `AsciiDoc&amp;#174;` (issue
882/// #757).
883fn apply_author_special_characters(source: &str, parser: &Parser) -> String {
884    let mut result = String::with_capacity(source.len());
885    let mut last = 0;
886
887    // Escape the text between the character references, emitting each reference
888    // verbatim so its `&` is never doubled.
889    for m in NUMERIC_CHARACTER_REFERENCE.find_iter(source) {
890        result.push_str(&escape_special_characters(&source[last..m.start()], parser));
891        result.push_str(m.as_str());
892        last = m.end();
893    }
894
895    result.push_str(&escape_special_characters(&source[last..], parser));
896    result
897}
898
899/// Run the special-characters substitution step over `source`, escaping `<`,
900/// `>`, and `&`.
901fn escape_special_characters(source: &str, parser: &Parser) -> String {
902    if source.is_empty() {
903        return String::new();
904    }
905
906    let span = Span::new(source);
907    let mut content = Content::from(span);
908
909    crate::content::SubstitutionStep::SpecialCharacters.apply(&mut content, parser, None);
910
911    content.rendered().to_string()
912}
913
914/// Matches a numeric HTML character reference – decimal (`&#174;`) or
915/// hexadecimal (`&#xAE;`). Mirrors the guard the author line uses when deciding
916/// whether a semicolon separates two authors (see
917/// [`AuthorLine`](crate::document::AuthorLine)); the leading `&` of such a
918/// reference is preserved rather than escaped when header subs are applied.
919static NUMERIC_CHARACTER_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
920    #[allow(clippy::unwrap_used)]
921    Regex::new(r"&#(?:[0-9]+|[xX][0-9a-fA-F]+);").unwrap()
922});
923
924#[cfg(test)]
925mod tests {
926    #![allow(clippy::unwrap_used)]
927
928    use super::Author;
929
930    // The `raw_*` accessors return the author value with attribute references
931    // resolved but the header substitution group's special-characters escaping
932    // *not* applied – the value Asciidoctor keeps in its internal `metadata`
933    // hash. The rendered accessors are unaffected and keep escaping literal `<`,
934    // `>`, and `&` to match `doc.author`/`{author}`.
935    mod raw_accessors {
936        use crate::{Parser, document::Author};
937
938        // Parse `src` and return its single, first author.
939        fn only_author(src: &str) -> Author {
940            let mut parser = Parser::default();
941            let doc = parser.parse(src);
942            doc.authors().first().cloned().unwrap()
943        }
944
945        #[test]
946        fn plain_name_has_no_special_characters_so_raw_equals_rendered() {
947            let a = only_author("= Doc\nKismet R. Lee <kismet@asciidoctor.org>\n\nbody\n");
948
949            assert_eq!(a.name(), "Kismet R. Lee");
950            assert_eq!(a.raw_name(), "Kismet R. Lee");
951            assert_eq!(a.firstname(), "Kismet");
952            assert_eq!(a.raw_firstname(), "Kismet");
953            assert_eq!(a.middlename(), Some("R."));
954            assert_eq!(a.raw_middlename(), Some("R."));
955            assert_eq!(a.lastname(), Some("Lee"));
956            assert_eq!(a.raw_lastname(), Some("Lee"));
957            assert_eq!(a.email(), Some("kismet@asciidoctor.org"));
958            assert_eq!(a.raw_email(), Some("kismet@asciidoctor.org"));
959        }
960
961        #[test]
962        fn implicit_line_with_literal_angle_brackets_keeps_them_raw() {
963            // A line that does not match the author pattern (here because of the
964            // comma) becomes a single name. The rendered value escapes the literal
965            // angle brackets – matching Asciidoctor's `doc.author` – while the raw
966            // value keeps them as written.
967            let a = only_author(
968                "= Doc\nStuart Rackham, founder of AsciiDoc <founder@asciidoc.org>\n\nbody\n",
969            );
970
971            assert_eq!(
972                a.name(),
973                "Stuart Rackham, founder of AsciiDoc &lt;founder@asciidoc.org&gt;"
974            );
975
976            assert_eq!(
977                a.raw_name(),
978                "Stuart Rackham, founder of AsciiDoc <founder@asciidoc.org>"
979            );
980
981            assert_eq!(
982                a.raw_firstname(),
983                "Stuart Rackham, founder of AsciiDoc <founder@asciidoc.org>"
984            );
985        }
986
987        #[test]
988        fn literal_ampersand_is_escaped_only_in_the_rendered_value() {
989            // A downstream converter that HTML-escapes the raw name gets a single
990            // level of escaping rather than the double-escaped `&amp;amp;` it would
991            // get from re-escaping the already-escaped rendered value.
992            let a = only_author("= Doc\nBen & Jerry\n\nbody\n");
993
994            assert_eq!(a.name(), "Ben &amp; Jerry");
995            assert_eq!(a.raw_name(), "Ben & Jerry");
996            assert_eq!(a.middlename(), Some("&amp;"));
997            assert_eq!(a.raw_middlename(), Some("&"));
998        }
999
1000        #[test]
1001        fn four_part_name_with_email_keeps_brackets_raw() {
1002            let a = only_author("= Doc\nFour Names Not Supported <doc@example.com>\n\nbody\n");
1003
1004            assert_eq!(a.name(), "Four Names Not Supported &lt;doc@example.com&gt;");
1005            assert_eq!(a.raw_name(), "Four Names Not Supported <doc@example.com>");
1006        }
1007
1008        #[test]
1009        fn attribute_reference_resolves_then_stays_a_single_raw_name() {
1010            // The literal `<`/`>` around the referenced email keep the expanded
1011            // value from matching the author pattern, so it is stored as a single
1012            // name. The rendered value escapes the brackets; the raw value resolves
1013            // the references but leaves the brackets literal, and – importantly –
1014            // keeps the same single-name structure as the rendered value rather
1015            // than re-splitting into name parts.
1016            let src = concat!(
1017                ":first-name: Jane\n",
1018                ":last-name: Smith\n",
1019                ":author-email: jane@example.com\n",
1020                "= Doc\n",
1021                "{first-name} {last-name} <{author-email}>\n\n",
1022                "body\n",
1023            );
1024
1025            let a = only_author(src);
1026
1027            assert_eq!(a.name(), "Jane Smith &lt;jane@example.com&gt;");
1028            assert_eq!(a.raw_name(), "Jane Smith <jane@example.com>");
1029            assert_eq!(a.middlename(), None);
1030            assert_eq!(a.raw_middlename(), None);
1031            assert_eq!(a.lastname(), None);
1032            assert_eq!(a.raw_lastname(), None);
1033        }
1034
1035        #[test]
1036        fn unresolved_attribute_reference_warns_only_once() {
1037            // The raw pass resolves the same references as the rendered pass, so
1038            // an author line with an unresolved reference must not record the
1039            // `attribute-missing` warning twice.
1040            let mut parser = Parser::default();
1041            let doc =
1042                parser.parse(":attribute-missing: warn\n= Doc\nJane {undefined} Smith\n\nbody\n");
1043
1044            assert_eq!(doc.warnings().count(), 1);
1045        }
1046
1047        #[test]
1048        fn names_only_email_split_stays_aligned_between_raw_and_rendered() {
1049            // A four-part `:author:` entry that wraps an attribute reference in
1050            // literal brackets fails the author pattern, so it is partitioned. The
1051            // rendered value escapes the brackets and keeps no email; the raw
1052            // value must follow that decision rather than splitting the still-
1053            // literal `<…>` off as an email, so the two stay structurally aligned.
1054            let a = only_author(":mail: someone@example.com\n:author: A B C D <{mail}>\n\nbody\n");
1055
1056            assert_eq!(a.name(), "A B C D &lt;someone@example.com&gt;");
1057            assert_eq!(a.raw_name(), "A B C D <someone@example.com>");
1058            assert_eq!(a.email(), None);
1059            assert_eq!(a.raw_email(), None);
1060            assert_eq!(a.lastname(), Some("C D &lt;someone@example.com&gt;"));
1061            assert_eq!(a.raw_lastname(), Some("C D <someone@example.com>"));
1062        }
1063
1064        #[test]
1065        fn names_only_email_from_an_attribute_still_splits_in_both() {
1066            // When the trailing `<email>` survives into the rendered expansion
1067            // unescaped (here from an intrinsic attribute value, so the bracket is
1068            // literal in *both* the rendered and raw expansions), the email is
1069            // split off in both – the alignment fix only suppresses a raw split
1070            // the rendered value did not also make.
1071            let mut parser = Parser::default().with_intrinsic_attribute(
1072                "tail",
1073                "Jr. <boss@example.com>",
1074                crate::parser::ModificationContext::Anywhere,
1075            );
1076
1077            let doc = parser.parse("= Doc\n:author: A B C {tail}\n\nbody\n");
1078            let a = doc.authors().first().cloned().unwrap();
1079
1080            assert_eq!(a.email(), Some("boss@example.com"));
1081            assert_eq!(a.raw_email(), Some("boss@example.com"));
1082        }
1083    }
1084
1085    // The `<`-branch of Asciidoctor's `process_authors` (`names_only`), reached
1086    // when an `:author:` attribute value's substitution produced inline HTML.
1087    // The rendered markup – with name-joiner underscores turned to spaces –
1088    // becomes `name`, while the name parts are partitioned from the tag-stripped
1089    // text so the formatting does not leak into them. No email is split off.
1090    mod parse_substituted_names_only {
1091        use super::Author;
1092
1093        #[test]
1094        fn empty_input_is_none() {
1095            assert!(Author::parse_substituted_names_only("").is_none());
1096            assert!(Author::parse_substituted_names_only("   ").is_none());
1097        }
1098
1099        #[test]
1100        fn markup_with_no_text_keeps_rendered_value_as_single_name() {
1101            // Stripping the tags leaves nothing to partition, so the rendered
1102            // markup stands as the whole name and `firstname`.
1103            let author = Author::parse_substituted_names_only("<a href=\"x\"></a>").unwrap();
1104            assert_eq!(author.name(), "<a href=\"x\"></a>");
1105            assert_eq!(author.firstname(), "<a href=\"x\"></a>");
1106            assert_eq!(author.middlename(), None);
1107            assert_eq!(author.lastname(), None);
1108            assert_eq!(author.email(), None);
1109        }
1110
1111        #[test]
1112        fn single_name_part() {
1113            let author = Author::parse_substituted_names_only("<strong>Solo</strong>").unwrap();
1114            assert_eq!(author.name(), "<strong>Solo</strong>");
1115            assert_eq!(author.firstname(), "Solo");
1116            assert_eq!(author.middlename(), None);
1117            assert_eq!(author.lastname(), None);
1118        }
1119
1120        #[test]
1121        fn first_and_last_name() {
1122            let author = Author::parse_substituted_names_only("<em>Kismet</em> Chameleon").unwrap();
1123            assert_eq!(author.firstname(), "Kismet");
1124            assert_eq!(author.middlename(), None);
1125            assert_eq!(author.lastname(), Some("Chameleon"));
1126        }
1127
1128        #[test]
1129        fn first_middle_and_last_name() {
1130            let author =
1131                Author::parse_substituted_names_only("<em>Kismet</em> R. Chameleon").unwrap();
1132            assert_eq!(author.firstname(), "Kismet");
1133            assert_eq!(author.middlename(), Some("R."));
1134            assert_eq!(author.lastname(), Some("Chameleon"));
1135        }
1136
1137        #[test]
1138        fn underscores_join_name_parts_and_the_rendered_name() {
1139            // Underscores act as name joiners: within a part they become a
1140            // space, and the full `name` has them replaced too.
1141            let author = Author::parse_substituted_names_only("<b>Ze_Project</b> team").unwrap();
1142            assert_eq!(author.name(), "<b>Ze Project</b> team");
1143            assert_eq!(author.firstname(), "Ze Project");
1144            assert_eq!(author.lastname(), Some("team"));
1145            assert_eq!(author.email(), None);
1146        }
1147    }
1148
1149    // The entry dispatcher routes a *whole-value* `pass:[…]` macro to its
1150    // substituted value (the resolved content) and every other value to the
1151    // raw-value partitioning, so the literal macro syntax never leaks into the
1152    // name parts.
1153    mod parse_from_entry {
1154        use super::Author;
1155        use crate::Parser;
1156
1157        #[test]
1158        fn pass_macro_with_markup_is_partitioned_from_the_substituted_value() {
1159            // The raw is the macro syntax; the substituted value is its rendered
1160            // output, which is what gets partitioned (tags stripped).
1161            let parser = Parser::default();
1162            let author = Author::parse_from_entry(
1163                "pass:n[https://example.org/x[Ze *team*]]",
1164                Some("<a href=\"https://example.org/x\">Ze <strong>team</strong></a>"),
1165                &parser,
1166            )
1167            .unwrap();
1168
1169            assert_eq!(author.firstname(), "Ze");
1170            assert_eq!(author.lastname(), Some("team"));
1171        }
1172
1173        #[test]
1174        fn pass_macro_resolving_to_plain_text_uses_the_substituted_value() {
1175            // A pass macro whose result has no markup must still be partitioned
1176            // from the substituted value, never the raw `pass:…[…]` syntax.
1177            let parser = Parser::default();
1178            let author =
1179                Author::parse_from_entry("pass:n[Doc Writer]", Some("Doc Writer"), &parser)
1180                    .unwrap();
1181
1182            assert_eq!(author.name(), "Doc Writer");
1183            assert_eq!(author.firstname(), "Doc");
1184            assert_eq!(author.lastname(), Some("Writer"));
1185        }
1186
1187        #[test]
1188        fn non_pass_value_uses_raw_partitioning() {
1189            // A value that is not a whole-value pass macro is partitioned from
1190            // the raw value as before (the substituted value is not consulted).
1191            let parser = Parser::default();
1192            let author =
1193                Author::parse_from_entry("Doc Writer", Some("Doc Writer"), &parser).unwrap();
1194
1195            assert_eq!(author.firstname(), "Doc");
1196            assert_eq!(author.lastname(), Some("Writer"));
1197        }
1198
1199        #[test]
1200        fn empty_pass_macro_yields_no_author() {
1201            // `pass:[]` resolves to an empty value, which describes no author.
1202            let parser = Parser::default();
1203            assert!(Author::parse_from_entry("pass:[]", Some(""), &parser).is_none());
1204        }
1205    }
1206}