use crate::render::component::RenderedComponent;
use crate::render::format::{OutputFormat, RealizedPunctuation};
use citum_schema::options::{Config, StrongTerminalCommaPolicy};
pub(crate) fn strong_terminal_comma_policy(config: Option<&Config>) -> StrongTerminalCommaPolicy {
config
.and_then(|config| config.punctuation.as_ref())
.and_then(|punctuation| punctuation.strong_terminal_comma_policy)
.unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PunctuationClass {
StrongTerminal,
WeakTerminal,
CommaLike,
}
impl PunctuationClass {
pub(crate) fn of(ch: char) -> Option<Self> {
match ch {
'!' | '?' | '…' => Some(Self::StrongTerminal),
'.' | ':' => Some(Self::WeakTerminal),
',' | ';' => Some(Self::CommaLike),
_ => None,
}
}
}
pub(crate) fn is_terminal_punctuation(ch: char) -> bool {
PunctuationClass::of(ch).is_some()
}
pub(crate) fn is_strong_terminal(ch: char) -> bool {
PunctuationClass::of(ch) == Some(PunctuationClass::StrongTerminal)
}
pub(crate) fn visible_projection<F: OutputFormat<Output = String>>(
fragment: &str,
) -> (String, Vec<usize>) {
let fmt = F::default();
let runs = fmt.visible_runs(fragment);
let mut visible = String::with_capacity(fragment.len());
let mut raw_pos = Vec::with_capacity(fragment.len());
for run in runs {
if let Some(slice) = fragment.get(run.clone()) {
visible.push_str(slice);
raw_pos.extend(run);
}
}
(visible, raw_pos)
}
fn is_fully_visible<F: OutputFormat<Output = String>>(fragment: &str) -> bool {
let runs = F::default().visible_runs(fragment);
runs.len() == 1 && runs.first() == Some(&(0..fragment.len()))
}
fn visible_suffix_raw_index<F: OutputFormat<Output = String>>(
fragment: &str,
target: &str,
) -> Option<usize> {
if target.is_empty() {
return None;
}
if is_fully_visible::<F>(fragment) {
return fragment
.ends_with(target)
.then(|| fragment.len() - target.len());
}
let (visible, raw_pos) = visible_projection::<F>(fragment);
if !visible.ends_with(target) {
return None;
}
raw_pos.get(visible.len() - target.len()).copied()
}
fn first_visible_char_and_raw_range<F: OutputFormat<Output = String>>(
text: &str,
) -> Option<(char, std::ops::Range<usize>)> {
if is_fully_visible::<F>(text) {
let ch = text.chars().next()?;
return Some((ch, 0..ch.len_utf8()));
}
let (visible, raw_pos) = visible_projection::<F>(text);
let ch = visible.chars().next()?;
let start = *raw_pos.first()?;
Some((ch, start..start + ch.len_utf8()))
}
pub(crate) fn leading_movable_mark<F: OutputFormat<Output = String>>(
text: &str,
) -> Option<(char, String)> {
let (mark, range) = first_visible_char_and_raw_range::<F>(text)?;
if !matches!(mark, '.' | ',') {
return None;
}
#[allow(
clippy::string_slice,
reason = "range is derived from visible_runs/char boundaries"
)]
let rest = format!("{}{}", &text[..range.start], &text[range.end..]);
Some((mark, rest))
}
pub(crate) fn move_punctuation_into_quote<F: OutputFormat<Output = String>>(
accumulated: &mut String,
punct: char,
close_quote: &str,
) -> bool {
if !close_quote.is_empty()
&& let Some(idx) = visible_suffix_raw_index::<F>(accumulated, close_quote)
{
accumulated.insert(idx, punct);
return true;
}
if close_quote != "\""
&& let Some(idx) = visible_suffix_raw_index::<F>(accumulated, "\"")
{
accumulated.insert(idx, punct);
return true;
}
false
}
pub(crate) fn join_with_quote_movement<F: OutputFormat<Output = String>>(
parts: Vec<RenderedComponent>,
delimiter: &RealizedPunctuation<'_>,
punctuation_in_quote: bool,
close_quote: &str,
) -> String {
let mut iter = parts.into_iter();
let Some(first) = iter.next() else {
return String::new();
};
let mut result = first.text;
for part in iter {
let delim_first = delimiter.core();
let moved_via_delimiter = punctuation_in_quote
&& matches!(delim_first, Some('.' | ','))
&& move_punctuation_into_quote::<F>(
&mut result,
delim_first.unwrap_or('.'),
close_quote,
);
if moved_via_delimiter {
result.push_str(delimiter.tail());
result.push_str(&part.text);
continue;
}
if punctuation_in_quote
&& delim_first.is_none()
&& let Some((mark, rest)) = leading_movable_mark::<F>(&part.text)
&& move_punctuation_into_quote::<F>(&mut result, mark, close_quote)
{
result.push_str(&rest);
continue;
}
result.push_str(delimiter.text());
result.push_str(&part.text);
}
result
}
pub(crate) fn resolve_punctuation_collision(
first: char,
second: char,
strong_terminal_comma_policy: StrongTerminalCommaPolicy,
) -> String {
if second == ','
&& is_strong_terminal(first)
&& strong_terminal_comma_policy == StrongTerminalCommaPolicy::KeepTerminal
{
return first.to_string();
}
match (first, second) {
(':', ':') => ":".to_string(),
('.', ':') => ".:".to_string(),
(';', ':') => ";".to_string(),
('!', ':') => "!".to_string(),
('?', ':') => "?".to_string(),
(',', ':') => ",:".to_string(),
(':', '.') => ":".to_string(),
('.', '.') => ".".to_string(),
(';', '.') => ";".to_string(),
('!', '.') => "!".to_string(),
('?', '.') => "?".to_string(),
(',', '.') => ",.".to_string(),
(':', ';') => ":;".to_string(),
('.', ';') => ".;".to_string(),
(';', ';') => ";".to_string(),
('!', ';') => "!;".to_string(),
('?', ';') => "?;".to_string(),
(',', ';') => ",;".to_string(),
(':', '!') => "!".to_string(),
('.', '!') => ".!".to_string(),
(';', '!') => "!".to_string(),
('!', '!') => "!".to_string(),
('?', '!') => "?!".to_string(),
(',', '!') => ",!".to_string(),
(':', '?') => "?".to_string(),
('.', '?') => ".?".to_string(),
(';', '?') => "?".to_string(),
('!', '?') => "!?".to_string(),
('?', '?') => "?".to_string(),
(',', '?') => ",?".to_string(),
(':', ',') => ":,".to_string(),
('.', ',') => ".,".to_string(),
(';', ',') => ";,".to_string(),
('!', ',') => "!,".to_string(),
('?', ',') => "?,".to_string(),
(',', ',') => ",".to_string(),
_ => format!("{first}{second}"),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use super::*;
use crate::render::html::Html;
use crate::render::latex::Latex;
use crate::render::plain::PlainText;
use rstest::rstest;
fn part(text: &str) -> RenderedComponent {
RenderedComponent {
text: text.to_string(),
}
}
#[rstest]
#[case('.', "period")]
#[case(',', "comma")]
fn join_with_quote_movement_moves_group_delimiter_led_mark_inside_closing_quote(
#[case] mark: char,
#[case] label: &str,
) {
let parts = vec![part("“Title”"), part("2023")];
let delimiter = RealizedPunctuation::new(format!("{mark} ").into());
let joined = join_with_quote_movement::<PlainText>(parts, &delimiter, true, "”");
assert_eq!(
joined,
format!("“Title{mark}” 2023"),
"{label}-led group delimiter should move inside the quote"
);
}
#[rstest]
#[case('.', "period")]
#[case(',', "comma")]
fn join_with_quote_movement_moves_next_item_own_leading_mark_inside_closing_quote_when_delimiter_is_empty(
#[case] mark: char,
#[case] label: &str,
) {
let parts = vec![part("“Title”"), part(&format!("{mark} 1"))];
let delimiter = RealizedPunctuation::new("".into());
let joined = join_with_quote_movement::<PlainText>(parts, &delimiter, true, "”");
assert_eq!(
joined,
format!("“Title{mark}” 1"),
"{label}-led self-delimiting item should move inside the quote"
);
}
#[test]
fn join_with_quote_movement_leaves_group_delimiter_led_mark_outside_quote_when_disabled() {
let parts = vec![part("“Title”"), part("2023")];
let delimiter = RealizedPunctuation::new(". ".into());
let joined = join_with_quote_movement::<PlainText>(parts, &delimiter, false, "”");
assert_eq!(joined, "“Title”. 2023");
}
#[test]
fn join_with_quote_movement_moves_mark_inside_a_locale_specific_close_quote() {
let parts = vec![part("«Titre»"), part("2023")];
let delimiter = RealizedPunctuation::new(", ".into());
let joined = join_with_quote_movement::<PlainText>(parts, &delimiter, true, "»");
assert_eq!(joined, "«Titre,» 2023");
}
#[test]
fn move_punctuation_into_quote_finds_the_close_quote_behind_trailing_html_markup() {
let mut accumulated = r#"<span class="citum-title">“Title”</span>"#.to_string();
let moved = move_punctuation_into_quote::<Html>(&mut accumulated, '.', "”");
assert!(moved, "expected the mark to be moved: {accumulated}");
assert_eq!(accumulated, r#"<span class="citum-title">“Title.”</span>"#);
}
#[test]
fn move_punctuation_into_quote_finds_the_close_quote_behind_trailing_latex_markup() {
let mut accumulated = r"\emph{“Title”}".to_string();
let moved = move_punctuation_into_quote::<Latex>(&mut accumulated, '.', "”");
assert!(moved, "expected the mark to be moved: {accumulated}");
assert_eq!(accumulated, "\\emph{“Title.”}");
}
#[test]
fn move_punctuation_into_quote_returns_false_when_no_close_quote_is_present() {
let mut accumulated = r#"<span class="citum-title">Title</span>"#.to_string();
let moved = move_punctuation_into_quote::<Html>(&mut accumulated, '.', "”");
assert!(!moved);
assert_eq!(accumulated, r#"<span class="citum-title">Title</span>"#);
}
#[rstest]
#[case::plain(". Aired September 28", " Aired September 28")]
#[case::html(
r#"<span class="citum-issued">. Aired September 28</span>"#,
r#"<span class="citum-issued"> Aired September 28</span>"#
)]
fn leading_movable_mark_strips_the_mark_behind_leading_markup(
#[case] input: &str,
#[case] expected: &str,
) {
let found = if input.starts_with('<') {
leading_movable_mark::<Html>(input)
} else {
leading_movable_mark::<PlainText>(input)
};
assert_eq!(found, Some(('.', expected.to_string())));
}
#[test]
fn leading_movable_mark_returns_none_when_first_visible_char_is_not_a_mark() {
let found = leading_movable_mark::<PlainText>("Aired September 28");
assert_eq!(found, None);
}
}