use std::sync::LazyLock;
use regex::Regex;
use crate::{Parser, Span, content::Content};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Author {
name: String,
firstname: String,
middlename: Option<String>,
lastname: Option<String>,
email: Option<String>,
raw_name: String,
raw_firstname: String,
raw_middlename: Option<String>,
raw_lastname: Option<String>,
raw_email: Option<String>,
}
impl Author {
pub(crate) fn parse(source: &str, parser: &Parser, names_only: bool) -> Option<Self> {
let source = source.trim();
if source.is_empty() {
return None;
}
let is_single_attribute = source.trim().starts_with('{')
&& source.trim().ends_with('}')
&& source.matches('{').count() == 1;
if is_single_attribute {
let expanded_source = apply_author_subs(source, parser);
let raw_expanded = resolve_attribute_references(source, parser);
if names_only {
Some(partition_names_only(&expanded_source, &raw_expanded))
} else {
Some(single_name_author(
replace_underscores_with_spaces(expanded_source),
replace_underscores_with_spaces(raw_expanded),
))
}
} else if let Some(captures) = AUTHOR.captures(source) {
let (name, firstname, middlename, lastname, email) =
matched_parts(&captures, |s| apply_author_subs(s, parser));
let (raw_name, raw_firstname, raw_middlename, raw_lastname, raw_email) =
matched_parts(&captures, |s| resolve_attribute_references(s, parser));
Some(Self {
name,
firstname,
middlename,
lastname,
email,
raw_name,
raw_firstname,
raw_middlename,
raw_lastname,
raw_email,
})
} else if source.contains('{') {
let expanded_source = apply_author_subs(source, parser);
let raw_expanded = resolve_attribute_references(source, parser);
if let Some(captures) = AUTHOR.captures(&expanded_source) {
let (name, firstname, middlename, lastname, email) =
matched_parts(&captures, str::to_string);
let (raw_name, raw_firstname, raw_middlename, raw_lastname, raw_email) =
match AUTHOR.captures(&raw_expanded) {
Some(raw_captures) if raw_captures.get(4).is_some() == email.is_some() => {
matched_parts(&raw_captures, str::to_string)
}
_ => (
name.clone(),
firstname.clone(),
middlename.clone(),
lastname.clone(),
email.clone(),
),
};
Some(Self {
name,
firstname,
middlename,
lastname,
email,
raw_name,
raw_firstname,
raw_middlename,
raw_lastname,
raw_email,
})
} else if names_only {
Some(partition_names_only(&expanded_source, &raw_expanded))
} else {
Some(single_name_author(
replace_underscores_with_spaces(expanded_source),
replace_underscores_with_spaces(raw_expanded),
))
}
} else if names_only {
Some(partition_names_only(source, source))
} else {
let raw_name = condense_whitespace(source);
let name = apply_author_special_characters(&raw_name, parser);
Some(single_name_author(name, raw_name))
}
}
pub(crate) fn parse_from_entry(
raw: &str,
substituted: Option<&str>,
parser: &Parser,
) -> Option<Self> {
if crate::document::is_attribute_entry_pass_macro(raw) {
substituted.and_then(Self::parse_substituted_names_only)
} else {
Self::parse(raw, parser, true)
}
}
pub(crate) fn parse_substituted_names_only(substituted: &str) -> Option<Self> {
let substituted = substituted.trim();
if substituted.is_empty() {
return None;
}
let name = replace_underscores_with_spaces(substituted.to_string());
let stripped = strip_xml_tags(substituted);
let mut segments = split_whitespace_max3(&stripped);
if segments.is_empty() {
return Some(single_name_author(name.clone(), name));
}
let firstname = replace_underscores_with_spaces(segments.remove(0));
let (middlename, lastname) = match segments.len() {
0 => (None, None),
1 => (
None,
Some(replace_underscores_with_spaces(segments.remove(0))),
),
_ => (
Some(replace_underscores_with_spaces(segments.remove(0))),
Some(replace_underscores_with_spaces(segments.remove(0))),
),
};
Some(Self {
raw_name: name.clone(),
raw_firstname: firstname.clone(),
raw_middlename: middlename.clone(),
raw_lastname: lastname.clone(),
raw_email: None,
name,
firstname,
middlename,
lastname,
email: None,
})
}
pub(crate) fn with_email(mut self, email: Option<String>) -> Self {
if let Some(email) = email {
self.raw_email = Some(email.clone());
self.email = Some(email);
}
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn firstname(&self) -> &str {
&self.firstname
}
pub fn middlename(&self) -> Option<&str> {
self.middlename.as_deref()
}
pub fn lastname(&self) -> Option<&str> {
self.lastname.as_deref()
}
pub fn email(&self) -> Option<&str> {
self.email.as_deref()
}
pub fn raw_name(&self) -> &str {
&self.raw_name
}
pub fn raw_firstname(&self) -> &str {
&self.raw_firstname
}
pub fn raw_middlename(&self) -> Option<&str> {
self.raw_middlename.as_deref()
}
pub fn raw_lastname(&self) -> Option<&str> {
self.raw_lastname.as_deref()
}
pub fn raw_email(&self) -> Option<&str> {
self.raw_email.as_deref()
}
pub fn initials(&self) -> String {
format!(
"{first}{middle}{last}",
first = first_char_or_empty_string(&self.firstname),
middle = opt_first_char_or_empty_string(self.middlename.as_deref()),
last = opt_first_char_or_empty_string(self.lastname.as_deref()),
)
}
}
pub(crate) fn set_author_metadata(parser: &mut Parser, authors: &[Author]) {
for (idx, author) in authors.iter().enumerate() {
set_author_keys(parser, author, if idx == 0 { None } else { Some(idx + 1) });
if idx == 1
&& let Some(first) = authors.first()
{
set_author_keys(parser, first, Some(1));
}
}
let joined = authors
.iter()
.map(Author::name)
.collect::<Vec<_>>()
.join(", ");
parser.set_attribute_by_value_from_header("authors", joined);
}
fn set_author_keys(parser: &mut Parser, author: &Author, index: Option<usize>) {
let key = |name: &str| match index {
None => name.to_string(),
Some(n) => format!("{name}_{n}"),
};
parser.set_attribute_by_value_from_header(key("author"), author.name());
parser.set_attribute_by_value_from_header(key("firstname"), author.firstname());
if let Some(middlename) = author.middlename() {
parser.set_attribute_by_value_from_header(key("middlename"), middlename);
}
if let Some(lastname) = author.lastname() {
parser.set_attribute_by_value_from_header(key("lastname"), lastname);
}
parser.set_attribute_by_value_from_header(key("authorinitials"), author.initials());
if let Some(email) = author.email() {
parser.set_attribute_by_value_from_header(key("email"), email);
}
}
fn first_char_or_empty_string(s: &str) -> String {
s.chars().next().map_or(String::new(), |c| c.to_string())
}
fn opt_first_char_or_empty_string(s: Option<&str>) -> String {
s.map(first_char_or_empty_string).unwrap_or_default()
}
fn replace_underscores_with_spaces(name: String) -> String {
name.replace('_', " ")
}
fn strip_xml_tags(source: &str) -> String {
XML_TAG.replace_all(source, "").into_owned()
}
fn join_name_parts(firstname: &str, middlename: Option<&str>, lastname: Option<&str>) -> String {
let mut name = String::from(firstname);
if let Some(middlename) = middlename {
name.push(' ');
name.push_str(middlename);
}
if let Some(lastname) = lastname {
name.push(' ');
name.push_str(lastname);
}
name
}
fn partition_names_only(escaped_source: &str, raw_source: &str) -> Author {
let escaped = partition_parts(escaped_source, EmailSplit::Detect);
let raw = partition_parts(
raw_source,
if escaped.email.is_some() {
EmailSplit::Detect
} else {
EmailSplit::Suppress
},
);
Author {
name: escaped.name,
firstname: escaped.firstname,
middlename: escaped.middlename,
lastname: escaped.lastname,
email: escaped.email,
raw_name: raw.name,
raw_firstname: raw.firstname,
raw_middlename: raw.middlename,
raw_lastname: raw.lastname,
raw_email: raw.email,
}
}
struct NameParts {
name: String,
firstname: String,
middlename: Option<String>,
lastname: Option<String>,
email: Option<String>,
}
enum EmailSplit {
Detect,
Suppress,
}
fn partition_parts(source: &str, email_split: EmailSplit) -> NameParts {
let source = source.trim();
let (name_source, email) = match email_split {
EmailSplit::Detect => match NAMES_ONLY_EMAIL.captures(source) {
Some(captures) => (
captures.get(1).map_or(source, |m| m.as_str()),
Some(captures[2].to_string()),
),
None => (source, None),
},
EmailSplit::Suppress => (source, None),
};
let mut segments = split_whitespace_max3(name_source);
let firstname = replace_underscores_with_spaces(segments.remove(0));
let (middlename, lastname) = match segments.len() {
0 => (None, None),
1 => (
None,
Some(replace_underscores_with_spaces(segments.remove(0))),
),
_ => (
Some(replace_underscores_with_spaces(segments.remove(0))),
Some(replace_underscores_with_spaces(segments.remove(0))),
),
};
let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
NameParts {
name,
firstname,
middlename,
lastname,
email,
}
}
fn matched_parts<F>(
captures: ®ex::Captures,
transform: F,
) -> (
String,
String,
Option<String>,
Option<String>,
Option<String>,
)
where
F: Fn(&str) -> String,
{
let firstname = replace_underscores_with_spaces(transform(&captures[1]));
let mut middlename = captures
.get(2)
.map(|m| replace_underscores_with_spaces(transform(m.as_str())));
let mut lastname = captures
.get(3)
.map(|m| replace_underscores_with_spaces(transform(m.as_str())));
let email = captures.get(4).map(|m| transform(m.as_str()));
if middlename.is_some() && lastname.is_none() {
lastname = middlename;
middlename = None;
}
let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
(name, firstname, middlename, lastname, email)
}
fn single_name_author(name: String, raw_name: String) -> Author {
Author {
firstname: name.clone(),
name,
middlename: None,
lastname: None,
email: None,
raw_firstname: raw_name.clone(),
raw_name,
raw_middlename: None,
raw_lastname: None,
raw_email: None,
}
}
fn split_whitespace_max3(source: &str) -> Vec<String> {
let is_ascii_ws = |c: char| c.is_ascii_whitespace();
let mut segments: Vec<String> = Vec::with_capacity(3);
let mut rest = source;
for _ in 0..2 {
rest = rest.trim_start_matches(is_ascii_ws);
match rest.find(is_ascii_ws) {
Some(index) => {
segments.push(rest[..index].to_string());
rest = &rest[index..];
}
None => break,
}
}
rest = rest.trim_start_matches(is_ascii_ws);
if !rest.is_empty() {
segments.push(condense_whitespace(rest));
}
segments
}
fn condense_whitespace(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut prev_was_space = false;
for c in s.chars() {
if c == ' ' {
if !prev_was_space {
result.push(' ');
}
prev_was_space = true;
} else {
result.push(c);
prev_was_space = false;
}
}
result
}
static XML_TAG: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r"<[^>]+>").unwrap()
});
static AUTHOR: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?x)
^
# Group 1: First name (required)
([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*)
# Group 2: Middle name (optional)
(?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
# Group 3: Last name (optional)
(?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
# Group 4: Email address (optional)
(?:\ +<([^>]+)>)?
$
"#,
)
.unwrap()
});
static NAMES_ONLY_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r"^(.*\S)\s+<([^>]+)>$").unwrap()
});
pub(crate) fn matches_author_pattern(source: &str) -> bool {
AUTHOR.is_match(source.trim())
}
fn apply_author_subs(source: &str, parser: &Parser) -> String {
use crate::content::SubstitutionStep;
let with_special_characters = apply_author_special_characters(source, parser);
let span = Span::new(&with_special_characters);
let mut content = Content::from(span);
SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
content.rendered().to_string()
}
fn resolve_attribute_references(source: &str, parser: &Parser) -> String {
use crate::content::SubstitutionStep;
let warnings_before = parser.substitution_warnings_len();
let span = Span::new(source);
let mut content = Content::from(span);
SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
parser.truncate_substitution_warnings(warnings_before);
content.rendered().to_string()
}
fn apply_author_special_characters(source: &str, parser: &Parser) -> String {
let mut result = String::with_capacity(source.len());
let mut last = 0;
for m in NUMERIC_CHARACTER_REFERENCE.find_iter(source) {
result.push_str(&escape_special_characters(&source[last..m.start()], parser));
result.push_str(m.as_str());
last = m.end();
}
result.push_str(&escape_special_characters(&source[last..], parser));
result
}
fn escape_special_characters(source: &str, parser: &Parser) -> String {
if source.is_empty() {
return String::new();
}
let span = Span::new(source);
let mut content = Content::from(span);
crate::content::SubstitutionStep::SpecialCharacters.apply(&mut content, parser, None);
content.rendered().to_string()
}
static NUMERIC_CHARACTER_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r"&#(?:[0-9]+|[xX][0-9a-fA-F]+);").unwrap()
});
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::Author;
mod raw_accessors {
use crate::{Parser, document::Author};
fn only_author(src: &str) -> Author {
let mut parser = Parser::default();
let doc = parser.parse(src);
doc.authors().first().cloned().unwrap()
}
#[test]
fn plain_name_has_no_special_characters_so_raw_equals_rendered() {
let a = only_author("= Doc\nKismet R. Lee <kismet@asciidoctor.org>\n\nbody\n");
assert_eq!(a.name(), "Kismet R. Lee");
assert_eq!(a.raw_name(), "Kismet R. Lee");
assert_eq!(a.firstname(), "Kismet");
assert_eq!(a.raw_firstname(), "Kismet");
assert_eq!(a.middlename(), Some("R."));
assert_eq!(a.raw_middlename(), Some("R."));
assert_eq!(a.lastname(), Some("Lee"));
assert_eq!(a.raw_lastname(), Some("Lee"));
assert_eq!(a.email(), Some("kismet@asciidoctor.org"));
assert_eq!(a.raw_email(), Some("kismet@asciidoctor.org"));
}
#[test]
fn implicit_line_with_literal_angle_brackets_keeps_them_raw() {
let a = only_author(
"= Doc\nStuart Rackham, founder of AsciiDoc <founder@asciidoc.org>\n\nbody\n",
);
assert_eq!(
a.name(),
"Stuart Rackham, founder of AsciiDoc <founder@asciidoc.org>"
);
assert_eq!(
a.raw_name(),
"Stuart Rackham, founder of AsciiDoc <founder@asciidoc.org>"
);
assert_eq!(
a.raw_firstname(),
"Stuart Rackham, founder of AsciiDoc <founder@asciidoc.org>"
);
}
#[test]
fn literal_ampersand_is_escaped_only_in_the_rendered_value() {
let a = only_author("= Doc\nBen & Jerry\n\nbody\n");
assert_eq!(a.name(), "Ben & Jerry");
assert_eq!(a.raw_name(), "Ben & Jerry");
assert_eq!(a.middlename(), Some("&"));
assert_eq!(a.raw_middlename(), Some("&"));
}
#[test]
fn four_part_name_with_email_keeps_brackets_raw() {
let a = only_author("= Doc\nFour Names Not Supported <doc@example.com>\n\nbody\n");
assert_eq!(a.name(), "Four Names Not Supported <doc@example.com>");
assert_eq!(a.raw_name(), "Four Names Not Supported <doc@example.com>");
}
#[test]
fn attribute_reference_resolves_then_stays_a_single_raw_name() {
let src = concat!(
":first-name: Jane\n",
":last-name: Smith\n",
":author-email: jane@example.com\n",
"= Doc\n",
"{first-name} {last-name} <{author-email}>\n\n",
"body\n",
);
let a = only_author(src);
assert_eq!(a.name(), "Jane Smith <jane@example.com>");
assert_eq!(a.raw_name(), "Jane Smith <jane@example.com>");
assert_eq!(a.middlename(), None);
assert_eq!(a.raw_middlename(), None);
assert_eq!(a.lastname(), None);
assert_eq!(a.raw_lastname(), None);
}
#[test]
fn unresolved_attribute_reference_warns_only_once() {
let mut parser = Parser::default();
let doc =
parser.parse(":attribute-missing: warn\n= Doc\nJane {undefined} Smith\n\nbody\n");
assert_eq!(doc.warnings().count(), 1);
}
#[test]
fn names_only_email_split_stays_aligned_between_raw_and_rendered() {
let a = only_author(":mail: someone@example.com\n:author: A B C D <{mail}>\n\nbody\n");
assert_eq!(a.name(), "A B C D <someone@example.com>");
assert_eq!(a.raw_name(), "A B C D <someone@example.com>");
assert_eq!(a.email(), None);
assert_eq!(a.raw_email(), None);
assert_eq!(a.lastname(), Some("C D <someone@example.com>"));
assert_eq!(a.raw_lastname(), Some("C D <someone@example.com>"));
}
#[test]
fn names_only_email_from_an_attribute_still_splits_in_both() {
let mut parser = Parser::default().with_intrinsic_attribute(
"tail",
"Jr. <boss@example.com>",
crate::parser::ModificationContext::Anywhere,
);
let doc = parser.parse("= Doc\n:author: A B C {tail}\n\nbody\n");
let a = doc.authors().first().cloned().unwrap();
assert_eq!(a.email(), Some("boss@example.com"));
assert_eq!(a.raw_email(), Some("boss@example.com"));
}
}
mod parse_substituted_names_only {
use super::Author;
#[test]
fn empty_input_is_none() {
assert!(Author::parse_substituted_names_only("").is_none());
assert!(Author::parse_substituted_names_only(" ").is_none());
}
#[test]
fn markup_with_no_text_keeps_rendered_value_as_single_name() {
let author = Author::parse_substituted_names_only("<a href=\"x\"></a>").unwrap();
assert_eq!(author.name(), "<a href=\"x\"></a>");
assert_eq!(author.firstname(), "<a href=\"x\"></a>");
assert_eq!(author.middlename(), None);
assert_eq!(author.lastname(), None);
assert_eq!(author.email(), None);
}
#[test]
fn single_name_part() {
let author = Author::parse_substituted_names_only("<strong>Solo</strong>").unwrap();
assert_eq!(author.name(), "<strong>Solo</strong>");
assert_eq!(author.firstname(), "Solo");
assert_eq!(author.middlename(), None);
assert_eq!(author.lastname(), None);
}
#[test]
fn first_and_last_name() {
let author = Author::parse_substituted_names_only("<em>Kismet</em> Chameleon").unwrap();
assert_eq!(author.firstname(), "Kismet");
assert_eq!(author.middlename(), None);
assert_eq!(author.lastname(), Some("Chameleon"));
}
#[test]
fn first_middle_and_last_name() {
let author =
Author::parse_substituted_names_only("<em>Kismet</em> R. Chameleon").unwrap();
assert_eq!(author.firstname(), "Kismet");
assert_eq!(author.middlename(), Some("R."));
assert_eq!(author.lastname(), Some("Chameleon"));
}
#[test]
fn underscores_join_name_parts_and_the_rendered_name() {
let author = Author::parse_substituted_names_only("<b>Ze_Project</b> team").unwrap();
assert_eq!(author.name(), "<b>Ze Project</b> team");
assert_eq!(author.firstname(), "Ze Project");
assert_eq!(author.lastname(), Some("team"));
assert_eq!(author.email(), None);
}
}
mod parse_from_entry {
use super::Author;
use crate::Parser;
#[test]
fn pass_macro_with_markup_is_partitioned_from_the_substituted_value() {
let parser = Parser::default();
let author = Author::parse_from_entry(
"pass:n[https://example.org/x[Ze *team*]]",
Some("<a href=\"https://example.org/x\">Ze <strong>team</strong></a>"),
&parser,
)
.unwrap();
assert_eq!(author.firstname(), "Ze");
assert_eq!(author.lastname(), Some("team"));
}
#[test]
fn pass_macro_resolving_to_plain_text_uses_the_substituted_value() {
let parser = Parser::default();
let author =
Author::parse_from_entry("pass:n[Doc Writer]", Some("Doc Writer"), &parser)
.unwrap();
assert_eq!(author.name(), "Doc Writer");
assert_eq!(author.firstname(), "Doc");
assert_eq!(author.lastname(), Some("Writer"));
}
#[test]
fn non_pass_value_uses_raw_partitioning() {
let parser = Parser::default();
let author =
Author::parse_from_entry("Doc Writer", Some("Doc Writer"), &parser).unwrap();
assert_eq!(author.firstname(), "Doc");
assert_eq!(author.lastname(), Some("Writer"));
}
#[test]
fn empty_pass_macro_yields_no_author() {
let parser = Parser::default();
assert!(Author::parse_from_entry("pass:[]", Some(""), &parser).is_none());
}
}
}