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 the value does not match the author
149                // pattern, so it becomes a single name. `apply_author_subs`
150                // already applied the header substitution group – special
151                // characters (escaping any literal `<`, `>`, or `&`) followed by
152                // attribute references – so the expanded value is stored as is,
153                // mirroring Asciidoctor's `apply_header_subs`.
154                let name_with_spaces = replace_underscores_with_spaces(expanded_source);
155                Some(Self {
156                    name: name_with_spaces.clone(),
157                    firstname: name_with_spaces,
158                    middlename: None,
159                    lastname: None,
160                    email: None,
161                })
162            }
163        } else if names_only {
164            // Input comes from an attribute entry (e.g. `:author:`) and does not
165            // match the author pattern – typically a name with four or more parts
166            // or one containing punctuation such as a comma. Asciidoctor still
167            // partitions it by splitting on whitespace into at most three parts,
168            // assigning any trailing parts to `lastname`.
169            Some(partition_names_only(source))
170        } else {
171            // Input doesn't contain attributes and doesn't match the author
172            // pattern. Asciidoctor stores the whole line as the author,
173            // condensing interior whitespace. Underscores are left literal here:
174            // Asciidoctor only converts underscore-joined names while
175            // partitioning a *matching* line, not in this fallback.
176            //
177            // The header substitution group still applies, so any literal `<`,
178            // `>`, or `&` is escaped consistently – matching Asciidoctor's
179            // `apply_header_subs` and the attribute-reference path above, rather
180            // than returning the name raw only because it lacks an attribute
181            // reference.
182            let name = apply_author_special_characters(&condense_whitespace(source), parser);
183            Some(Self {
184                name: name.clone(),
185                firstname: name,
186                middlename: None,
187                lastname: None,
188                email: None,
189            })
190        }
191    }
192
193    /// Parse the single author described by an `:author:` attribute entry.
194    ///
195    /// `raw` is the entry's raw (pre-substitution) value and `substituted` is
196    /// its substituted value (the stored attribute value). When the entry is a
197    /// whole-value `pass:[…]` macro its substituted value is the resolved
198    /// content, so that value is partitioned – with any generated markup
199    /// stripped – rather than the raw macro syntax (see
200    /// [`parse_substituted_names_only`]). Every other value is partitioned from
201    /// the raw value as before, so plain names, attribute references, and
202    /// inline emails are unaffected.
203    ///
204    /// [`parse_substituted_names_only`]: Self::parse_substituted_names_only
205    pub(crate) fn parse_from_entry(
206        raw: &str,
207        substituted: Option<&str>,
208        parser: &Parser,
209    ) -> Option<Self> {
210        if crate::document::is_attribute_entry_pass_macro(raw) {
211            substituted.and_then(Self::parse_substituted_names_only)
212        } else {
213            Self::parse(raw, parser, true)
214        }
215    }
216
217    /// Parse a single author from a value that has *already* been through
218    /// attribute-value substitution and now carries generated inline HTML –
219    /// typically the rendered output of a `pass:[…]` macro in an `:author:`
220    /// entry.
221    ///
222    /// This mirrors the `<`-branch of Asciidoctor's `process_authors` under
223    /// `names_only`: the full rendered value – with name-joiner underscores
224    /// turned to spaces – becomes the author's `name`, while the name parts are
225    /// partitioned from the value with its HTML tags removed, so the formatting
226    /// does not leak into `firstname`/`middlename`/`lastname`. As in
227    /// Asciidoctor, no email is split off here – an email supplied through a
228    /// companion `:email:` entry is attached later.
229    pub(crate) fn parse_substituted_names_only(substituted: &str) -> Option<Self> {
230        let substituted = substituted.trim();
231        if substituted.is_empty() {
232            return None;
233        }
234
235        let name = replace_underscores_with_spaces(substituted.to_string());
236        let stripped = strip_xml_tags(substituted);
237
238        let mut segments = split_whitespace_max3(&stripped);
239
240        if segments.is_empty() {
241            // The value was nothing but markup: keep the rendered value as the
242            // single name.
243            return Some(Self {
244                firstname: name.clone(),
245                name,
246                middlename: None,
247                lastname: None,
248                email: None,
249            });
250        }
251
252        let firstname = replace_underscores_with_spaces(segments.remove(0));
253        let (middlename, lastname) = match segments.len() {
254            0 => (None, None),
255
256            1 => (
257                None,
258                Some(replace_underscores_with_spaces(segments.remove(0))),
259            ),
260
261            _ => (
262                Some(replace_underscores_with_spaces(segments.remove(0))),
263                Some(replace_underscores_with_spaces(segments.remove(0))),
264            ),
265        };
266
267        Some(Self {
268            name,
269            firstname,
270            middlename,
271            lastname,
272            email: None,
273        })
274    }
275
276    /// Overrides the author's email address, unless `email` is `None`.
277    ///
278    /// Used when an author is assembled from `author_N` document attributes,
279    /// where the name and the companion `email_N` attribute are parsed
280    /// separately.
281    pub(crate) fn with_email(mut self, email: Option<String>) -> Self {
282        if let Some(email) = email {
283            self.email = Some(email);
284        }
285
286        self
287    }
288
289    /// Returns the full name of the author.
290    ///
291    /// The name includes the entire author declaration except for email.
292    pub fn name(&self) -> &str {
293        &self.name
294    }
295
296    /// Returns the first, forename, or given name of the author.
297    ///
298    /// The first space-separated name in the value of the `author` attribute is
299    /// automatically assigned to `firstname`.
300    pub fn firstname(&self) -> &str {
301        &self.firstname
302    }
303
304    /// Returns the middle name or initial of the author.
305    ///
306    /// If author contains three space-separated names, the second name is
307    /// assigned to the `middlename` attribute.
308    pub fn middlename(&self) -> Option<&str> {
309        self.middlename.as_deref()
310    }
311
312    /// Returns the last, surname, or family name of the author.
313    ///
314    /// If the author name contains two or three space-separated names, the last
315    /// of those names is assigned to the `lastname` attribute.
316    pub fn lastname(&self) -> Option<&str> {
317        self.lastname.as_deref()
318    }
319
320    /// Returns the email address or URL associated with the author.
321    ///
322    /// When assigned via the author line, it’s enclosed in a pair of angle
323    /// brackets (`< >`). A URL can be used in place of the email address.
324    pub fn email(&self) -> Option<&str> {
325        self.email.as_deref()
326    }
327
328    /// Returns the initials of the author.
329    ///
330    /// The first character of the `firstname`, `middlename`, and `lastname`
331    /// attribute values are assigned to the `authorinitials` attribute. The
332    /// value of the `authorinitials` attribute will consist of three characters
333    /// or less depending on how many parts are in the author’s name.
334    pub fn initials(&self) -> String {
335        format!(
336            "{first}{middle}{last}",
337            first = first_char_or_empty_string(&self.firstname),
338            middle = opt_first_char_or_empty_string(self.middlename.as_deref()),
339            last = opt_first_char_or_empty_string(self.lastname.as_deref()),
340        )
341    }
342}
343
344/// Populates the derived author document attributes from a resolved author
345/// list, mirroring Asciidoctor's `process_authors`.
346///
347/// The first author sets the unsuffixed keys (`author`, `firstname`,
348/// `authorinitials`, …); each subsequent author sets its `_N` companions. Once
349/// a second author appears, the first author is also mirrored onto its `_1`
350/// companions. The `authors` attribute is rewritten to the comma-joined list of
351/// resolved author names.
352///
353/// This intentionally does **not** honor `authorinitials_from_entry`: the
354/// derived `authorinitials` always overwrites an explicit `:authorinitials:`
355/// entry for the `authors` and `author_N` forms. Only a single `:author:` entry
356/// (handled inline in [`Header::parse`](crate::document::Header)) preserves an
357/// explicit override, exactly as Asciidoctor does – its `authorinitials`
358/// deletion guard lives only in the `author` branch of `process_authors`, not
359/// the `authors`/indexed branches.
360pub(crate) fn set_author_metadata(parser: &mut Parser, authors: &[Author]) {
361    for (idx, author) in authors.iter().enumerate() {
362        set_author_keys(parser, author, if idx == 0 { None } else { Some(idx + 1) });
363
364        // The `_1` companions are only assigned once a second author is seen.
365        if idx == 1
366            && let Some(first) = authors.first()
367        {
368            set_author_keys(parser, first, Some(1));
369        }
370    }
371
372    let joined = authors
373        .iter()
374        .map(Author::name)
375        .collect::<Vec<_>>()
376        .join(", ");
377
378    parser.set_attribute_by_value_from_header("authors", joined);
379}
380
381/// Sets the author attributes for a single author, either as the unsuffixed
382/// keys (`index` is `None`) or the `_N` companions (`index` is `Some(n)`).
383fn set_author_keys(parser: &mut Parser, author: &Author, index: Option<usize>) {
384    let key = |name: &str| match index {
385        None => name.to_string(),
386        Some(n) => format!("{name}_{n}"),
387    };
388
389    parser.set_attribute_by_value_from_header(key("author"), author.name());
390
391    parser.set_attribute_by_value_from_header(key("firstname"), author.firstname());
392
393    if let Some(middlename) = author.middlename() {
394        parser.set_attribute_by_value_from_header(key("middlename"), middlename);
395    }
396
397    if let Some(lastname) = author.lastname() {
398        parser.set_attribute_by_value_from_header(key("lastname"), lastname);
399    }
400
401    parser.set_attribute_by_value_from_header(key("authorinitials"), author.initials());
402
403    if let Some(email) = author.email() {
404        parser.set_attribute_by_value_from_header(key("email"), email);
405    }
406}
407
408fn first_char_or_empty_string(s: &str) -> String {
409    s.chars().next().map_or(String::new(), |c| c.to_string())
410}
411
412fn opt_first_char_or_empty_string(s: Option<&str>) -> String {
413    s.map(first_char_or_empty_string).unwrap_or_default()
414}
415
416/// Replace underscores with spaces in a name component.
417fn replace_underscores_with_spaces(name: String) -> String {
418    name.replace('_', " ")
419}
420
421/// Remove HTML tags from `source`, mirroring Asciidoctor's `XmlSanitizeRx`
422/// (`/<[^>]+>/`). Used to partition an author value whose substitution produced
423/// inline markup (see [`Author::parse_substituted_names_only`]).
424fn strip_xml_tags(source: &str) -> String {
425    XML_TAG.replace_all(source, "").into_owned()
426}
427
428/// Join an author's parsed name parts with a single space.
429///
430/// Asciidoctor reconstructs the full name from its partitioned parts, which
431/// condenses any interior whitespace that appeared between the names in the
432/// source down to a single space.
433fn join_name_parts(firstname: &str, middlename: Option<&str>, lastname: Option<&str>) -> String {
434    let mut name = String::from(firstname);
435
436    if let Some(middlename) = middlename {
437        name.push(' ');
438        name.push_str(middlename);
439    }
440
441    if let Some(lastname) = lastname {
442        name.push(' ');
443        name.push_str(lastname);
444    }
445
446    name
447}
448
449/// Partition an author value that does not match the [`AUTHOR`] pattern using
450/// Asciidoctor's `names_only` rules (the path taken for an attribute-entry
451/// value such as `:author:`).
452///
453/// A trailing `<email>` (or URL) is first split off so it is not absorbed into
454/// the name – mirroring the email group of the author pattern and Asciidoctor's
455/// XML sanitization of a names-only value. The remaining name is then split on
456/// whitespace into at most three segments (Ruby's `String#split(nil, 3)`, which
457/// also drops leading whitespace). The trailing segment retains its interior
458/// text – so a four-plus-part name keeps its later parts in `lastname` – but
459/// has repeating spaces condensed to a single space. Each segment then has
460/// underscore joiners replaced with spaces.
461///
462/// `source` may already have attribute references substituted (the expanded
463/// value of a reference such as `{full-name}`); in that case any email it
464/// carries is likewise already substituted.
465fn partition_names_only(source: &str) -> Author {
466    let source = source.trim();
467
468    let (name_source, email) = match NAMES_ONLY_EMAIL.captures(source) {
469        Some(captures) => (
470            captures.get(1).map_or(source, |m| m.as_str()),
471            Some(captures[2].to_string()),
472        ),
473        None => (source, None),
474    };
475
476    let mut segments = split_whitespace_max3(name_source);
477
478    let firstname = replace_underscores_with_spaces(segments.remove(0));
479    let (middlename, lastname) = match segments.len() {
480        0 => (None, None),
481        1 => (
482            None,
483            Some(replace_underscores_with_spaces(segments.remove(0))),
484        ),
485        _ => (
486            Some(replace_underscores_with_spaces(segments.remove(0))),
487            Some(replace_underscores_with_spaces(segments.remove(0))),
488        ),
489    };
490
491    let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
492
493    Author {
494        name,
495        firstname,
496        middlename,
497        lastname,
498        email,
499    }
500}
501
502/// Split `source` on runs of whitespace into at most three segments, mirroring
503/// Ruby's `String#split(nil, 3)`. Leading whitespace is dropped and the first
504/// two whitespace runs delimit the first two segments; the remainder becomes
505/// the third segment, with its repeating spaces condensed to a single space
506/// (Ruby's `String#squeeze ' '`).
507///
508/// Only ASCII whitespace is treated as a delimiter, matching Ruby's split
509/// (which does not break on non-breaking or other Unicode spaces), so a name
510/// joined by such a space stays a single segment. The returned vector always
511/// has at least one element because the caller has already rejected empty
512/// input.
513fn split_whitespace_max3(source: &str) -> Vec<String> {
514    let is_ascii_ws = |c: char| c.is_ascii_whitespace();
515
516    let mut segments: Vec<String> = Vec::with_capacity(3);
517    let mut rest = source;
518
519    for _ in 0..2 {
520        rest = rest.trim_start_matches(is_ascii_ws);
521        match rest.find(is_ascii_ws) {
522            Some(index) => {
523                segments.push(rest[..index].to_string());
524                rest = &rest[index..];
525            }
526            None => break,
527        }
528    }
529
530    rest = rest.trim_start_matches(is_ascii_ws);
531    if !rest.is_empty() {
532        segments.push(condense_whitespace(rest));
533    }
534
535    segments
536}
537
538/// Condense runs of spaces into a single space, mirroring Ruby's
539/// `String#tr_s(' ', ' ')`, which Asciidoctor applies to an author line that
540/// does not match the author pattern.
541fn condense_whitespace(s: &str) -> String {
542    let mut result = String::with_capacity(s.len());
543    let mut prev_was_space = false;
544
545    for c in s.chars() {
546        if c == ' ' {
547            if !prev_was_space {
548                result.push(' ');
549            }
550            prev_was_space = true;
551        } else {
552            result.push(c);
553            prev_was_space = false;
554        }
555    }
556
557    result
558}
559
560/// Matches a single HTML tag, mirroring Asciidoctor's `XmlSanitizeRx`.
561static XML_TAG: LazyLock<Regex> = LazyLock::new(|| {
562    #[allow(clippy::unwrap_used)]
563    Regex::new(r"<[^>]+>").unwrap()
564});
565
566static AUTHOR: LazyLock<Regex> = LazyLock::new(|| {
567    #[allow(clippy::unwrap_used)]
568    Regex::new(
569        r#"(?x)
570            ^
571
572            # Group 1: First name (required)
573            ([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*)
574
575            # Group 2: Middle name (optional)
576            (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
577
578            # Group 3: Last name (optional)
579            (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
580
581            # Group 4: Email address (optional)
582            (?:\ +<([^>]+)>)?
583
584            $
585        "#,
586    )
587    .unwrap()
588});
589
590/// Splits a names-only author value into its name portion (group 1) and a
591/// trailing `<email>` (group 2). The name must contain at least one
592/// non-whitespace character and is followed by whitespace before the bracketed
593/// email, matching the email group of [`AUTHOR`] for a value that otherwise
594/// fails the full pattern (e.g. a name with four or more parts).
595static NAMES_ONLY_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
596    #[allow(clippy::unwrap_used)]
597    Regex::new(r"^(.*\S)\s+<([^>]+)>$").unwrap()
598});
599
600/// Returns whether `source` matches the author pattern – at most three
601/// space-separated names with an optional trailing `<email>`.
602///
603/// The `:author:` attribute-entry path uses this to tell whether a plain-name
604/// value was partitioned by the fallback whitespace split (a name with four or
605/// more parts, or one containing punctuation such as a comma) rather than by
606/// the pattern. Only in the fallback case is the stored `author` value replaced
607/// with the reconstructed, whitespace-condensed name (issue #758).
608pub(crate) fn matches_author_pattern(source: &str) -> bool {
609    AUTHOR.is_match(source.trim())
610}
611
612/// Apply the header substitution group to an author-line fragment: special
613/// characters first, then attribute references – matching Asciidoctor's
614/// `apply_header_subs` (`HEADER_SUBS = [:specialcharacters, :attributes]`).
615///
616/// Running special characters *before* attribute references escapes any literal
617/// `<`, `>`, or `&` in the fragment while leaving the expanded value of an
618/// attribute reference untouched, so an attribute whose value already contains
619/// markup (or a character reference) is inserted verbatim rather than escaped a
620/// second time – exactly as Asciidoctor's header subs behave. Numeric character
621/// references in the literal text are preserved (see
622/// [`apply_author_special_characters`]).
623fn apply_author_subs(source: &str, parser: &Parser) -> String {
624    use crate::content::SubstitutionStep;
625
626    let with_special_characters = apply_author_special_characters(source, parser);
627
628    let span = Span::new(&with_special_characters);
629    let mut content = Content::from(span);
630
631    SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
632
633    content.rendered().to_string()
634}
635
636/// Apply the special-characters substitution to `source`, escaping every
637/// literal `<`, `>`, and `&` – except the leading `&` of a numeric HTML
638/// character reference, which is left intact so a name such as `AsciiDoc&#174;`
639/// keeps its ® entity rather than degrading to `AsciiDoc&amp;#174;` (issue
640/// #757).
641fn apply_author_special_characters(source: &str, parser: &Parser) -> String {
642    let mut result = String::with_capacity(source.len());
643    let mut last = 0;
644
645    // Escape the text between the character references, emitting each reference
646    // verbatim so its `&` is never doubled.
647    for m in NUMERIC_CHARACTER_REFERENCE.find_iter(source) {
648        result.push_str(&escape_special_characters(&source[last..m.start()], parser));
649        result.push_str(m.as_str());
650        last = m.end();
651    }
652
653    result.push_str(&escape_special_characters(&source[last..], parser));
654    result
655}
656
657/// Run the special-characters substitution step over `source`, escaping `<`,
658/// `>`, and `&`.
659fn escape_special_characters(source: &str, parser: &Parser) -> String {
660    if source.is_empty() {
661        return String::new();
662    }
663
664    let span = Span::new(source);
665    let mut content = Content::from(span);
666
667    crate::content::SubstitutionStep::SpecialCharacters.apply(&mut content, parser, None);
668
669    content.rendered().to_string()
670}
671
672/// Matches a numeric HTML character reference – decimal (`&#174;`) or
673/// hexadecimal (`&#xAE;`). Mirrors the guard the author line uses when deciding
674/// whether a semicolon separates two authors (see
675/// [`AuthorLine`](crate::document::AuthorLine)); the leading `&` of such a
676/// reference is preserved rather than escaped when header subs are applied.
677static NUMERIC_CHARACTER_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
678    #[allow(clippy::unwrap_used)]
679    Regex::new(r"&#(?:[0-9]+|[xX][0-9a-fA-F]+);").unwrap()
680});
681
682#[cfg(test)]
683mod tests {
684    #![allow(clippy::unwrap_used)]
685
686    use super::Author;
687
688    // The `<`-branch of Asciidoctor's `process_authors` (`names_only`), reached
689    // when an `:author:` attribute value's substitution produced inline HTML.
690    // The rendered markup – with name-joiner underscores turned to spaces –
691    // becomes `name`, while the name parts are partitioned from the tag-stripped
692    // text so the formatting does not leak into them. No email is split off.
693    mod parse_substituted_names_only {
694        use super::Author;
695
696        #[test]
697        fn empty_input_is_none() {
698            assert!(Author::parse_substituted_names_only("").is_none());
699            assert!(Author::parse_substituted_names_only("   ").is_none());
700        }
701
702        #[test]
703        fn markup_with_no_text_keeps_rendered_value_as_single_name() {
704            // Stripping the tags leaves nothing to partition, so the rendered
705            // markup stands as the whole name and `firstname`.
706            let author = Author::parse_substituted_names_only("<a href=\"x\"></a>").unwrap();
707            assert_eq!(author.name(), "<a href=\"x\"></a>");
708            assert_eq!(author.firstname(), "<a href=\"x\"></a>");
709            assert_eq!(author.middlename(), None);
710            assert_eq!(author.lastname(), None);
711            assert_eq!(author.email(), None);
712        }
713
714        #[test]
715        fn single_name_part() {
716            let author = Author::parse_substituted_names_only("<strong>Solo</strong>").unwrap();
717            assert_eq!(author.name(), "<strong>Solo</strong>");
718            assert_eq!(author.firstname(), "Solo");
719            assert_eq!(author.middlename(), None);
720            assert_eq!(author.lastname(), None);
721        }
722
723        #[test]
724        fn first_and_last_name() {
725            let author = Author::parse_substituted_names_only("<em>Kismet</em> Chameleon").unwrap();
726            assert_eq!(author.firstname(), "Kismet");
727            assert_eq!(author.middlename(), None);
728            assert_eq!(author.lastname(), Some("Chameleon"));
729        }
730
731        #[test]
732        fn first_middle_and_last_name() {
733            let author =
734                Author::parse_substituted_names_only("<em>Kismet</em> R. Chameleon").unwrap();
735            assert_eq!(author.firstname(), "Kismet");
736            assert_eq!(author.middlename(), Some("R."));
737            assert_eq!(author.lastname(), Some("Chameleon"));
738        }
739
740        #[test]
741        fn underscores_join_name_parts_and_the_rendered_name() {
742            // Underscores act as name joiners: within a part they become a
743            // space, and the full `name` has them replaced too.
744            let author = Author::parse_substituted_names_only("<b>Ze_Project</b> team").unwrap();
745            assert_eq!(author.name(), "<b>Ze Project</b> team");
746            assert_eq!(author.firstname(), "Ze Project");
747            assert_eq!(author.lastname(), Some("team"));
748            assert_eq!(author.email(), None);
749        }
750    }
751
752    // The entry dispatcher routes a *whole-value* `pass:[…]` macro to its
753    // substituted value (the resolved content) and every other value to the
754    // raw-value partitioning, so the literal macro syntax never leaks into the
755    // name parts.
756    mod parse_from_entry {
757        use super::Author;
758        use crate::Parser;
759
760        #[test]
761        fn pass_macro_with_markup_is_partitioned_from_the_substituted_value() {
762            // The raw is the macro syntax; the substituted value is its rendered
763            // output, which is what gets partitioned (tags stripped).
764            let parser = Parser::default();
765            let author = Author::parse_from_entry(
766                "pass:n[https://example.org/x[Ze *team*]]",
767                Some("<a href=\"https://example.org/x\">Ze <strong>team</strong></a>"),
768                &parser,
769            )
770            .unwrap();
771
772            assert_eq!(author.firstname(), "Ze");
773            assert_eq!(author.lastname(), Some("team"));
774        }
775
776        #[test]
777        fn pass_macro_resolving_to_plain_text_uses_the_substituted_value() {
778            // A pass macro whose result has no markup must still be partitioned
779            // from the substituted value, never the raw `pass:…[…]` syntax.
780            let parser = Parser::default();
781            let author =
782                Author::parse_from_entry("pass:n[Doc Writer]", Some("Doc Writer"), &parser)
783                    .unwrap();
784
785            assert_eq!(author.name(), "Doc Writer");
786            assert_eq!(author.firstname(), "Doc");
787            assert_eq!(author.lastname(), Some("Writer"));
788        }
789
790        #[test]
791        fn non_pass_value_uses_raw_partitioning() {
792            // A value that is not a whole-value pass macro is partitioned from
793            // the raw value as before (the substituted value is not consulted).
794            let parser = Parser::default();
795            let author =
796                Author::parse_from_entry("Doc Writer", Some("Doc Writer"), &parser).unwrap();
797
798            assert_eq!(author.firstname(), "Doc");
799            assert_eq!(author.lastname(), Some("Writer"));
800        }
801
802        #[test]
803        fn empty_pass_macro_yields_no_author() {
804            // `pass:[]` resolves to an empty value, which describes no author.
805            let parser = Parser::default();
806            assert!(Author::parse_from_entry("pass:[]", Some(""), &parser).is_none());
807        }
808    }
809}