use std::collections::HashMap;
use std::fmt::Write;
use crate::api::{AnnotationFormat, AnnotationStyle};
use crate::render::component::{ProcEntry, ProcTemplateComponent, render_component_with_format};
use crate::render::format::{OutputFormat, PunctuationPosition, RealizedPunctuation};
use crate::render::plain::PlainText;
use crate::render::punctuation::{
is_strong_terminal, move_punctuation_into_quote, strong_terminal_comma_policy,
};
use crate::render::rich_text::{render_djot_inline, render_org_inline};
use citum_schema::template::DelimiterPunctuation;
pub(crate) fn realize_bibliography_punctuation(
first: Option<&ProcTemplateComponent>,
punctuation: Option<&DelimiterPunctuation>,
default: DelimiterPunctuation,
position: PunctuationPosition,
) -> RealizedPunctuation<'static> {
let multilingual = first
.and_then(|c| c.config.as_ref())
.and_then(|config| config.multilingual.as_ref());
let (script, realization) = crate::values::punctuation_realization_context(
first.and_then(|c| c.item_language.as_deref()),
multilingual,
first.and_then(|c| c.quote_marks.punctuation_realization.as_ref()),
);
let owned;
let punctuation = if let Some(punctuation) = punctuation {
punctuation
} else {
owned = default;
&owned
};
crate::render::format::realize_punctuation_decomposed(
punctuation,
script,
realization.as_deref(),
position,
)
.into_owned()
}
fn default_separator_punctuation() -> DelimiterPunctuation {
DelimiterPunctuation::Custom(". ".to_string())
}
fn is_final_punctuation(c: char) -> bool {
matches!(c, '.' | ',' | ':' | ';' | '!' | '?' | '…')
}
fn is_sentence_ending_punctuation(c: char) -> bool {
matches!(c, '.' | '!' | '?' | '…')
}
fn first_visible_char<F: OutputFormat<Output = String>>(input: &str) -> Option<char> {
F::default().visible_text(input).chars().next()
}
fn last_visible_non_space_char<F: OutputFormat<Output = String>>(input: &str) -> Option<char> {
F::default()
.visible_text(input)
.chars()
.rev()
.find(|ch| !ch.is_whitespace())
}
fn ends_with_sentence_ending_visible_punctuation<F: OutputFormat<Output = String>>(
input: &str,
) -> bool {
let visible = F::default().visible_text(input);
let mut chars = visible.chars().rev().filter(|ch| !ch.is_whitespace());
match chars.next() {
Some(ch) if is_sentence_ending_punctuation(ch) => true,
Some('"' | '\u{201D}') => chars.next().is_some_and(is_sentence_ending_punctuation),
_ => false,
}
}
#[must_use]
pub(crate) fn component_starts_new_sentence<F: OutputFormat<Output = String>>(
entry_output: &str,
rendered: &str,
default_separator: &RealizedPunctuation<'_>,
punctuation_in_quote: bool,
close_quote: &str,
) -> bool {
if entry_output.is_empty() {
return true;
}
let first_char = first_visible_char::<F>(rendered).unwrap_or(' ');
let starts_with_separator = matches!(first_char, ',' | ';' | ':' | ' ' | '.' | '(');
if starts_with_separator {
return false;
}
if ends_with_sentence_ending_visible_punctuation::<F>(entry_output) {
return true;
}
let last_char = entry_output.chars().last().unwrap_or(' ');
let trimmed_last = last_visible_non_space_char::<F>(entry_output).unwrap_or(' ');
if !last_char.is_whitespace()
&& !first_char.is_whitespace()
&& !is_final_punctuation(trimmed_last)
&& default_separator
.core()
.is_some_and(is_sentence_ending_punctuation)
{
return true;
}
punctuation_in_quote
&& default_separator.core() == Some('.')
&& ends_with_close_quote(entry_output, close_quote)
}
fn ends_with_close_quote(text: &str, close_quote: &str) -> bool {
(!close_quote.is_empty() && text.ends_with(close_quote))
|| (close_quote != "\"" && text.ends_with('"'))
}
#[must_use]
pub fn refs_to_string(proc_entries: Vec<ProcEntry>) -> String {
refs_to_string_with_format::<PlainText>(proc_entries, None, None)
}
#[must_use]
pub fn render_entry_body_with_format<F: OutputFormat<Output = String>>(
entry: &ProcEntry,
) -> String {
render_entry_body_components_with_format::<F>(&entry.template)
}
#[allow(
clippy::too_many_arguments,
reason = "threads shared per-entry punctuation state"
)]
fn append_or_suppress<F: OutputFormat<Output = String>>(
entry_output: &mut String,
rendered: &str,
suppress: bool,
default_separator: &RealizedPunctuation<'_>,
punctuation_in_quote: bool,
strong_terminal_comma_policy: citum_schema::options::StrongTerminalCommaPolicy,
close_quote: &str,
) {
if suppress {
entry_output.push_str(rendered);
} else {
append_rendered_component::<F>(
entry_output,
rendered,
default_separator,
punctuation_in_quote,
strong_terminal_comma_policy,
close_quote,
);
}
}
fn trim_trailing_only_suffix<F: OutputFormat<Output = String>>(
last_component: &ProcTemplateComponent,
rendered: String,
is_truly_last: bool,
) -> String {
if is_truly_last {
return rendered;
}
let mut trimmed_component = last_component.clone();
let rendering = trimmed_component.template_component.rendering_mut();
rendering.suffix = None;
if let Some(ref mut wrap_config) = rendering.wrap {
wrap_config.inner_suffix = None;
}
trimmed_component.suffix = None;
render_component_with_format::<F>(&trimmed_component)
}
#[must_use]
pub(crate) fn render_entry_body_components_with_format<F: OutputFormat<Output = String>>(
proc_template: &[ProcTemplateComponent],
) -> String {
let mut entry_output = String::new();
let mut pending_component: Option<(
usize,
&crate::render::component::ProcTemplateComponent,
String,
)> = None;
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 close_quote = proc_template
.first()
.map(|c| c.quote_marks.close.as_str())
.unwrap_or("\u{201D}");
let first_component = proc_template.first();
let default_separator = realize_bibliography_punctuation(
first_component,
first_component
.and_then(|c| c.bibliography_config.as_ref())
.and_then(|bib| bib.separator.as_ref()),
default_separator_punctuation(),
PunctuationPosition::Separator,
);
let mut suppress_next_separator = false;
for (index, component) in proc_template.iter().enumerate() {
let rendered = render_component_with_format::<F>(component);
if rendered.is_empty() {
continue;
}
if let Some((_, previous_component, previous)) =
pending_component.replace((index, component, rendered))
{
append_or_suppress::<F>(
&mut entry_output,
&previous,
suppress_next_separator,
&default_separator,
punctuation_in_quote,
strong_terminal_comma_policy,
close_quote,
);
suppress_next_separator = previous_component.label_only;
}
}
if let Some((last_index, last_component, rendered)) = pending_component {
let final_rendered = trim_trailing_only_suffix::<F>(
last_component,
rendered,
last_index + 1 == proc_template.len(),
);
append_or_suppress::<F>(
&mut entry_output,
&final_rendered,
suppress_next_separator,
&default_separator,
punctuation_in_quote,
strong_terminal_comma_policy,
close_quote,
);
}
let bib_cfg = proc_template
.first()
.and_then(|c| c.bibliography_config.as_ref());
if let Some(entry_suffix) = bib_cfg.and_then(|bib| bib.entry_suffix.as_ref()) {
let realized_suffix = realize_bibliography_punctuation(
first_component,
Some(entry_suffix),
DelimiterPunctuation::None,
PunctuationPosition::Suffix,
);
if !realized_suffix.is_empty() {
let suffix = realized_suffix.text();
let suffix_core = realized_suffix.core().unwrap_or('.');
let suppress = match terminal_link::<F>(&entry_output) {
TerminalLink::Doi => !bib_cfg.is_some_and(|b| b.entry_suffix_after_doi),
TerminalLink::Url => !bib_cfg.is_some_and(|b| b.entry_suffix_after_url),
TerminalLink::None => false,
};
let moved_into_quote = !suppress
&& suffix_core == '.'
&& realized_suffix.tail().is_empty()
&& punctuation_in_quote
&& !entry_output.ends_with(suffix_core)
&& move_punctuation_into_quote(&mut entry_output, '.', close_quote);
if !moved_into_quote && !suppress && !entry_output.ends_with(suffix_core) {
entry_output.push_str(suffix);
}
}
}
cleanup_dangling_punctuation::<F>(&mut entry_output, strong_terminal_comma_policy);
entry_output
}
#[allow(
clippy::string_slice,
reason = "UTF-8 safe slicing based on char boundary checks"
)]
pub(crate) fn append_rendered_component<F: OutputFormat<Output = String>>(
entry_output: &mut String,
rendered: &str,
default_separator: &RealizedPunctuation<'_>,
punctuation_in_quote: bool,
strong_terminal_comma_policy: citum_schema::options::StrongTerminalCommaPolicy,
close_quote: &str,
) {
if !entry_output.is_empty() {
let last_char = entry_output.chars().last().unwrap_or(' ');
let first_char = first_visible_char::<F>(rendered).unwrap_or(' ');
let sep_first_char = default_separator.core().unwrap_or('.');
let trimmed_last = last_visible_non_space_char::<F>(entry_output).unwrap_or(' ');
let ends_with_punctuation = is_final_punctuation(trimmed_last);
let starts_with_separator = matches!(first_char, ',' | ';' | ':' | ' ' | '.' | '(');
let raw_first_char = rendered.chars().next();
if punctuation_in_quote
&& raw_first_char == Some(first_char)
&& matches!(first_char, '.' | ',')
&& move_punctuation_into_quote(entry_output, first_char, close_quote)
{
let remainder = &rendered[first_char.len_utf8()..];
entry_output.push_str(remainder);
return;
}
if starts_with_separator {
if first_char == '(' && !last_char.is_whitespace() && last_char != '[' {
entry_output.push(' ');
}
} else if ends_with_punctuation {
if sep_first_char == ',' && is_strong_terminal(trimmed_last) {
if strong_terminal_comma_policy
== citum_schema::options::StrongTerminalCommaPolicy::KeepBoth
{
entry_output.push_str(default_separator.text());
} else {
entry_output.push_str(default_separator.tail());
}
} else if !last_char.is_whitespace() {
entry_output.push(' ');
}
} else if punctuation_in_quote
&& (sep_first_char == '.' || sep_first_char == ',')
&& move_punctuation_into_quote(entry_output, sep_first_char, close_quote)
{
entry_output.push_str(default_separator.tail());
} else if !last_char.is_whitespace() && !first_char.is_whitespace() {
entry_output.push_str(default_separator.text());
} else if !last_char.is_whitespace()
&& first_char.is_whitespace()
&& default_separator.core() == Some('.')
&& !ends_with_punctuation
{
entry_output.push('.');
}
}
let _ = write!(entry_output, "{rendered}");
}
#[must_use]
pub fn refs_to_string_with_format<F: OutputFormat<Output = String>>(
proc_entries: Vec<ProcEntry>,
annotations: Option<&HashMap<String, String>>,
annotation_style: Option<&AnnotationStyle>,
) -> String {
refs_to_string_slice_with_format::<F>(&proc_entries, annotations, annotation_style)
}
#[must_use]
pub fn refs_to_string_slice_with_format<F: OutputFormat<Output = String>>(
proc_entries: &[ProcEntry],
annotations: Option<&HashMap<String, String>>,
annotation_style: Option<&AnnotationStyle>,
) -> String {
let fmt = F::default();
let mut rendered_entries = Vec::with_capacity(proc_entries.len());
for entry in proc_entries {
let mut entry_output = render_entry_body_with_format::<F>(entry);
let proc_template = &entry.template;
if let Some(annotations) = annotations
&& let Some(annotation_text) = annotations.get(&entry.id)
{
let style = annotation_style.cloned().unwrap_or_default();
let rendered = match style.format {
AnnotationFormat::Djot => render_djot_inline(annotation_text, &fmt),
AnnotationFormat::Plain => annotation_text.clone(),
AnnotationFormat::Org => render_org_inline(annotation_text, &fmt),
};
let rendered = rendered.trim();
if !rendered.is_empty() {
let annotation_output = fmt.text(rendered);
entry_output.push_str(&fmt.annotation(annotation_output));
}
}
if fmt.visible_text(&entry_output).trim().is_empty() {
continue;
}
let entry_url = proc_template
.first()
.and_then(|c| c.config.as_ref())
.and_then(|cfg| cfg.links.as_ref())
.and_then(|links| {
use citum_schema::options::LinkAnchor;
if matches!(links.anchor, Some(LinkAnchor::Entry)) {
proc_template.iter().find_map(|c| c.url.as_deref())
} else {
None
}
});
rendered_entries.push(fmt.entry(&entry.id, entry_output, entry_url, &entry.metadata));
}
fmt.finish(fmt.bibliography(rendered_entries))
}
#[derive(PartialEq)]
enum TerminalLink {
None,
Url,
Doi,
}
fn terminal_link<F: OutputFormat<Output = String>>(output: &str) -> TerminalLink {
let visible = F::default().visible_text(output);
let trimmed = visible.trim_end_matches('.').trim_end();
let last = trimmed.rsplit_once(' ').map_or(trimmed, |(_, last)| last);
let is_doi = last.contains("doi.org/")
|| last.starts_with("doi:")
|| (last.starts_with("10.") && last.contains('/'));
if is_doi {
TerminalLink::Doi
} else if last.starts_with("https://") || last.starts_with("http://") {
TerminalLink::Url
} else {
TerminalLink::None
}
}
const DANGLING_PUNCTUATION_PATTERNS: [(&str, &str); 13] = [
(", .", "."),
(", ,", ","),
(": .", "."),
("; .", "."),
(" ,", ","),
(" ;", ";"),
(" :", ":"),
(" .", "."),
(", ", ", "),
(". .", "."),
(".. ", ". "),
("..", "."),
(" ", " "), ];
const STRONG_TERMINAL_COMMA_PATTERNS: [(&str, &str); 3] = [("!,", "!"), ("?,", "?"), ("…,", "…")];
#[allow(
clippy::string_slice,
reason = "byte ranges come from OutputFormat::visible_runs, which always yields char boundaries"
)]
fn cleanup_dangling_punctuation<F: OutputFormat<Output = String>>(
output: &mut String,
strong_terminal_comma_policy: citum_schema::options::StrongTerminalCommaPolicy,
) {
let fmt = F::default();
loop {
let runs = fmt.visible_runs(output);
let mut visible = String::with_capacity(output.len());
let mut raw_pos = Vec::with_capacity(output.len());
for run in &runs {
if let Some(slice) = output.get(run.clone()) {
visible.push_str(slice);
raw_pos.extend(run.clone());
}
}
let locale_pattern = if strong_terminal_comma_policy
== citum_schema::options::StrongTerminalCommaPolicy::KeepTerminal
{
STRONG_TERMINAL_COMMA_PATTERNS
.iter()
.find_map(|&(pat, repl)| visible.find(pat).map(|idx| (pat, repl, idx)))
} else {
None
};
let Some((pat, replacement, visible_at)) = locale_pattern.or_else(|| {
DANGLING_PUNCTUATION_PATTERNS
.iter()
.find_map(|&(pat, repl)| visible.find(pat).map(|idx| (pat, repl, idx)))
}) else {
break;
};
let matched_raw_positions: Vec<usize> = (visible_at..visible_at + pat.len())
.filter_map(|k| raw_pos.get(k).copied())
.collect();
if matched_raw_positions.len() != pat.len() {
break;
}
apply_minimal_raw_edit(output, &matched_raw_positions, replacement);
}
}
fn apply_minimal_raw_edit(output: &mut String, positions: &[usize], replacement: &str) {
let Some((&front, rest)) = positions.split_first() else {
return;
};
let drop: std::collections::HashSet<usize> = rest.iter().copied().collect();
let mut new_output = String::with_capacity(output.len() + replacement.len());
for (pos, ch) in output.char_indices() {
if pos == front {
new_output.push_str(replacement);
} else if !drop.contains(&pos) {
new_output.push(ch);
}
}
*output = new_output;
}
#[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::djot::Djot;
use crate::render::html::Html;
use crate::render::latex::Latex;
use crate::render::markdown::Markdown;
use crate::render::typst::Typst;
use citum_schema::template::{Rendering, TemplateComponent, WrapConfig, WrapPunctuation};
use rstest::rstest;
fn sep(text: &str) -> RealizedPunctuation<'static> {
RealizedPunctuation::new(text.to_string().into())
}
#[test]
fn terminal_link_classifies_url_doi_and_plain_text() {
assert!(
terminal_link::<PlainText>("Author. Title. https://doi.org/10.1/x")
== TerminalLink::Doi
);
assert!(terminal_link::<PlainText>("Author. Title. doi:10.1038/abc") == TerminalLink::Doi);
assert!(terminal_link::<PlainText>("Author. Title. doi: 10.1038/abc") == TerminalLink::Doi);
assert!(
terminal_link::<PlainText>("Author. Title. https://example.com/page")
== TerminalLink::Url
);
assert!(terminal_link::<PlainText>("Author. Title. Publisher, 2020") == TerminalLink::None);
assert!(
terminal_link::<PlainText>("Author. https://example.com/page.") == TerminalLink::Url
);
}
#[test]
fn test_component_starts_new_sentence_at_entry_start() {
assert!(component_starts_new_sentence::<PlainText>(
"",
"Edited by Grimm, Jacob",
&sep(". "),
false,
"\u{201D}"
));
}
#[test]
fn test_component_starts_new_sentence_after_period() {
assert!(component_starts_new_sentence::<PlainText>(
"Collected Essays.",
"edited by Grimm, Jacob",
&sep(". "),
false,
"\u{201D}"
));
}
#[test]
fn test_component_does_not_start_new_sentence_after_colon() {
assert!(!component_starts_new_sentence::<PlainText>(
"Collected Essays:",
"edited by Grimm, Jacob",
&sep(". "),
false,
"\u{201D}"
));
}
#[test]
fn test_bibliography_separator_suppression() {
use citum_schema::options::{BibliographyConfig, Config};
let config = Config::default();
let bibliography_config = BibliographyConfig {
separator: Some(". ".into()),
entry_suffix: Some(String::new().into()),
..Default::default()
};
let c1 = ProcTemplateComponent {
template_component: TemplateComponent::Variable(
citum_schema::template::TemplateVariable {
variable: citum_schema::template::SimpleVariable::Publisher,
rendering: Rendering::default(),
..Default::default()
},
),
template_index: None,
value: "Publisher1".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.clone().into()),
bibliography_config: Some(bibliography_config.clone().into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let c2 = ProcTemplateComponent {
template_component: TemplateComponent::Variable(
citum_schema::template::TemplateVariable {
variable: citum_schema::template::SimpleVariable::PublisherPlace,
rendering: Rendering {
prefix: Some(". ".into()),
..Default::default()
},
..Default::default()
},
),
template_index: None,
value: "Place".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.into()),
bibliography_config: Some(bibliography_config.into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let entries = vec![ProcEntry {
id: "id1".to_string(),
template: vec![c1, c2],
metadata: crate::render::format::ProcEntryMetadata::default(),
}];
let result = refs_to_string(entries);
assert_eq!(result, "Publisher1. Place");
}
#[rstest]
#[case::label_only_component_suppresses_the_separator(true, "[15]Title Text")]
#[case::ordinary_first_component_keeps_the_separator(false, "[15]. Title Text")]
fn given_a_leading_component_when_label_only_flag_varies_then_separator_follows_it(
#[case] label_only: bool,
#[case] expected: &str,
) {
use citum_schema::options::{BibliographyConfig, Config};
let config = Config::default();
let bibliography_config = BibliographyConfig {
separator: Some(". ".into()),
entry_suffix: Some(String::new().into()),
..Default::default()
};
let label = ProcTemplateComponent {
template_component: TemplateComponent::Number(citum_schema::template::TemplateNumber {
number: citum_schema::template::NumberVariable::CitationNumber,
rendering: Rendering::default(),
..Default::default()
}),
template_index: None,
value: "[15]".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.clone().into()),
bibliography_config: Some(bibliography_config.clone().into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: true,
label_only,
};
let content = ProcTemplateComponent {
template_component: TemplateComponent::Title(citum_schema::template::TemplateTitle {
title: citum_schema::template::TitleType::Primary,
rendering: Rendering::default(),
..Default::default()
}),
template_index: None,
value: "Title Text".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.into()),
bibliography_config: Some(bibliography_config.into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let entries = vec![ProcEntry {
id: "id1".to_string(),
template: vec![label, content],
metadata: crate::render::format::ProcEntryMetadata::default(),
}];
let result = refs_to_string(entries);
assert_eq!(result, expected);
}
#[test]
fn test_no_suppression_after_parenthesis() {
use citum_schema::options::{BibliographyConfig, Config};
let config = Config::default();
let bibliography_config = BibliographyConfig {
separator: Some(", ".into()),
entry_suffix: Some(String::new().into()),
..Default::default()
};
let c1 = ProcTemplateComponent {
template_component: TemplateComponent::Contributor(
citum_schema::template::TemplateContributor {
contributor: citum_schema::template::ContributorRole::Editor.into(),
rendering: Rendering {
wrap: Some(WrapConfig {
punctuation: WrapPunctuation::Parentheses,
inner_prefix: None,
inner_suffix: None,
}),
..Default::default()
},
..Default::default()
},
),
template_index: None,
value: "Eds.".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.clone().into()),
bibliography_config: Some(bibliography_config.clone().into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let c2 = ProcTemplateComponent {
template_component: TemplateComponent::Title(citum_schema::template::TemplateTitle {
title: citum_schema::template::TitleType::Primary,
rendering: Rendering::default(),
..Default::default()
}),
template_index: None,
value: "Title".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.into()),
bibliography_config: Some(bibliography_config.into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let entries = vec![ProcEntry {
id: "id1".to_string(),
template: vec![c1, c2],
metadata: crate::render::format::ProcEntryMetadata::default(),
}];
let result = refs_to_string(entries);
assert_eq!(result, "(Eds.), Title");
}
#[test]
fn test_punctuation_in_quote_pulls_comma_inside_closing_quote() {
let mut entry_output = String::from("\u{201C}Deep Learning\u{201D}");
append_rendered_component::<PlainText>(
&mut entry_output,
"Nature",
&sep(", "),
true,
Default::default(),
"\u{201D}",
);
assert_eq!(entry_output, "\u{201C}Deep Learning,\u{201D} Nature");
}
#[test]
fn test_punctuation_in_quote_pulls_period_inside_closing_quote() {
let mut entry_output = String::from("\u{201C}Deep Learning\u{201D}");
append_rendered_component::<PlainText>(
&mut entry_output,
"Nature",
&sep(". "),
true,
Default::default(),
"\u{201D}",
);
assert_eq!(entry_output, "\u{201C}Deep Learning.\u{201D} Nature");
}
#[test]
fn test_punctuation_in_quote_disabled_leaves_comma_outside_quote() {
let mut entry_output = String::from("\u{201C}Deep Learning\u{201D}");
append_rendered_component::<PlainText>(
&mut entry_output,
"Nature",
&sep(", "),
false,
Default::default(),
"\u{201D}",
);
assert_eq!(entry_output, "\u{201C}Deep Learning\u{201D}, Nature");
}
#[rstest]
#[case('.', "period")]
#[case(',', "comma")]
fn append_rendered_component_moves_next_component_own_leading_mark_inside_closing_quote(
#[case] mark: char,
#[case] label: &str,
) {
let mut entry_output = "\u{201C}The Universe in a Nutshell\u{201D}".to_string();
let rendered = format!("{mark} Aired September 28");
append_rendered_component::<PlainText>(
&mut entry_output,
&rendered,
&sep(", "),
true,
Default::default(),
"\u{201D}",
);
assert_eq!(
entry_output,
format!("\u{201C}The Universe in a Nutshell{mark}\u{201D} Aired September 28"),
"{label}-led component text should move inside the quote"
);
}
#[test]
fn append_rendered_component_leaves_next_component_own_leading_mark_outside_quote_when_disabled()
{
let mut entry_output = "\u{201C}The Universe in a Nutshell\u{201D}".to_string();
append_rendered_component::<PlainText>(
&mut entry_output,
". Aired September 28",
&sep(", "),
false,
Default::default(),
"\u{201D}",
);
assert_eq!(
entry_output,
"\u{201C}The Universe in a Nutshell\u{201D}. Aired September 28"
);
}
#[test]
fn append_rendered_component_moves_mark_inside_a_locale_specific_close_quote() {
let mut entry_output = "«Titre»".to_string();
append_rendered_component::<PlainText>(
&mut entry_output,
"Suite",
&sep(", "),
true,
Default::default(),
"»",
);
assert_eq!(entry_output, "«Titre,» Suite");
}
#[test]
fn strong_terminal_comma_policy_controls_bibliography_separator() {
for terminal in ['!', '?', '…'] {
let mut keep_both = format!("Title{terminal}");
append_rendered_component::<PlainText>(
&mut keep_both,
"Next",
&sep(", "),
false,
citum_schema::options::StrongTerminalCommaPolicy::KeepBoth,
"\u{201D}",
);
assert_eq!(keep_both, format!("Title{terminal}, Next"));
let mut keep_terminal = format!("Title{terminal}");
append_rendered_component::<PlainText>(
&mut keep_terminal,
"Next",
&sep(", "),
false,
citum_schema::options::StrongTerminalCommaPolicy::KeepTerminal,
"\u{201D}",
);
assert_eq!(keep_terminal, format!("Title{terminal} Next"));
}
}
#[test]
fn keep_terminal_policy_preserves_bibliography_separator_tail() {
let mut entry_output = "Title?".to_string();
append_rendered_component::<PlainText>(
&mut entry_output,
"Next",
&sep(",\u{00A0}"),
false,
citum_schema::options::StrongTerminalCommaPolicy::KeepTerminal,
"\u{201D}",
);
assert_eq!(entry_output, "Title?\u{00A0}Next");
}
#[test]
fn test_html_bibliography_structure() {
use crate::render::html::Html;
use citum_schema::template::TemplateTerm;
let c1 = ProcTemplateComponent {
template_component: TemplateComponent::Term(TemplateTerm::default()),
value: "Reference Content".to_string(),
..Default::default()
};
let entries = vec![ProcEntry {
id: "ref-1".to_string(),
template: vec![c1],
metadata: crate::render::format::ProcEntryMetadata::default(),
}];
let result = refs_to_string_with_format::<Html>(entries, None, None);
assert_eq!(
result,
"<div class=\"citum-bibliography\">\n<div class=\"citum-entry\" id=\"ref-ref-1\">Reference Content</div>\n</div>"
);
}
#[test]
fn test_component_suffix_preserved_elsevier_harvard() {
use citum_schema::options::{BibliographyConfig, Config};
let config = Config::default();
let bibliography_config = BibliographyConfig {
separator: Some(". ".into()),
entry_suffix: Some(".".into()),
..Default::default()
};
let c1 = ProcTemplateComponent {
template_component: TemplateComponent::Contributor(
citum_schema::template::TemplateContributor {
contributor: citum_schema::template::ContributorRole::Author.into(),
rendering: Rendering {
suffix: Some(", ".into()),
..Default::default()
},
..Default::default()
},
),
template_index: None,
value: "Hawking, S.".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.clone().into()),
bibliography_config: Some(bibliography_config.clone().into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let c2 = ProcTemplateComponent {
template_component: TemplateComponent::Date(citum_schema::template::TemplateDate {
date: citum_schema::template::DateVariable::Issued,
rendering: Rendering {
suffix: Some(".".into()),
..Default::default()
},
..Default::default()
}),
template_index: None,
value: "1988".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.into()),
bibliography_config: Some(bibliography_config.into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let entries = vec![ProcEntry {
id: "hawking1988".to_string(),
template: vec![c1, c2],
metadata: crate::render::format::ProcEntryMetadata::default(),
}];
let result = refs_to_string(entries);
assert_eq!(result, "Hawking, S., 1988.");
}
#[test]
fn test_terminal_component_suffix_suppressed_when_following_component_is_empty() {
use citum_schema::options::{BibliographyConfig, Config};
let config = Config::default();
let bibliography_config = BibliographyConfig {
separator: Some(". ".into()),
entry_suffix: Some(String::new().into()),
..Default::default()
};
let date = ProcTemplateComponent {
template_component: TemplateComponent::Date(citum_schema::template::TemplateDate {
date: citum_schema::template::DateVariable::Issued,
rendering: Rendering {
suffix: Some(", ".into()),
..Default::default()
},
..Default::default()
}),
template_index: None,
value: "2024".to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.clone().into()),
bibliography_config: Some(bibliography_config.clone().into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let pages = ProcTemplateComponent {
template_component: TemplateComponent::Number(citum_schema::template::TemplateNumber {
number: citum_schema::template::NumberVariable::Pages,
rendering: Rendering::default(),
..Default::default()
}),
template_index: None,
value: String::new(),
prefix: None,
suffix: None,
ref_type: None,
config: Some(config.into()),
bibliography_config: Some(bibliography_config.into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let result = refs_to_string(vec![ProcEntry {
id: "book-without-pages".to_string(),
template: vec![date, pages],
metadata: crate::render::format::ProcEntryMetadata::default(),
}]);
assert_eq!(result, "2024");
}
#[allow(
clippy::too_many_lines,
reason = "rendering fixture exercises a full punctuation case"
)]
#[test]
fn test_html_separator_logic_uses_visible_punctuation() {
use crate::render::html::Html;
use citum_schema::options::{BibliographyConfig, Config};
use citum_schema::template::{
NumberVariable, SimpleVariable, TemplateNumber, TemplateVariable,
};
let config = Config {
..Default::default()
};
let bibliography_config = BibliographyConfig {
separator: Some(". ".into()),
entry_suffix: Some(String::new().into()),
..Default::default()
};
let volume_issue = ProcTemplateComponent {
template_component: TemplateComponent::Number(TemplateNumber {
number: NumberVariable::Volume,
rendering: Rendering {
emph: Some(true),
..Default::default()
},
..Default::default()
}),
template_index: None,
value: "322(10)".to_string(),
prefix: None,
suffix: None,
ref_type: Some("article-journal".to_string()),
config: Some(config.clone().into()),
bibliography_config: Some(bibliography_config.clone().into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let pages = ProcTemplateComponent {
template_component: TemplateComponent::Number(TemplateNumber {
number: NumberVariable::Pages,
rendering: Rendering {
prefix: Some(", ".into()),
suffix: Some(".".into()),
..Default::default()
},
..Default::default()
}),
template_index: None,
value: "891–921".to_string(),
prefix: None,
suffix: None,
ref_type: Some("article-journal".to_string()),
config: Some(config.clone().into()),
bibliography_config: Some(bibliography_config.clone().into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let doi = ProcTemplateComponent {
template_component: TemplateComponent::Variable(TemplateVariable {
variable: SimpleVariable::Doi,
rendering: Rendering {
prefix: Some("https://doi.org/".into()),
..Default::default()
},
..Default::default()
}),
template_index: None,
value: "10.1002/andp.19053221004".to_string(),
prefix: None,
suffix: None,
ref_type: Some("article-journal".to_string()),
config: Some(config.into()),
bibliography_config: Some(bibliography_config.into()),
url: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
};
let result = refs_to_string_with_format::<Html>(
vec![ProcEntry {
id: "einstein1905".to_string(),
template: vec![volume_issue, pages, doi],
metadata: crate::render::format::ProcEntryMetadata::default(),
}],
None,
None,
);
assert!(
!result.contains("322(10)</i></span>. <span class=\"citum-pages\">, 891–921."),
"separator should not inject a period before pages: {result}"
);
assert!(
!result.contains("891–921.</span>. <span class=\"citum-doi\">"),
"separator should not inject a period before DOI: {result}"
);
assert!(
result.contains(
"<span class=\"citum-pages\">, 891–921.</span><span class=\"citum-doi\">"
) || result.contains(
"<span class=\"citum-pages\">, 891–921.</span> <span class=\"citum-doi\">"
),
"HTML output should preserve pages punctuation without duplicate separators: {result}"
);
}
fn make_entry(id: &str, value: &str) -> ProcEntry {
ProcEntry {
id: id.to_string(),
template: vec![ProcTemplateComponent {
template_component: TemplateComponent::Variable(
citum_schema::template::TemplateVariable {
variable: citum_schema::template::SimpleVariable::Publisher,
rendering: Rendering::default(),
..Default::default()
},
),
template_index: None,
value: value.to_string(),
prefix: None,
suffix: None,
ref_type: None,
config: None,
url: None,
bibliography_config: None,
item_language: None,
quote_marks: Default::default(),
sentence_initial: false,
pre_formatted: false,
label_only: false,
}],
metadata: crate::render::format::ProcEntryMetadata::default(),
}
}
#[test]
fn test_annotation_appended_after_entry() {
let mut annotations = HashMap::new();
annotations.insert(
"ref1".to_string(),
"A useful overview of the topic.".to_string(),
);
let style = AnnotationStyle::default();
let result = refs_to_string_with_format::<PlainText>(
vec![make_entry("ref1", "Some Publisher")],
Some(&annotations),
Some(&style),
);
assert!(
result.contains("Some Publisher"),
"entry text should appear: {result}"
);
assert!(
result.contains("A useful overview of the topic."),
"annotation should appear: {result}"
);
assert!(
result.contains("\n\nA useful overview"),
"annotation should be separated by blank line: {result}"
);
}
#[test]
fn test_no_annotation_when_id_absent() {
let mut annotations = HashMap::new();
annotations.insert(
"other-ref".to_string(),
"Annotation for someone else.".to_string(),
);
let style = AnnotationStyle::default();
let result = refs_to_string_with_format::<PlainText>(
vec![make_entry("ref1", "Some Publisher")],
Some(&annotations),
Some(&style),
);
assert!(
!result.contains("Annotation for someone else."),
"annotation for a different ref should not appear: {result}"
);
}
#[test]
fn test_no_annotations_when_none_supplied() {
let result = refs_to_string_with_format::<PlainText>(
vec![make_entry("ref1", "Some Publisher")],
None,
None,
);
assert!(
result.contains("Some Publisher"),
"entry should render normally: {result}"
);
let blank_line_count = result.matches("\n\n").count();
assert!(
blank_line_count <= 1,
"should not have spurious blank lines: {result}"
);
}
#[test]
fn visible_text_is_identical_across_backends_for_the_same_logical_content() {
assert_eq!(
Html.visible_text(&Html.emph("Title.".to_string())),
"Title."
);
assert_eq!(
Latex.visible_text(&Latex.emph("Title.".to_string())),
"Title."
);
assert_eq!(
Typst.visible_text(&Typst.emph("Title.".to_string())),
"Title."
);
assert_eq!(
Markdown.visible_text(&Markdown.emph("Title.".to_string())),
"Title."
);
assert_eq!(
Djot.visible_text(&Djot.emph("Title.".to_string())),
"Title."
);
}
#[test]
fn append_rendered_component_does_not_double_punctuate_an_emphasized_latex_title() {
let mut entry_output = Latex.emph("Title.".to_string());
append_rendered_component::<Latex>(
&mut entry_output,
"Next",
&sep(". "),
false,
Default::default(),
"\u{201D}",
);
assert_eq!(Latex.visible_text(&entry_output), "Title. Next");
assert!(
!Latex.visible_text(&entry_output).contains(".."),
"no doubled period, got: {entry_output}"
);
}
#[test]
fn cleanup_dangling_punctuation_collapses_across_a_latex_markup_boundary() {
let mut output = r"\emph{Title.}. Next".to_string();
cleanup_dangling_punctuation::<Latex>(&mut output, Default::default());
assert_eq!(Latex.visible_text(&output), "Title. Next");
assert!(
output.contains(r"\emph{Title"),
"emph markup must survive: {output}"
);
}
#[test]
fn cleanup_dangling_punctuation_never_touches_the_href_target() {
let mut output = r"\href{https://example.com/a, .b}{Link}, .".to_string();
cleanup_dangling_punctuation::<Latex>(&mut output, Default::default());
assert!(
output.contains("https://example.com/a, .b"),
"href target must be untouched: {output}"
);
assert_eq!(Latex.visible_text(&output), "Link.");
}
#[test]
fn cleanup_dangling_punctuation_collapses_across_a_typst_markup_boundary() {
let mut output = "#emph[Title.]. Next".to_string();
cleanup_dangling_punctuation::<Typst>(&mut output, Default::default());
assert_eq!(Typst.visible_text(&output), "Title. Next");
assert!(
output.contains("#emph[Title"),
"emph markup must survive: {output}"
);
}
#[test]
fn cleanup_dangling_punctuation_applies_locale_policy_across_markup() {
let mut latex = r"\emph{Title!}, Next".to_string();
cleanup_dangling_punctuation::<Latex>(
&mut latex,
citum_schema::options::StrongTerminalCommaPolicy::KeepTerminal,
);
assert_eq!(Latex.visible_text(&latex), "Title! Next");
assert!(latex.contains(r"\emph{Title!}"));
let mut typst = "#emph[Title…], Next".to_string();
cleanup_dangling_punctuation::<Typst>(
&mut typst,
citum_schema::options::StrongTerminalCommaPolicy::KeepTerminal,
);
assert_eq!(Typst.visible_text(&typst), "Title… Next");
assert!(typst.contains("#emph[Title…]"));
}
}