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 (`<`, `>`, `&`), 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: ®ex::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®`
881/// keeps its ® entity rather than degrading to `AsciiDoc&#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 (`®`) or
915/// hexadecimal (`®`). 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 <founder@asciidoc.org>"
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;` 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 & Jerry");
995 assert_eq!(a.raw_name(), "Ben & Jerry");
996 assert_eq!(a.middlename(), Some("&"));
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 <doc@example.com>");
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 <jane@example.com>");
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 <someone@example.com>");
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 <someone@example.com>"));
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}