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>,
}
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);
if names_only {
Some(partition_names_only(&expanded_source))
} else {
let name_with_spaces = replace_underscores_with_spaces(expanded_source);
Some(Self {
name: name_with_spaces.clone(),
firstname: name_with_spaces,
middlename: None,
lastname: None,
email: None,
})
}
} else if let Some(captures) = AUTHOR.captures(source) {
let firstname =
replace_underscores_with_spaces(apply_author_subs(&captures[1], parser));
let mut middlename = captures
.get(2)
.map(|m| replace_underscores_with_spaces(apply_author_subs(m.as_str(), parser)));
let mut lastname = captures
.get(3)
.map(|m| replace_underscores_with_spaces(apply_author_subs(m.as_str(), parser)));
let email = captures
.get(4)
.map(|m| apply_author_subs(m.as_str(), parser));
if middlename.is_some() && lastname.is_none() {
lastname = middlename;
middlename = None;
}
let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
Some(Self {
name,
firstname,
middlename,
lastname,
email,
})
} else if source.contains('{') {
let expanded_source = apply_author_subs(source, parser);
if let Some(captures) = AUTHOR.captures(&expanded_source) {
let firstname = replace_underscores_with_spaces(captures[1].to_string());
let mut middlename = captures
.get(2)
.map(|m| replace_underscores_with_spaces(m.as_str().to_string()));
let mut lastname = captures
.get(3)
.map(|m| replace_underscores_with_spaces(m.as_str().to_string()));
let email = captures.get(4).map(|m| m.as_str().to_string());
if middlename.is_some() && lastname.is_none() {
lastname = middlename;
middlename = None;
}
let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
Some(Self {
name,
firstname,
middlename,
lastname,
email,
})
} else if names_only {
Some(partition_names_only(&expanded_source))
} else {
let mut expanded_name = expanded_source;
if expanded_name.contains('<') && expanded_name.contains('>') {
let span = crate::Span::new(&expanded_name);
let mut content = crate::content::Content::from(span);
crate::content::SubstitutionStep::SpecialCharacters.apply(
&mut content,
parser,
None,
);
expanded_name = content.rendered().to_string();
}
let name_with_spaces = replace_underscores_with_spaces(expanded_name);
Some(Self {
name: name_with_spaces.clone(),
firstname: name_with_spaces,
middlename: None,
lastname: None,
email: None,
})
}
} else if names_only {
Some(partition_names_only(source))
} else {
let name = condense_whitespace(source);
Some(Self {
name: name.clone(),
firstname: name,
middlename: None,
lastname: None,
email: None,
})
}
}
pub(crate) fn with_email(mut self, email: Option<String>) -> Self {
if let Some(email) = email {
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 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()),
)
}
}
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 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(source: &str) -> Author {
let source = source.trim();
let (name_source, email) = 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),
};
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());
Author {
name,
firstname,
middlename,
lastname,
email,
}
}
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 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 {
let span = Span::new(source);
let mut content = Content::from(span);
use crate::content::SubstitutionStep;
SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
let is_simple_single_attribute = source.trim().starts_with('{')
&& source.trim().ends_with('}')
&& source.matches('{').count() == 1;
let has_multiple_attributes = source.matches('{').count() > 1;
let rendered = content.rendered();
let has_angle_brackets = rendered.contains('<') && rendered.contains('>');
let has_unencoded_ampersand = rendered.contains('&') && !rendered.contains("&");
if !is_simple_single_attribute
&& has_multiple_attributes
&& (has_angle_brackets || has_unencoded_ampersand)
{
SubstitutionStep::SpecialCharacters.apply(&mut content, parser, None);
}
content.rendered().to_string()
}