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("&");
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}