use crate::render::component::{ProcTemplate, render_component_with_format};
use crate::render::format::OutputFormat;
use crate::render::plain::PlainText;
use crate::render::punctuation::{
is_terminal_punctuation, resolve_punctuation_collision, strong_terminal_comma_policy,
};
use citum_schema::template::WrapPunctuation;
#[inline]
#[allow(
clippy::string_slice,
reason = "UTF-8 safe slicing based on char boundary checks"
)]
fn push_delimiter<F: OutputFormat<Output = String>>(
content: &mut String,
delim: &str,
punctuation_in_quote: bool,
strong_terminal_comma_policy: citum_schema::options::StrongTerminalCommaPolicy,
) {
let delim_first = delim.chars().next();
if punctuation_in_quote
&& delim_first == Some(',')
&& (content.ends_with('"') || content.ends_with('\u{201D}'))
{
let is_curly = content.ends_with('\u{201D}');
content.pop();
content.push(',');
content.push(if is_curly { '\u{201D}' } else { '"' });
content.push_str(&delim[1..]);
return;
}
let Some(first) = delim_first else {
content.push_str(delim);
return;
};
let Some(visible_last) = F::default().visible_text(content).chars().last() else {
content.push_str(delim);
return;
};
if !is_terminal_punctuation(visible_last) || !is_terminal_punctuation(first) {
content.push_str(delim);
} else if content.ends_with(visible_last) {
content.pop();
content.push_str(&resolve_punctuation_collision(
visible_last,
first,
strong_terminal_comma_policy,
));
content.push_str(&delim[first.len_utf8()..]);
} else {
if first == ','
&& crate::render::punctuation::is_strong_terminal(visible_last)
&& strong_terminal_comma_policy
== citum_schema::options::StrongTerminalCommaPolicy::KeepBoth
{
content.push_str(delim);
} else {
content.push_str(&delim[first.len_utf8()..]);
}
}
}
#[must_use]
pub fn citation_to_string(
proc_template: &ProcTemplate,
wrap: Option<&WrapPunctuation>,
prefix: Option<&str>,
suffix: Option<&str>,
delimiter: Option<&str>,
) -> String {
citation_to_string_with_format::<PlainText>(proc_template, wrap, prefix, suffix, delimiter)
}
#[must_use]
pub fn citation_to_string_with_format<F: OutputFormat<Output = String>>(
proc_template: &ProcTemplate,
wrap: Option<&WrapPunctuation>,
prefix: Option<&str>,
suffix: Option<&str>,
delimiter: Option<&str>,
) -> String {
let mut parts: Vec<String> = Vec::new();
for component in proc_template {
let rendered = render_component_with_format::<F>(component);
if !rendered.is_empty() {
parts.push(rendered);
}
}
let delim = delimiter.unwrap_or("");
let punctuation_in_quote = proc_template
.first()
.and_then(|c| c.config.as_ref())
.is_some_and(|cfg| cfg.punctuation_in_quote);
let strong_terminal_comma_policy = strong_terminal_comma_policy(
proc_template
.first()
.and_then(|component| component.config.as_deref()),
);
let mut content = String::new();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
push_delimiter::<F>(
&mut content,
delim,
punctuation_in_quote,
strong_terminal_comma_policy,
);
}
content.push_str(part);
}
let (open, close) = match wrap {
Some(WrapPunctuation::Parentheses) => ("(", ")"),
Some(WrapPunctuation::Brackets) => ("[", "]"),
Some(WrapPunctuation::Quotes) => ("\u{201C}", "\u{201D}"),
_ => (prefix.unwrap_or(""), suffix.unwrap_or("")),
};
let assembled = format!("{open}{content}{close}");
if proc_template
.first()
.is_some_and(crate::render::component::wants_latin_punctuation)
{
crate::render::component::remap_to_latin_punctuation(assembled)
} else {
assembled
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::*;
use crate::render::component::ProcTemplateComponent;
use crate::render::typst::Typst;
use citum_schema::options::Config;
use citum_schema::template::{
ContributorForm, ContributorRole, DateForm, DateVariable, Rendering, TemplateComponent,
TemplateContributor, TemplateDate, TemplateTitle, TitleType,
};
#[test]
fn test_citation_to_string() {
let template = vec![
ProcTemplateComponent {
template_component: TemplateComponent::Contributor(TemplateContributor {
contributor: ContributorRole::Author.into(),
form: ContributorForm::Short,
name_order: None,
delimiter: None,
rendering: Rendering::default(),
..Default::default()
}),
template_index: None,
value: "Kuhn".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: None,
bibliography_config: None,
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
},
ProcTemplateComponent {
template_component: TemplateComponent::Date(TemplateDate {
date: DateVariable::Issued,
form: DateForm::Year,
rendering: Rendering::default(),
..Default::default()
}),
template_index: None,
value: "1962".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: None,
bibliography_config: None,
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
},
];
let result = citation_to_string(
&template,
Some(&WrapPunctuation::Parentheses),
None,
None,
Some(", "),
);
assert_eq!(result, "(Kuhn, 1962)");
}
#[test]
fn test_punctuation_in_quote_moves_comma_inside_closing_quote() {
let config = Config {
punctuation_in_quote: true,
..Default::default()
};
let template = vec![
ProcTemplateComponent {
template_component: TemplateComponent::Title(TemplateTitle {
title: TitleType::Primary,
rendering: Rendering {
quote: Some(true),
..Default::default()
},
..Default::default()
}),
template_index: None,
value: "colon".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.clone().into()),
bibliography_config: None,
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
},
ProcTemplateComponent {
template_component: TemplateComponent::Date(TemplateDate {
date: DateVariable::Issued,
form: DateForm::Year,
rendering: Rendering::default(),
..Default::default()
}),
template_index: None,
value: "period".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.into()),
bibliography_config: None,
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
},
];
let plain = citation_to_string(&template, None, None, None, Some(", "));
let typst =
citation_to_string_with_format::<Typst>(&template, None, None, None, Some(", "));
assert_eq!(plain, "“colon,” period");
assert_eq!(typst, "“colon,” period");
}
#[test]
fn test_punctuation_outside_quotes_preserves_full_monty_matrix() {
let config = Config {
punctuation_in_quote: false,
..Default::default()
};
let suffixes = [
("colon", ":"),
("period", "."),
("semicolon", ";"),
("exclamation", "!"),
("question", "?"),
("comma", ","),
];
let delimiters = [
("ENDING IN COLON", ": "),
("ENDING IN PERIOD", ". "),
("ENDING IN SEMICOLON", "; "),
("ENDING IN EXCLAMATION", "! "),
("ENDING IN QUESTION", "? "),
("ENDING IN COMMA", ", "),
];
let mut lines = Vec::new();
for (heading, delimiter) in delimiters {
lines.push(heading.to_string());
for (value, suffix) in suffixes {
let template = full_monty_template(&config, heading, value, suffix);
lines.push(citation_to_string(
&template,
None,
None,
None,
Some(delimiter),
));
}
}
let plain = lines.join("\n");
let expected = r"ENDING IN COLON
“colon”: colon
“period”.: colon
“semicolon”; colon
“exclamation”! colon
“question”? colon
“comma”,: colon
ENDING IN PERIOD
“colon”: period
“period”. period
“semicolon”; period
“exclamation”! period
“question”? period
“comma”,. period
ENDING IN SEMICOLON
“colon”:; semicolon
“period”.; semicolon
“semicolon”; semicolon
“exclamation”!; semicolon
“question”?; semicolon
“comma”,; semicolon
ENDING IN EXCLAMATION
“colon”! exclamation
“period”.! exclamation
“semicolon”! exclamation
“exclamation”! exclamation
“question”?! exclamation
“comma”,! exclamation
ENDING IN QUESTION
“colon”? question
“period”.? question
“semicolon”? question
“exclamation”!? question
“question”? question
“comma”,? question
ENDING IN COMMA
“colon”:, comma
“period”., comma
“semicolon”;, comma
“exclamation”!, comma
“question”?, comma
“comma”, comma";
let mut typst_lines = Vec::new();
for (heading, delimiter) in delimiters {
typst_lines.push(heading.to_string());
for (value, suffix) in suffixes {
let template = full_monty_template(&config, heading, value, suffix);
typst_lines.push(citation_to_string_with_format::<Typst>(
&template,
None,
None,
None,
Some(delimiter),
));
}
}
let typst = typst_lines.join("\n");
assert_eq!(plain, expected);
assert_eq!(typst, expected);
}
#[test]
fn test_keep_terminal_policy_suppresses_comma_after_strong_terminal_marks() {
let config = Config {
punctuation: Some(citum_schema::options::PunctuationConfig {
strong_terminal_comma_policy: Some(
citum_schema::options::StrongTerminalCommaPolicy::KeepTerminal,
),
delimiter_suppressing_terminal_marks: None,
}),
..Default::default()
};
let cases = [
("question", "?", "“question”? comma"),
("exclamation", "!", "“exclamation”! comma"),
("ellipsis", "…", "“ellipsis”… comma"),
];
for (value, suffix, expected) in cases {
let template = full_monty_template(&config, "ENDING IN COMMA", value, suffix);
assert_eq!(
citation_to_string(&template, None, None, None, Some(", ")),
expected
);
}
}
#[test]
fn test_keep_both_policy_preserves_comma_after_ellipsis() {
let config = Config::default();
let template = full_monty_template(&config, "ENDING IN COMMA", "ellipsis", "…");
assert_eq!(
citation_to_string(&template, None, None, None, Some(", ")),
"“ellipsis”…, comma"
);
}
#[test]
fn test_strong_terminal_comma_policy_applies_across_typst_markup() {
let template = |policy| {
let config = Config {
punctuation: Some(citum_schema::options::PunctuationConfig {
strong_terminal_comma_policy: Some(policy),
delimiter_suppressing_terminal_marks: None,
}),
..Default::default()
};
vec![
ProcTemplateComponent {
template_component: TemplateComponent::Title(TemplateTitle {
rendering: Rendering {
emph: Some(true),
..Default::default()
},
..Default::default()
}),
value: "Title!".to_string(),
config: Some(config.clone().into()),
..Default::default()
},
ProcTemplateComponent {
template_component: TemplateComponent::Date(TemplateDate {
date: DateVariable::Issued,
form: DateForm::Year,
..Default::default()
}),
value: "next".to_string(),
config: Some(config.into()),
..Default::default()
},
]
};
assert_eq!(
citation_to_string_with_format::<Typst>(
&template(citum_schema::options::StrongTerminalCommaPolicy::KeepBoth),
None,
None,
None,
Some(", "),
),
"#emph[Title!], next"
);
assert_eq!(
citation_to_string_with_format::<Typst>(
&template(citum_schema::options::StrongTerminalCommaPolicy::KeepTerminal),
None,
None,
None,
Some(", "),
),
"#emph[Title!] next"
);
}
fn full_monty_template(
config: &Config,
heading: &str,
value: &str,
suffix: &str,
) -> Vec<ProcTemplateComponent> {
vec![
ProcTemplateComponent {
template_component: TemplateComponent::Title(TemplateTitle {
title: TitleType::Primary,
rendering: Rendering {
quote: Some(true),
suffix: Some(suffix.into()),
..Default::default()
},
..Default::default()
}),
template_index: None,
value: value.to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.clone().into()),
bibliography_config: None,
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
},
ProcTemplateComponent {
template_component: TemplateComponent::Date(TemplateDate {
date: DateVariable::Issued,
form: DateForm::Year,
rendering: Rendering::default(),
..Default::default()
}),
template_index: None,
value: {
#[allow(
clippy::string_slice,
reason = "heading is guaranteed to start with prefix"
)]
let val = heading["ENDING IN ".len()..].to_ascii_lowercase();
val
},
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.clone().into()),
bibliography_config: None,
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
},
]
}
}