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