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    /// Overrides the author's email address, unless `email` is `None`.
196    ///
197    /// Used when an author is assembled from `author_N` document attributes,
198    /// where the name and the companion `email_N` attribute are parsed
199    /// separately.
200    pub(crate) fn with_email(mut self, email: Option<String>) -> Self {
201        if let Some(email) = email {
202            self.email = Some(email);
203        }
204
205        self
206    }
207
208    /// Returns the full name of the author.
209    ///
210    /// The name includes the entire author declaration except for email.
211    pub fn name(&self) -> &str {
212        &self.name
213    }
214
215    /// Returns the first, forename, or given name of the author.
216    ///
217    /// The first space-separated name in the value of the `author` attribute is
218    /// automatically assigned to `firstname`.
219    pub fn firstname(&self) -> &str {
220        &self.firstname
221    }
222
223    /// Returns the middle name or initial of the author.
224    ///
225    /// If author contains three space-separated names, the second name is
226    /// assigned to the `middlename` attribute.
227    pub fn middlename(&self) -> Option<&str> {
228        self.middlename.as_deref()
229    }
230
231    /// Returns the last, surname, or family name of the author.
232    ///
233    /// If the author name contains two or three space-separated names, the last
234    /// of those names is assigned to the `lastname` attribute.
235    pub fn lastname(&self) -> Option<&str> {
236        self.lastname.as_deref()
237    }
238
239    /// Returns the email address or URL associated with the author.
240    ///
241    /// When assigned via the author line, it’s enclosed in a pair of angle
242    /// brackets (`< >`). A URL can be used in place of the email address.
243    pub fn email(&self) -> Option<&str> {
244        self.email.as_deref()
245    }
246
247    /// Returns the initials of the author.
248    ///
249    /// The first character of the `firstname`, `middlename`, and `lastname`
250    /// attribute values are assigned to the `authorinitials` attribute. The
251    /// value of the `authorinitials` attribute will consist of three characters
252    /// or less depending on how many parts are in the author’s name.
253    pub fn initials(&self) -> String {
254        format!(
255            "{first}{middle}{last}",
256            first = first_char_or_empty_string(&self.firstname),
257            middle = opt_first_char_or_empty_string(self.middlename.as_deref()),
258            last = opt_first_char_or_empty_string(self.lastname.as_deref()),
259        )
260    }
261}
262
263fn first_char_or_empty_string(s: &str) -> String {
264    s.chars().next().map_or(String::new(), |c| c.to_string())
265}
266
267fn opt_first_char_or_empty_string(s: Option<&str>) -> String {
268    s.map(first_char_or_empty_string).unwrap_or_default()
269}
270
271/// Replace underscores with spaces in a name component.
272fn replace_underscores_with_spaces(name: String) -> String {
273    name.replace('_', " ")
274}
275
276/// Join an author's parsed name parts with a single space.
277///
278/// Asciidoctor reconstructs the full name from its partitioned parts, which
279/// condenses any interior whitespace that appeared between the names in the
280/// source down to a single space.
281fn join_name_parts(firstname: &str, middlename: Option<&str>, lastname: Option<&str>) -> String {
282    let mut name = String::from(firstname);
283
284    if let Some(middlename) = middlename {
285        name.push(' ');
286        name.push_str(middlename);
287    }
288
289    if let Some(lastname) = lastname {
290        name.push(' ');
291        name.push_str(lastname);
292    }
293
294    name
295}
296
297/// Partition an author value that does not match the [`AUTHOR`] pattern using
298/// Asciidoctor's `names_only` rules (the path taken for an attribute-entry
299/// value such as `:author:`).
300///
301/// A trailing `<email>` (or URL) is first split off so it is not absorbed into
302/// the name – mirroring the email group of the author pattern and Asciidoctor's
303/// XML sanitization of a names-only value. The remaining name is then split on
304/// whitespace into at most three segments (Ruby's `String#split(nil, 3)`, which
305/// also drops leading whitespace). The trailing segment retains its interior
306/// text – so a four-plus-part name keeps its later parts in `lastname` – but
307/// has repeating spaces condensed to a single space. Each segment then has
308/// underscore joiners replaced with spaces.
309///
310/// `source` may already have attribute references substituted (the expanded
311/// value of a reference such as `{full-name}`); in that case any email it
312/// carries is likewise already substituted.
313fn partition_names_only(source: &str) -> Author {
314    let source = source.trim();
315
316    let (name_source, email) = match NAMES_ONLY_EMAIL.captures(source) {
317        Some(captures) => (
318            captures.get(1).map_or(source, |m| m.as_str()),
319            Some(captures[2].to_string()),
320        ),
321        None => (source, None),
322    };
323
324    let mut segments = split_whitespace_max3(name_source);
325
326    let firstname = replace_underscores_with_spaces(segments.remove(0));
327    let (middlename, lastname) = match segments.len() {
328        0 => (None, None),
329        1 => (
330            None,
331            Some(replace_underscores_with_spaces(segments.remove(0))),
332        ),
333        _ => (
334            Some(replace_underscores_with_spaces(segments.remove(0))),
335            Some(replace_underscores_with_spaces(segments.remove(0))),
336        ),
337    };
338
339    let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
340
341    Author {
342        name,
343        firstname,
344        middlename,
345        lastname,
346        email,
347    }
348}
349
350/// Split `source` on runs of whitespace into at most three segments, mirroring
351/// Ruby's `String#split(nil, 3)`. Leading whitespace is dropped and the first
352/// two whitespace runs delimit the first two segments; the remainder becomes
353/// the third segment, with its repeating spaces condensed to a single space
354/// (Ruby's `String#squeeze ' '`).
355///
356/// Only ASCII whitespace is treated as a delimiter, matching Ruby's split
357/// (which does not break on non-breaking or other Unicode spaces), so a name
358/// joined by such a space stays a single segment. The returned vector always
359/// has at least one element because the caller has already rejected empty
360/// input.
361fn split_whitespace_max3(source: &str) -> Vec<String> {
362    let is_ascii_ws = |c: char| c.is_ascii_whitespace();
363
364    let mut segments: Vec<String> = Vec::with_capacity(3);
365    let mut rest = source;
366
367    for _ in 0..2 {
368        rest = rest.trim_start_matches(is_ascii_ws);
369        match rest.find(is_ascii_ws) {
370            Some(index) => {
371                segments.push(rest[..index].to_string());
372                rest = &rest[index..];
373            }
374            None => break,
375        }
376    }
377
378    rest = rest.trim_start_matches(is_ascii_ws);
379    if !rest.is_empty() {
380        segments.push(condense_whitespace(rest));
381    }
382
383    segments
384}
385
386/// Condense runs of spaces into a single space, mirroring Ruby's
387/// `String#tr_s(' ', ' ')`, which Asciidoctor applies to an author line that
388/// does not match the author pattern.
389fn condense_whitespace(s: &str) -> String {
390    let mut result = String::with_capacity(s.len());
391    let mut prev_was_space = false;
392
393    for c in s.chars() {
394        if c == ' ' {
395            if !prev_was_space {
396                result.push(' ');
397            }
398            prev_was_space = true;
399        } else {
400            result.push(c);
401            prev_was_space = false;
402        }
403    }
404
405    result
406}
407
408static AUTHOR: LazyLock<Regex> = LazyLock::new(|| {
409    #[allow(clippy::unwrap_used)]
410    Regex::new(
411        r#"(?x)
412            ^
413
414            # Group 1: First name (required)
415            ([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*)
416
417            # Group 2: Middle name (optional)
418            (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
419
420            # Group 3: Last name (optional)
421            (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
422
423            # Group 4: Email address (optional)
424            (?:\ +<([^>]+)>)?
425
426            $
427        "#,
428    )
429    .unwrap()
430});
431
432/// Splits a names-only author value into its name portion (group 1) and a
433/// trailing `<email>` (group 2). The name must contain at least one
434/// non-whitespace character and is followed by whitespace before the bracketed
435/// email, matching the email group of [`AUTHOR`] for a value that otherwise
436/// fails the full pattern (e.g. a name with four or more parts).
437static NAMES_ONLY_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
438    #[allow(clippy::unwrap_used)]
439    Regex::new(r"^(.*\S)\s+<([^>]+)>$").unwrap()
440});
441
442/// Returns whether `source` matches the author pattern – at most three
443/// space-separated names with an optional trailing `<email>`.
444///
445/// The `:author:` attribute-entry path uses this to tell whether a plain-name
446/// value was partitioned by the fallback whitespace split (a name with four or
447/// more parts, or one containing punctuation such as a comma) rather than by
448/// the pattern. Only in the fallback case is the stored `author` value replaced
449/// with the reconstructed, whitespace-condensed name (issue #758).
450pub(crate) fn matches_author_pattern(source: &str) -> bool {
451    AUTHOR.is_match(source.trim())
452}
453
454fn apply_author_subs(source: &str, parser: &Parser) -> String {
455    let span = Span::new(source);
456    let mut content = Content::from(span);
457
458    use crate::content::SubstitutionStep;
459
460    // Apply attribute references first.
461    SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
462
463    // Apply HTML encoding:
464    // - Single attribute reference (like {full-author}): No HTML encoding.
465    // - Single attribute in email position (like <{email}>): No HTML encoding.
466    // - Multiple attributes or complex patterns: HTML encoding.
467    // - Don't HTML encode if the content only has pre-existing HTML entities.
468    let is_simple_single_attribute = source.trim().starts_with('{')
469        && source.trim().ends_with('}')
470        && source.matches('{').count() == 1;
471
472    let has_multiple_attributes = source.matches('{').count() > 1;
473
474    // Check if we should apply HTML encoding.
475    let rendered = content.rendered();
476    let has_angle_brackets = rendered.contains('<') && rendered.contains('>');
477    let has_unencoded_ampersand = rendered.contains('&') && !rendered.contains("&amp;");
478
479    if !is_simple_single_attribute
480        && has_multiple_attributes
481        && (has_angle_brackets || has_unencoded_ampersand)
482    {
483        SubstitutionStep::SpecialCharacters.apply(&mut content, parser, None);
484    }
485
486    content.rendered().to_string()
487}