use crate::processor::rendering::get_variable_key;
use crate::reference::Reference;
use crate::render::format::OutputFormat;
use crate::values::text_case::apply_text_case_with_language;
use crate::values::title::resolve_substitute_text_case;
use crate::values::{ProcHints, ProcValues, RenderContext, RenderOptions};
use citum_schema::options::{
RoleLabelPreset, SubstituteField, SubstituteKey, SubstituteTitleQuoteMode,
};
use citum_schema::reference::Title;
use citum_schema::reference::{
AudioVisualType, ClassExtension, Contributor, ContributorRole as DataRole,
};
use citum_schema::template::{
ContributorRole, ContributorRoles, Rendering, TemplateComponent, TemplateContributor, TitleType,
};
#[derive(Debug, Clone)]
pub(crate) enum EffectivePrimary {
Contributor {
contributor: Box<Contributor>,
role: ContributorRole,
},
Merged(ContributorRoles),
Title { title: Title, kind: TitleType },
}
enum ResolvedRole {
BuiltIn(ContributorRole),
Custom(String),
}
impl ResolvedRole {
fn key(&self) -> &str {
match self {
Self::BuiltIn(role) => role.as_str(),
Self::Custom(role) => role.as_str(),
}
}
fn built_in(&self) -> Option<&ContributorRole> {
match self {
Self::BuiltIn(role) => Some(role),
Self::Custom(_) => None,
}
}
}
fn normalize_role_key(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let canonical = trimmed
.chars()
.map(|ch| match ch {
'_' => '-',
other => other.to_ascii_lowercase(),
})
.collect::<String>();
canonical
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
.then_some(canonical)
}
fn parse_known_role(value: &str) -> Option<ContributorRole> {
Some(match value {
"author" => ContributorRole::Author,
"chair" => ContributorRole::Chair,
"editor" => ContributorRole::Editor,
"translator" => ContributorRole::Translator,
"director" => ContributorRole::Director,
"composer" => ContributorRole::Composer,
"illustrator" => ContributorRole::Illustrator,
"collection-editor" => ContributorRole::CollectionEditor,
"container-author" => ContributorRole::ContainerAuthor,
"editorial-director" => ContributorRole::EditorialDirector,
"textual-editor" => ContributorRole::TextualEditor,
"original-author" => ContributorRole::OriginalAuthor,
"reviewed-author" => ContributorRole::ReviewedAuthor,
"recipient" => ContributorRole::Recipient,
"interviewer" => ContributorRole::Interviewer,
"guest" => ContributorRole::Guest,
"performer" => ContributorRole::Performer,
"inventor" => ContributorRole::Inventor,
"counsel" => ContributorRole::Counsel,
"writer" => ContributorRole::Writer,
_ => return None,
})
}
fn resolve_role_key(value: &str) -> Option<ResolvedRole> {
let canonical = normalize_role_key(value)?;
Some(
parse_known_role(&canonical)
.map(ResolvedRole::BuiltIn)
.unwrap_or(ResolvedRole::Custom(canonical)),
)
}
fn lookup_role_contributor(
reference: &Reference,
role: &ResolvedRole,
) -> Option<citum_schema::reference::contributor::Contributor> {
match role {
ResolvedRole::BuiltIn(ContributorRole::Editor) => reference.editor(),
ResolvedRole::BuiltIn(ContributorRole::Translator) => reference.translator(),
ResolvedRole::BuiltIn(role) => {
data_role_for_builtin(role).and_then(|data_role| reference.contributor(data_role))
}
ResolvedRole::Custom(role) => reference.contributor(data_role_for_custom(role)),
}
}
fn data_role_for_builtin(role: &ContributorRole) -> Option<DataRole> {
Some(match role {
ContributorRole::Director => DataRole::Director,
ContributorRole::Composer => DataRole::Composer,
ContributorRole::Illustrator => DataRole::Illustrator,
ContributorRole::Recipient => DataRole::Recipient,
ContributorRole::Interviewer => DataRole::Interviewer,
ContributorRole::Guest => DataRole::Guest,
ContributorRole::Performer => DataRole::Performer,
ContributorRole::Writer => DataRole::Writer,
ContributorRole::ContainerAuthor
| ContributorRole::CollectionEditor
| ContributorRole::EditorialDirector
| ContributorRole::TextualEditor
| ContributorRole::OriginalAuthor
| ContributorRole::ReviewedAuthor
| ContributorRole::Chair
| ContributorRole::Inventor
| ContributorRole::Counsel => DataRole::Unknown(role.as_str().to_string()),
ContributorRole::Unknown(role) => DataRole::Unknown(role.clone()),
ContributorRole::Author
| ContributorRole::Editor
| ContributorRole::Translator
| ContributorRole::Publisher
| ContributorRole::Interviewee => return None,
_ => return None,
})
}
fn data_role_for_custom(role: &str) -> DataRole {
match role {
"compiler" => DataRole::Compiler,
"performer" => DataRole::Performer,
"narrator" => DataRole::Narrator,
"host" => DataRole::Host,
"producer" | "executive-producer" => DataRole::Producer,
"writer" => DataRole::Writer,
_ => DataRole::Unknown(role.to_string()),
}
}
pub(super) fn resolve_multilingual_for_contrib(
contrib: &citum_schema::reference::contributor::Contributor,
options: &RenderOptions<'_>,
) -> Vec<crate::reference::FlatName> {
let mode = options
.config
.multilingual
.as_ref()
.and_then(|m| m.name_mode.as_ref());
let preferred_transliteration = options
.config
.multilingual
.as_ref()
.and_then(|m| m.preferred_transliteration.as_deref());
let preferred_script = options
.config
.multilingual
.as_ref()
.and_then(|m| m.preferred_script.as_ref());
crate::values::resolve_multilingual_name(
contrib,
mode,
preferred_transliteration,
preferred_script,
&options.locale.locale,
)
}
struct SubstituteRoleLabelContext<'a, 'b, F> {
component: &'a TemplateContributor,
role: &'a ResolvedRole,
names_count: usize,
reference: &'a Reference,
options: &'a RenderOptions<'b>,
effective_rendering: &'a Rendering,
fmt: &'a F,
substitute: &'a citum_schema::options::Substitute,
}
fn substitute_role_label_preset(
role: &ResolvedRole,
options: &RenderOptions<'_>,
substitute: &citum_schema::options::Substitute,
) -> Option<RoleLabelPreset> {
substitute
.contributor_role_form
.as_deref()
.and_then(|form| match form {
"short" => Some(RoleLabelPreset::ShortSuffix),
"short-comma" => Some(RoleLabelPreset::ShortSuffixComma),
"long" => Some(RoleLabelPreset::LongSuffix),
_ => None,
})
.or_else(|| {
let contributors = options.config.contributors.as_ref()?;
role.built_in()
.and_then(|known| contributors.effective_role_label_preset(known))
})
.or_else(|| {
let contributors = options.config.contributors.as_ref()?;
role.built_in()
.and_then(|known| contributors.default_role_label_preset(known))
})
}
fn resolve_substitute_role_labels<F: OutputFormat<Output = String>>(
context: &SubstituteRoleLabelContext<'_, '_, F>,
) -> (Option<String>, Option<String>) {
let SubstituteRoleLabelContext {
component,
role,
names_count,
reference,
options,
effective_rendering,
fmt,
substitute,
} = context;
if matches!(role.built_in(), Some(ContributorRole::Author)) {
return (None, None);
}
if options.context != RenderContext::Bibliography
|| role
.built_in()
.is_some_and(|known| super::is_role_label_omitted(options, known))
{
return (None, None);
}
if substitute.contributor_role_form.is_none()
&& substitute.contributor_role_case.is_none()
&& let Some(known) = role.built_in()
&& options
.config
.contributors
.as_ref()
.is_some_and(|contributors| {
contributors.role_label_presentation(known).is_some()
|| contributors
.role
.as_ref()
.and_then(|role_options| role_options.defaults)
.and_then(|defaults| defaults.presentation_for(known))
.is_some()
})
{
let mut role_component = (*component).clone();
role_component.contributor = known.clone().into();
return super::labels::resolve_role_labels::<F>(super::labels::RoleLabelContext {
component: &role_component,
role: known,
reference,
names_count: *names_count,
effective_rendering,
options,
fmt: *fmt,
role_omitted: super::is_role_label_omitted(options, known),
});
}
let preset = substitute_role_label_preset(role, options, substitute);
preset
.and_then(|selected| {
if component.contributor == ContributorRole::Author
&& matches!(
selected,
RoleLabelPreset::VerbPrefix | RoleLabelPreset::VerbShortPrefix
)
{
return None;
}
role.built_in().map(|known| {
super::labels::resolve_role_label_preset::<F>(
known,
selected,
*names_count,
super::labels::RoleLabelTermOptions {
gender: None,
text_case: substitute.contributor_role_case,
},
effective_rendering,
options,
fmt,
)
})
})
.unwrap_or((None, None))
}
#[allow(
clippy::too_many_arguments,
reason = "Role-aware substitute formatting needs shared engine state until this module is refactored."
)]
fn resolve_named_substitute<F: OutputFormat<Output = String>>(
role: &ResolvedRole,
contributor: &citum_schema::reference::contributor::Contributor,
component: &TemplateContributor,
hints: &ProcHints,
options: &RenderOptions<'_>,
reference: &Reference,
effective_rendering: &Rendering,
fmt: &F,
substitute: &citum_schema::options::Substitute,
) -> Option<ProcValues<F::Output>> {
let names_vec = resolve_multilingual_for_contrib(contributor, options);
if names_vec.is_empty() {
return None;
}
if matches!(role.built_in(), Some(ContributorRole::Author)) {
let formatted = super::format_contributor_names(
component,
&ContributorRole::Author,
&names_vec,
reference,
effective_rendering,
options,
hints,
);
return Some(ProcValues {
value: crate::values::apply_abbreviation(formatted, options.abbreviation_map),
prefix: None,
suffix: None,
url: crate::values::resolve_effective_url(
component.links.as_ref(),
options.config.links.as_ref(),
reference,
citum_schema::options::LinkAnchor::Component,
),
substituted_key: None,
pre_formatted: false,
});
}
let effective_name_order = component.name_order.as_ref().or_else(|| {
role.built_in().and_then(|known| {
options
.config
.contributors
.as_ref()
.and_then(|contributors| contributors.effective_role_name_order(known))
})
});
let effective_name_form = component.name_form.or(effective_rendering.name_form);
let name_overrides = super::names::NamesOverrides {
name_order: effective_name_order,
sort_separator: component.sort_separator.as_ref(),
delimiter: component.delimiter.as_ref(),
shorten: component.shorten.as_ref(),
and: component.and.as_ref(),
initialize_with: effective_rendering.initialize_with.as_ref(),
name_form: effective_name_form,
strip_periods: effective_rendering.strip_periods,
item_language: crate::values::effective_item_language(reference),
};
let formatted =
super::names::format_names(&names_vec, &component.form, options, &name_overrides, hints);
let (prefix, suffix) = resolve_substitute_role_labels::<F>(&SubstituteRoleLabelContext {
component,
role,
names_count: names_vec.len(),
reference,
options,
effective_rendering,
fmt,
substitute,
});
let url = crate::values::resolve_effective_url(
component.links.as_ref(),
options.config.links.as_ref(),
reference,
citum_schema::options::LinkAnchor::Component,
);
let substituted_key = role.built_in().map_or_else(
|| Some(format!("contributor:{}", role.key())),
|known| {
get_variable_key(&TemplateComponent::Contributor(TemplateContributor {
contributor: known.clone().into(),
rendering: component.rendering.clone(),
..Default::default()
}))
},
);
Some(ProcValues {
value: fmt.text(&formatted),
prefix,
suffix,
url,
substituted_key,
pre_formatted: true,
})
}
#[allow(
clippy::too_many_arguments,
reason = "Substitute lookup and formatting share the same rendering state."
)]
fn resolve_contributor_substitute_for_role<F: OutputFormat<Output = String>>(
role: &ResolvedRole,
component: &TemplateContributor,
hints: &ProcHints,
options: &RenderOptions<'_>,
reference: &Reference,
effective_rendering: &Rendering,
fmt: &F,
substitute: &citum_schema::options::Substitute,
) -> Option<ProcValues<F::Output>> {
let contributor = lookup_role_contributor(reference, role)?;
resolve_named_substitute(
role,
&contributor,
component,
hints,
options,
reference,
effective_rendering,
fmt,
substitute,
)
}
pub(super) fn is_role_suppressed_by_substitute(
role: &ContributorRole,
substitute: &citum_schema::options::Substitute,
reference: &Reference,
) -> bool {
let role_str = role.as_str();
for (primary_role_str, fallback_chain) in &substitute.role_substitute {
if !fallback_chain
.iter()
.filter_map(|entry| resolve_role_key(entry))
.any(|entry| entry.key() == role_str)
{
continue;
}
if let Some(primary_role) = resolve_role_key(primary_role_str)
&& lookup_role_contributor(reference, &primary_role).is_some()
{
return true;
}
}
false
}
fn find_role_substitute_chain<'a>(
substitute: &'a citum_schema::options::Substitute,
primary_role: &ContributorRole,
) -> Option<&'a Vec<String>> {
let primary_role_str = primary_role.as_str();
substitute
.role_substitute
.get(primary_role_str)
.or_else(|| {
substitute
.role_substitute
.iter()
.find_map(|(configured_role, fallback_chain)| {
resolve_role_key(configured_role)
.filter(|resolved| resolved.key() == primary_role_str)
.map(|_| fallback_chain)
})
})
}
#[allow(
clippy::too_many_arguments,
reason = "Role-aware role-substitute needs shared engine state."
)]
pub(super) fn resolve_role_substitute<F: OutputFormat<Output = String>>(
primary_role: &ContributorRole,
component: &TemplateContributor,
hints: &ProcHints,
options: &RenderOptions<'_>,
reference: &Reference,
effective_rendering: &Rendering,
fmt: &F,
substitute: &citum_schema::options::Substitute,
) -> Option<ProcValues<F::Output>> {
let fallback_chain = find_role_substitute_chain(substitute, primary_role)?;
for fallback_role_str in fallback_chain {
let Some(fallback_role) = resolve_role_key(fallback_role_str) else {
continue;
};
if let Some(result) = resolve_contributor_substitute_for_role(
&fallback_role,
component,
hints,
options,
reference,
effective_rendering,
fmt,
substitute,
) {
return Some(result);
}
}
None
}
fn substituted_key_for_title_type(title_type: &TitleType) -> &'static str {
match title_type {
TitleType::ParentSerial => "title:ParentSerial",
_ => "title:Primary",
}
}
fn resolve_title_substitute<F: OutputFormat<Output = String>>(
title: Title,
title_type: &TitleType,
component: &TemplateContributor,
options: &RenderOptions<'_>,
reference: &Reference,
fmt: &F,
quote_in_citation: bool,
) -> ProcValues<F::Output> {
let substituted_key = substituted_key_for_title_type(title_type);
let title_str = title_substitute_text(title, options.context);
let title_str = match resolve_substitute_text_case(title_type, reference, options) {
Some(case) => {
let language = crate::values::title::effective_title_language(title_type, reference);
apply_text_case_with_language(&title_str, case, language.as_deref())
}
None => title_str,
};
let value = if options.context == RenderContext::Citation && quote_in_citation {
let marks = crate::render::format::QuoteMarks::from(&options.locale.grammar_options);
fmt.quote(fmt.text(&title_str), &marks)
} else {
fmt.text(&title_str)
};
let url = crate::values::resolve_effective_url(
component.links.as_ref(),
options.config.links.as_ref(),
reference,
citum_schema::options::LinkAnchor::Title,
);
ProcValues {
value,
prefix: None,
suffix: None,
url,
substituted_key: Some(substituted_key.to_string()),
pre_formatted: true,
}
}
fn resolve_category_quote(reference: &Reference, options: &RenderOptions<'_>) -> bool {
let ref_type = reference.ref_type();
let lang = reference.language();
crate::render::component::get_title_category_rendering(
&TitleType::Primary,
Some(&ref_type),
lang.as_deref(),
&options.config,
)
.and_then(|rendering| rendering.quote)
.unwrap_or(false)
}
fn title_substitute_text(title: Title, context: RenderContext) -> String {
if context != RenderContext::Citation {
return title.to_string();
}
match title {
Title::Structured(s) => s.main,
Title::MultiStructured(v) => v
.into_iter()
.next()
.map(|(_, s)| s.main)
.unwrap_or_default(),
Title::Shorthand(abbr, _) => abbr,
_ => title.to_string(),
}
}
fn resolve_parent_serial_title(reference: &Reference) -> Option<Title> {
match reference.extension() {
ClassExtension::SerialComponent(_)
| ClassExtension::LegalCase(_)
| ClassExtension::Treaty(_) => reference.container_title(),
_ => None,
}
}
fn native_type_key(reference: &Reference) -> Option<&'static str> {
reference
.as_audio_visual()
.and_then(|work| match work.r#type {
AudioVisualType::Film => Some("film"),
AudioVisualType::Episode => Some("episode"),
AudioVisualType::Recording => Some("recording"),
AudioVisualType::Broadcast => Some("broadcast"),
_ => None,
})
}
fn exact_type_candidates<'a>(
reference: &Reference,
substitute: &'a citum_schema::options::Substitute,
) -> Option<&'a [SubstituteKey]> {
if let Some(candidates) =
native_type_key(reference).and_then(|key| substitute.overrides.get(key))
{
return Some(candidates.as_slice());
}
substitute
.overrides
.get(&reference.ref_type())
.map(Vec::as_slice)
}
fn contributor_for_candidate(reference: &Reference, role: &ContributorRole) -> Option<Contributor> {
lookup_role_contributor(reference, &ResolvedRole::BuiltIn(role.clone()))
}
struct EffectivePrimaryResolution {
primary: EffectivePrimary,
names: Vec<crate::reference::FlatName>,
}
fn resolve_candidate(
candidate: &SubstituteKey,
reference: &Reference,
config: &citum_schema::options::Config,
locale: &citum_schema::locale::Locale,
) -> Option<EffectivePrimaryResolution> {
match candidate {
SubstituteKey::Contributor(candidate) => {
if let Some(role) = candidate.contributor.as_single() {
contributor_for_candidate(reference, role).and_then(|contributor| {
let names =
super::merged::semantic_contributor_names(&contributor, config, locale);
(!names.is_empty()).then(|| EffectivePrimaryResolution {
primary: EffectivePrimary::Contributor {
contributor: Box::new(contributor),
role: role.clone(),
},
names,
})
})
} else {
let component = TemplateContributor {
contributor: candidate.contributor.clone(),
..Default::default()
};
let names = super::merged::semantic_names(&component, reference, config, locale);
(!names.is_empty()).then(|| EffectivePrimaryResolution {
primary: EffectivePrimary::Merged(candidate.contributor.clone()),
names,
})
}
}
SubstituteKey::Field(field) => match field {
SubstituteField::CollectionEditor => contributor_for_candidate(
reference,
&ContributorRole::CollectionEditor,
)
.and_then(|contributor| {
let names = super::merged::semantic_contributor_names(&contributor, config, locale);
(!names.is_empty()).then(|| EffectivePrimaryResolution {
primary: EffectivePrimary::Contributor {
contributor: Box::new(contributor),
role: ContributorRole::CollectionEditor,
},
names,
})
}),
SubstituteField::Editor => reference.local_editor().and_then(|contributor| {
let names = super::merged::semantic_contributor_names(&contributor, config, locale);
(!names.is_empty()).then(|| EffectivePrimaryResolution {
primary: EffectivePrimary::Contributor {
contributor: Box::new(contributor),
role: ContributorRole::Editor,
},
names,
})
}),
SubstituteField::ParentSerial => {
resolve_parent_serial_title(reference).map(|title| EffectivePrimaryResolution {
primary: EffectivePrimary::Title {
title,
kind: TitleType::ParentSerial,
},
names: Vec::new(),
})
}
SubstituteField::Title => reference.title().map(|title| EffectivePrimaryResolution {
primary: EffectivePrimary::Title {
title,
kind: TitleType::Primary,
},
names: Vec::new(),
}),
SubstituteField::Translator => reference.translator().and_then(|contributor| {
let names = super::merged::semantic_contributor_names(&contributor, config, locale);
(!names.is_empty()).then(|| EffectivePrimaryResolution {
primary: EffectivePrimary::Contributor {
contributor: Box::new(contributor),
role: ContributorRole::Translator,
},
names,
})
}),
},
}
}
fn resolve_effective_primary(
reference: &Reference,
substitute: &citum_schema::options::Substitute,
config: &citum_schema::options::Config,
locale: &citum_schema::locale::Locale,
) -> Option<EffectivePrimaryResolution> {
if let Some(candidates) = exact_type_candidates(reference, substitute) {
for candidate in candidates {
if let Some(resolved) = resolve_candidate(candidate, reference, config, locale) {
return Some(resolved);
}
}
}
if let Some(contributor) = reference.author() {
let names = super::merged::semantic_contributor_names(&contributor, config, locale);
if !names.is_empty() {
return Some(EffectivePrimaryResolution {
primary: EffectivePrimary::Contributor {
contributor: Box::new(contributor),
role: ContributorRole::Author,
},
names,
});
}
}
substitute
.template
.iter()
.find_map(|candidate| resolve_candidate(candidate, reference, config, locale))
}
pub(crate) fn effective_primary(
reference: &Reference,
substitute: &citum_schema::options::Substitute,
config: &citum_schema::options::Config,
locale: &citum_schema::locale::Locale,
) -> Option<EffectivePrimary> {
resolve_effective_primary(reference, substitute, config, locale)
.map(|resolved| resolved.primary)
}
pub(crate) fn effective_primary_names(
reference: &Reference,
substitute: &citum_schema::options::Substitute,
config: &citum_schema::options::Config,
locale: &citum_schema::locale::Locale,
) -> Vec<crate::reference::FlatName> {
resolve_effective_primary(reference, substitute, config, locale)
.map(|resolved| resolved.names)
.unwrap_or_default()
}
pub(super) fn resolve_author_substitute<F: OutputFormat<Output = String>>(
component: &TemplateContributor,
hints: &ProcHints,
options: &RenderOptions<'_>,
reference: &Reference,
effective_rendering: &Rendering,
fmt: &F,
substitute: &citum_schema::options::Substitute,
) -> Option<ProcValues<F::Output>> {
match effective_primary(reference, substitute, &options.config, options.locale) {
Some(EffectivePrimary::Contributor { contributor, role }) => resolve_named_substitute(
&ResolvedRole::BuiltIn(role),
&contributor,
component,
hints,
options,
reference,
effective_rendering,
fmt,
substitute,
),
Some(EffectivePrimary::Merged(roles)) => {
let mut merged = component.clone();
merged.contributor = roles;
merged.merge = None;
let mut values = super::merged::values(
&merged,
reference,
hints,
options,
effective_rendering,
fmt,
)?;
values.substituted_key = Some("contributor:effective-primary".to_string());
Some(values)
}
Some(EffectivePrimary::Title { title, kind }) => {
let quote_in_citation = matches!(kind, TitleType::Primary)
&& match substitute.title_quote {
Some(SubstituteTitleQuoteMode::ByCategory) => {
resolve_category_quote(reference, options)
}
Some(SubstituteTitleQuoteMode::Always) | None => true,
};
Some(resolve_title_substitute(
title,
&kind,
component,
options,
reference,
fmt,
quote_in_citation,
))
}
None => None,
}
}