use crate::render::format::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 move_punctuation_into_quote(
accumulated: &mut String,
punct: char,
close_quote: &str,
) -> bool {
if !close_quote.is_empty() && accumulated.ends_with(close_quote) {
let split = accumulated.len() - close_quote.len();
accumulated.insert(split, punct);
return true;
}
if close_quote != "\"" && accumulated.ends_with('"') {
accumulated.pop();
accumulated.push(punct);
accumulated.push('"');
return true;
}
false
}
#[allow(
clippy::string_slice,
reason = "UTF-8 safe slicing based on char boundary checks"
)]
pub(crate) fn join_with_quote_movement(
parts: Vec<String>,
delimiter: &RealizedPunctuation<'_>,
punctuation_in_quote: bool,
close_quote: &str,
) -> String {
let mut iter = parts.into_iter();
let Some(mut result) = iter.next() else {
return String::new();
};
for part in iter {
let delim_first = delimiter.core();
let moved_via_delimiter = punctuation_in_quote
&& matches!(delim_first, Some('.' | ','))
&& move_punctuation_into_quote(&mut result, delim_first.unwrap_or('.'), close_quote);
if moved_via_delimiter {
result.push_str(delimiter.tail());
result.push_str(&part);
continue;
}
if punctuation_in_quote
&& delim_first.is_none()
&& let Some(part_first) = part.chars().next()
&& matches!(part_first, '.' | ',')
&& move_punctuation_into_quote(&mut result, part_first, close_quote)
{
result.push_str(&part[part_first.len_utf8()..]);
continue;
}
result.push_str(delimiter.text());
result.push_str(&part);
}
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 rstest::rstest;
#[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!["“Title”".to_string(), "2023".to_string()];
let delimiter = RealizedPunctuation::new(format!("{mark} ").into());
let joined = join_with_quote_movement(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!["“Title”".to_string(), format!("{mark} 1")];
let delimiter = RealizedPunctuation::new("".into());
let joined = join_with_quote_movement(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!["“Title”".to_string(), "2023".to_string()];
let delimiter = RealizedPunctuation::new(". ".into());
let joined = join_with_quote_movement(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!["«Titre»".to_string(), "2023".to_string()];
let delimiter = RealizedPunctuation::new(", ".into());
let joined = join_with_quote_movement(parts, &delimiter, true, "»");
assert_eq!(joined, "«Titre,» 2023");
}
}