use std::borrow::Cow;
use std::ops::Range;
use crate::values::ScriptClass;
use citum_schema::locale::GrammarOptions;
use citum_schema::options::PunctuationRealization;
use citum_schema::template::{DelimiterPunctuation, WrapPunctuation};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PunctuationPosition {
Separator,
Prefix,
Suffix,
}
#[must_use]
pub(crate) fn realize_punctuation<'a>(
punctuation: &'a DelimiterPunctuation,
script: ScriptClass,
overrides: Option<&'a PunctuationRealization>,
position: PunctuationPosition,
) -> Cow<'a, str> {
use DelimiterPunctuation as Punctuation;
let override_value = overrides.and_then(|table| match punctuation {
Punctuation::Comma => table.comma.as_deref().map(Cow::Borrowed),
Punctuation::Colon => table.colon.as_deref().map(Cow::Borrowed),
Punctuation::Semicolon => table.semicolon.as_deref().map(Cow::Borrowed),
Punctuation::Period => table.period.as_deref().map(Cow::Borrowed),
Punctuation::Parentheses => table
.parentheses
.as_ref()
.map(|pair| pair_mark(pair, position)),
Punctuation::Brackets => table
.brackets
.as_ref()
.map(|pair| pair_mark(pair, position)),
Punctuation::Ampersand
| Punctuation::VerticalLine
| Punctuation::Slash
| Punctuation::Hyphen
| Punctuation::Space
| Punctuation::None
| Punctuation::Custom(_) => None,
});
if let Some(value) = override_value {
return value;
}
let default = match (punctuation, script, position) {
(Punctuation::Comma, ScriptClass::Latin, _) => ", ",
(Punctuation::Comma, ScriptClass::Cjk, _) => ",",
(Punctuation::Comma, ScriptClass::Mixed, _) => ",",
(Punctuation::Colon, ScriptClass::Latin, _) => ": ",
(Punctuation::Colon, ScriptClass::Cjk, _) => ":",
(Punctuation::Colon, ScriptClass::Mixed, _) => ":",
(Punctuation::Semicolon, ScriptClass::Latin, _) => "; ",
(Punctuation::Semicolon, ScriptClass::Cjk, _) => ";",
(Punctuation::Semicolon, ScriptClass::Mixed, _) => ";",
(Punctuation::Period, ScriptClass::Latin, _) => ". ",
(Punctuation::Period, ScriptClass::Cjk, _) => "。",
(Punctuation::Period, ScriptClass::Mixed, _) => ". ",
(Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Prefix) => "(",
(Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Suffix) => ")",
(Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Prefix) => "(",
(Punctuation::Parentheses, ScriptClass::Mixed, PunctuationPosition::Prefix) => "(",
(Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Suffix) => ")",
(Punctuation::Parentheses, ScriptClass::Mixed, PunctuationPosition::Suffix) => ")",
(Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Prefix) => "[",
(Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Suffix) => "]",
(Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Prefix) => "【",
(Punctuation::Brackets, ScriptClass::Mixed, PunctuationPosition::Prefix) => "[",
(Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Suffix) => "】",
(Punctuation::Brackets, ScriptClass::Mixed, PunctuationPosition::Suffix) => "]",
(Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Separator) => "()",
(Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Separator) => "()",
(Punctuation::Parentheses, ScriptClass::Mixed, PunctuationPosition::Separator) => "()",
(Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Separator) => "[]",
(Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Separator) => "【】",
(Punctuation::Brackets, ScriptClass::Mixed, PunctuationPosition::Separator) => "[]",
(
Punctuation::Ampersand
| Punctuation::VerticalLine
| Punctuation::Slash
| Punctuation::Hyphen
| Punctuation::Space
| Punctuation::None
| Punctuation::Custom(_),
_,
_,
) => return Cow::Borrowed(punctuation.as_default_str()),
};
Cow::Borrowed(default)
}
fn pair_mark(pair: &[String; 2], position: PunctuationPosition) -> Cow<'_, str> {
match position {
PunctuationPosition::Prefix => Cow::Borrowed(pair[0].as_str()),
PunctuationPosition::Suffix => Cow::Borrowed(pair[1].as_str()),
PunctuationPosition::Separator => Cow::Owned(format!("{}{}", pair[0], pair[1])),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RealizedPunctuation<'a> {
text: Cow<'a, str>,
core_len: usize,
}
impl<'a> RealizedPunctuation<'a> {
pub(crate) fn new(text: Cow<'a, str>) -> Self {
let core_len = text.chars().next().map(char::len_utf8).unwrap_or(0);
Self { text, core_len }
}
pub(crate) fn text(&self) -> &str {
&self.text
}
pub(crate) fn core(&self) -> Option<char> {
self.text.chars().next()
}
pub(crate) fn tail(&self) -> &str {
#[allow(
clippy::string_slice,
reason = "core_len is a char boundary derived from chars().next()"
)]
&self.text[self.core_len..]
}
pub(crate) fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub(crate) fn into_owned(self) -> RealizedPunctuation<'static> {
RealizedPunctuation {
text: Cow::Owned(self.text.into_owned()),
core_len: self.core_len,
}
}
}
#[must_use]
pub(crate) fn realize_punctuation_decomposed<'a>(
punctuation: &'a DelimiterPunctuation,
script: ScriptClass,
overrides: Option<&'a PunctuationRealization>,
position: PunctuationPosition,
) -> RealizedPunctuation<'a> {
RealizedPunctuation::new(realize_punctuation(
punctuation,
script,
overrides,
position,
))
}
pub(crate) fn apply_punctuation_affixes<F>(
fmt: &F,
prefix: Option<(&DelimiterPunctuation, &str)>,
mut content: String,
suffix: Option<(&DelimiterPunctuation, &str)>,
) -> String
where
F: OutputFormat<Output = String>,
{
if let Some((punctuation, text)) = prefix {
content = if punctuation.is_semantic() {
fmt.join(vec![fmt.text(text), content], "")
} else {
fmt.affix(text, content, "")
};
}
if let Some((punctuation, text)) = suffix {
content = if punctuation.is_semantic() {
fmt.join(vec![content, fmt.text(text)], "")
} else {
fmt.affix("", content, text)
};
}
content
}
#[must_use]
pub fn unicode_quote_marks(depth: usize) -> (&'static str, &'static str) {
if depth.is_multiple_of(2) {
("\u{201C}", "\u{201D}")
} else {
("\u{2018}", "\u{2019}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuoteMarks {
pub open: String,
pub close: String,
pub open_inner: String,
pub close_inner: String,
pub punctuation_realization: Option<citum_schema::options::PunctuationRealization>,
}
impl QuoteMarks {
#[must_use]
pub fn for_depth(&self, depth: usize) -> (&str, &str) {
if depth.is_multiple_of(2) {
(&self.open, &self.close)
} else {
(&self.open_inner, &self.close_inner)
}
}
}
impl Default for QuoteMarks {
fn default() -> Self {
let (open, close) = unicode_quote_marks(0);
let (open_inner, close_inner) = unicode_quote_marks(1);
Self {
open: open.to_string(),
close: close.to_string(),
open_inner: open_inner.to_string(),
close_inner: close_inner.to_string(),
punctuation_realization: None,
}
}
}
impl From<&GrammarOptions> for QuoteMarks {
fn from(options: &GrammarOptions) -> Self {
Self {
open: options.open_quote.clone(),
close: options.close_quote.clone(),
open_inner: options.open_inner_quote.clone(),
close_inner: options.close_inner_quote.clone(),
punctuation_realization: None,
}
}
}
impl From<&citum_schema::locale::Locale> for QuoteMarks {
fn from(locale: &citum_schema::locale::Locale) -> Self {
Self {
open: locale.grammar_options.open_quote.clone(),
close: locale.grammar_options.close_quote.clone(),
open_inner: locale.grammar_options.open_inner_quote.clone(),
close_inner: locale.grammar_options.close_inner_quote.clone(),
punctuation_realization: locale.punctuation_realization.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemanticAttribute {
pub name: &'static str,
pub value: String,
}
#[must_use]
pub(crate) fn realize_wrap<'a>(
wrap: &WrapPunctuation,
script: ScriptClass,
overrides: Option<&'a PunctuationRealization>,
) -> Option<(Cow<'a, str>, Cow<'a, str>)> {
if let Some(pair) = overrides.and_then(|table| match wrap {
WrapPunctuation::Parentheses => table.parentheses.as_ref(),
WrapPunctuation::Brackets => table.brackets.as_ref(),
WrapPunctuation::Quotes => None,
}) {
return Some((
Cow::Borrowed(pair[0].as_str()),
Cow::Borrowed(pair[1].as_str()),
));
}
match (wrap, script) {
(WrapPunctuation::Parentheses, ScriptClass::Latin) => {
Some((Cow::Borrowed("("), Cow::Borrowed(")")))
}
(WrapPunctuation::Parentheses, ScriptClass::Cjk) => {
Some((Cow::Borrowed("("), Cow::Borrowed(")")))
}
(WrapPunctuation::Parentheses, ScriptClass::Mixed) => {
Some((Cow::Borrowed("("), Cow::Borrowed(")")))
}
(WrapPunctuation::Brackets, ScriptClass::Latin) => {
Some((Cow::Borrowed("["), Cow::Borrowed("]")))
}
(WrapPunctuation::Brackets, ScriptClass::Cjk) => {
Some((Cow::Borrowed("【"), Cow::Borrowed("】")))
}
(WrapPunctuation::Brackets, ScriptClass::Mixed) => {
Some((Cow::Borrowed("["), Cow::Borrowed("]")))
}
(WrapPunctuation::Quotes, _) => None,
}
}
pub trait OutputFormat: Default + Clone {
type Output;
fn text(&self, s: &str) -> Self::Output;
fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
fn finish(&self, output: Self::Output) -> String;
fn emph(&self, content: Self::Output) -> Self::Output;
fn strong(&self, content: Self::Output) -> Self::Output;
fn small_caps(&self, content: Self::Output) -> Self::Output;
fn superscript(&self, content: Self::Output) -> Self::Output;
fn quote_marks<'a>(&self, depth: usize, marks: &'a QuoteMarks) -> (&'a str, &'a str) {
marks.for_depth(depth)
}
fn quote_with_depth(
&self,
content: Self::Output,
depth: usize,
marks: &QuoteMarks,
) -> Self::Output {
let (open, close) = self.quote_marks(depth, marks);
self.affix(open, content, close)
}
fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
self.quote_with_depth(content, 0, marks)
}
fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
fn wrap_punctuation(
&self,
wrap: &WrapPunctuation,
content: Self::Output,
marks: &QuoteMarks,
script: ScriptClass,
realization: Option<&PunctuationRealization>,
) -> Self::Output;
fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
fn annotation(&self, content: Self::Output) -> Self::Output;
fn paragraph(&self, content: Self::Output) -> Self::Output {
content
}
fn block_quote(&self, content: Self::Output) -> Self::Output {
content
}
fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
self.join(items, "\n")
}
fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
self.join(items, "\n")
}
fn list_item(&self, content: Self::Output) -> Self::Output {
content
}
fn heading(&self, _level: u8, content: Self::Output) -> Self::Output {
content
}
fn unnumbered_heading(&self, level: u8, content: Self::Output) -> Self::Output {
self.heading(level, content)
}
fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
content
}
fn inline_code(&self, content: Self::Output) -> Self::Output {
content
}
fn strikeout(&self, content: Self::Output) -> Self::Output {
content
}
fn hard_break(&self) -> Self::Output {
self.text(" ")
}
fn semantic_with_attributes(
&self,
class: &str,
content: Self::Output,
_attributes: &[SemanticAttribute],
) -> Self::Output {
self.semantic(class, content)
}
fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
content
}
fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
let mut runs = Vec::new();
if !fragment.is_empty() {
runs.push(0..fragment.len());
}
runs
}
fn visible_text<'a>(&self, fragment: &'a str) -> Cow<'a, str> {
let runs = self.visible_runs(fragment);
if runs.len() == 1 && runs.first() == Some(&(0..fragment.len())) {
return Cow::Borrowed(fragment);
}
let mut owned = String::with_capacity(fragment.len());
for run in runs {
if let Some(slice) = fragment.get(run) {
owned.push_str(slice);
}
}
Cow::Owned(owned)
}
fn link(&self, url: &str, content: Self::Output) -> Self::Output;
fn format_id(&self, id: &str) -> String {
id.to_string()
}
fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
self.join(entries, "\n\n")
}
fn entry(
&self,
_id: &str,
content: Self::Output,
_url: Option<&str>,
_metadata: &ProcEntryMetadata,
) -> Self::Output {
content
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ProcEntryMetadata {
pub author: Option<String>,
pub year: Option<String>,
pub title: Option<String>,
}
#[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 rstest::rstest;
#[derive(Default, Clone)]
struct DummyFormat;
impl OutputFormat for DummyFormat {
type Output = String;
fn text(&self, s: &str) -> Self::Output {
s.to_string()
}
fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
items.join(delimiter)
}
fn finish(&self, output: Self::Output) -> String {
output
}
fn emph(&self, content: Self::Output) -> Self::Output {
format!("emph({content})")
}
fn strong(&self, content: Self::Output) -> Self::Output {
format!("strong({content})")
}
fn small_caps(&self, content: Self::Output) -> Self::Output {
format!("sc({content})")
}
fn superscript(&self, content: Self::Output) -> Self::Output {
format!("sup({content})")
}
fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
format!("{prefix}{content}{suffix}")
}
fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
format!("{prefix}{content}{suffix}")
}
fn wrap_punctuation(
&self,
_wrap: &WrapPunctuation,
content: Self::Output,
_marks: &QuoteMarks,
_script: ScriptClass,
_realization: Option<&PunctuationRealization>,
) -> Self::Output {
content
}
fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
format!("sem[{class}]({content})")
}
fn annotation(&self, content: Self::Output) -> Self::Output {
format!("annot({content})")
}
fn link(&self, url: &str, content: Self::Output) -> Self::Output {
format!("link[{url}]({content})")
}
}
#[test]
fn test_realize_wrap() {
for (wrap, script, expected) in [
(
WrapPunctuation::Parentheses,
ScriptClass::Latin,
Some(("(", ")")),
),
(
WrapPunctuation::Parentheses,
ScriptClass::Cjk,
Some(("(", ")")),
),
(
WrapPunctuation::Brackets,
ScriptClass::Latin,
Some(("[", "]")),
),
(
WrapPunctuation::Brackets,
ScriptClass::Cjk,
Some(("【", "】")),
),
(WrapPunctuation::Quotes, ScriptClass::Latin, None),
(WrapPunctuation::Quotes, ScriptClass::Cjk, None),
] {
assert_eq!(
realize_wrap(&wrap, script, None)
.map(|(open, close)| (open.into_owned(), close.into_owned())),
expected.map(|(open, close)| (open.to_string(), close.to_string())),
"{wrap:?}/{script:?}"
);
}
}
#[test]
fn paired_punctuation_override_includes_both_marks_as_separator() {
let overrides = PunctuationRealization {
parentheses: Some(["〔".to_string(), "〕".to_string()]),
..PunctuationRealization::default()
};
assert_eq!(
realize_punctuation(
&DelimiterPunctuation::Parentheses,
ScriptClass::Cjk,
Some(&overrides),
PunctuationPosition::Separator,
),
"〔〕"
);
}
#[test]
fn test_default_methods() {
let fmt = DummyFormat;
assert_eq!(
fmt.semantic_with_attributes("test", "content".to_string(), &[]),
"sem[test](content)"
);
assert_eq!(
fmt.citation(vec!["id1".to_string()], "content".to_string()),
"content"
);
assert_eq!(fmt.format_id("id1"), "id1");
assert_eq!(
fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
"entry1\n\nentry2"
);
assert_eq!(
fmt.entry(
"id1",
"content".to_string(),
None,
&ProcEntryMetadata::default()
),
"content"
);
}
#[test]
fn semantic_affixes_use_each_output_formats_text_escaping() {
let punctuation = DelimiterPunctuation::Comma;
assert_eq!(
apply_punctuation_affixes(
&crate::render::plain::PlainText,
Some((&punctuation, "<&")),
"value".to_string(),
None,
),
"<&value"
);
assert_eq!(
apply_punctuation_affixes(
&crate::render::html::Html,
Some((&punctuation, "<&")),
"value".to_string(),
None,
),
"<&value"
);
assert_eq!(
apply_punctuation_affixes(
&crate::render::latex::Latex,
Some((&punctuation, "<&")),
"value".to_string(),
None,
),
"<\\&value"
);
assert_eq!(
apply_punctuation_affixes(
&crate::render::typst::Typst,
Some((&punctuation, "<&")),
"value".to_string(),
None,
),
"\\<&value"
);
assert_eq!(
apply_punctuation_affixes(
&crate::render::markdown::Markdown,
Some((&punctuation, "<&")),
"value".to_string(),
None,
),
"\\<\\&value"
);
assert_eq!(
apply_punctuation_affixes(
&crate::render::djot::Djot,
Some((&punctuation, "<&")),
"value".to_string(),
None,
),
"<&value"
);
assert_eq!(
apply_punctuation_affixes(
&crate::render::org::OrgOutputFormat,
Some((&punctuation, "<&")),
"value".to_string(),
None,
),
"<&value"
);
}
#[rstest]
#[case::latin_comma(DelimiterPunctuation::Comma, ScriptClass::Latin, Some(','), " ")]
#[case::cjk_comma_has_no_tail(DelimiterPunctuation::Comma, ScriptClass::Cjk, Some(','), "")]
#[case::custom_period_matches_semantic_period_under_latin(
DelimiterPunctuation::Custom(". ".to_string()),
ScriptClass::Latin,
Some('.'),
" ",
)]
#[case::custom_empty_has_no_core(
DelimiterPunctuation::Custom(String::new()),
ScriptClass::Latin,
None,
""
)]
#[case::custom_ampersand_space_led_core_is_not_terminal_punctuation(
DelimiterPunctuation::Custom(" & ".to_string()),
ScriptClass::Latin,
Some(' '),
"& ",
)]
fn realized_punctuation_decomposes_core_and_tail(
#[case] punctuation: DelimiterPunctuation,
#[case] script: ScriptClass,
#[case] expected_core: Option<char>,
#[case] expected_tail: &str,
) {
let realized = realize_punctuation_decomposed(
&punctuation,
script,
None,
PunctuationPosition::Separator,
);
assert_eq!(realized.core(), expected_core);
assert_eq!(realized.tail(), expected_tail);
}
#[test]
fn realized_punctuation_french_colon_has_no_movable_core() {
let realization = citum_schema::options::PunctuationRealization {
colon: Some("\u{00A0}: ".to_string()),
..Default::default()
};
let punctuation = DelimiterPunctuation::Colon;
let realized = realize_punctuation_decomposed(
&punctuation,
ScriptClass::Latin,
Some(&realization),
PunctuationPosition::Separator,
);
assert_eq!(realized.text(), "\u{00A0}: ");
assert_eq!(realized.core(), Some('\u{00A0}'));
assert!(!matches!(realized.core(), Some('.' | ',')));
}
#[test]
fn realized_punctuation_is_empty_and_into_owned_detach_from_the_input() {
let borrowed = RealizedPunctuation::new(Cow::Borrowed(""));
assert!(borrowed.is_empty());
let source = String::from(", ");
let realized = RealizedPunctuation::new(Cow::Borrowed(source.as_str()));
let owned = realized.into_owned();
drop(source);
assert_eq!(owned.text(), ", ");
assert_eq!(owned.core(), Some(','));
}
}