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 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 names_only {
Some(partition_names_only(source))
} else {
let name = apply_author_special_characters(&condense_whitespace(source), parser);
Some(Self {
name: name.clone(),
firstname: name,
middlename: None,
lastname: None,
email: None,
})
}
}
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(Self {
firstname: name.clone(),
name,
middlename: None,
lastname: None,
email: None,
});
}
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 {
name,
firstname,
middlename,
lastname,
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()),
)
}
}
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(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 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 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 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());
}
}
}