1use crate::render::component::{ProcTemplate, render_component_with_format};
7use crate::render::format::OutputFormat;
8use crate::render::plain::PlainText;
9use citum_schema::template::WrapPunctuation;
10
11fn is_terminal_punctuation(ch: char) -> bool {
12 matches!(ch, ':' | '.' | ';' | '!' | '?' | ',')
13}
14
15fn resolve_punctuation_collision(first: char, second: char) -> String {
16 match (first, second) {
17 (':', ':') => ":".to_string(),
18 ('.', ':') => ".:".to_string(),
19 (';', ':') => ";".to_string(),
20 ('!', ':') => "!".to_string(),
21 ('?', ':') => "?".to_string(),
22 (',', ':') => ",:".to_string(),
23 (':', '.') => ":".to_string(),
24 ('.', '.') => ".".to_string(),
25 (';', '.') => ";".to_string(),
26 ('!', '.') => "!".to_string(),
27 ('?', '.') => "?".to_string(),
28 (',', '.') => ",.".to_string(),
29 (':', ';') => ":;".to_string(),
30 ('.', ';') => ".;".to_string(),
31 (';', ';') => ";".to_string(),
32 ('!', ';') => "!;".to_string(),
33 ('?', ';') => "?;".to_string(),
34 (',', ';') => ",;".to_string(),
35 (':', '!') => "!".to_string(),
36 ('.', '!') => ".!".to_string(),
37 (';', '!') => "!".to_string(),
38 ('!', '!') => "!".to_string(),
39 ('?', '!') => "?!".to_string(),
40 (',', '!') => ",!".to_string(),
41 (':', '?') => "?".to_string(),
42 ('.', '?') => ".?".to_string(),
43 (';', '?') => "?".to_string(),
44 ('!', '?') => "!?".to_string(),
45 ('?', '?') => "?".to_string(),
46 (',', '?') => ",?".to_string(),
47 (':', ',') => ":,".to_string(),
48 ('.', ',') => ".,".to_string(),
49 (';', ',') => ";,".to_string(),
50 ('!', ',') => "!,".to_string(),
51 ('?', ',') => "?,".to_string(),
52 (',', ',') => ",".to_string(),
53 _ => format!("{first}{second}"),
54 }
55}
56
57#[inline]
68#[allow(
69 clippy::string_slice,
70 reason = "UTF-8 safe slicing based on char boundary checks"
71)]
72fn push_delimiter(content: &mut String, delim: &str, punctuation_in_quote: bool) {
73 let delim_first = delim.chars().next();
74 let content_last = content.chars().last();
75
76 if punctuation_in_quote
77 && delim_first == Some(',')
78 && (content.ends_with('"') || content.ends_with('\u{201D}'))
79 {
80 let is_curly = content.ends_with('\u{201D}');
82 content.pop();
83 content.push(',');
84 content.push(if is_curly { '\u{201D}' } else { '"' });
85 content.push_str(&delim[1..]);
86 } else if let (Some(last), Some(first)) = (content_last, delim_first)
87 && is_terminal_punctuation(last)
88 && is_terminal_punctuation(first)
89 {
90 content.pop();
92 content.push_str(&resolve_punctuation_collision(last, first));
93 content.push_str(&delim[first.len_utf8()..]);
94 } else {
95 content.push_str(delim);
97 }
98}
99
100#[must_use]
102pub fn citation_to_string(
103 proc_template: &ProcTemplate,
104 wrap: Option<&WrapPunctuation>,
105 prefix: Option<&str>,
106 suffix: Option<&str>,
107 delimiter: Option<&str>,
108) -> String {
109 citation_to_string_with_format::<PlainText>(proc_template, wrap, prefix, suffix, delimiter)
110}
111
112#[must_use]
114pub fn citation_to_string_with_format<F: OutputFormat<Output = String>>(
115 proc_template: &ProcTemplate,
116 wrap: Option<&WrapPunctuation>,
117 prefix: Option<&str>,
118 suffix: Option<&str>,
119 delimiter: Option<&str>,
120) -> String {
121 let mut parts: Vec<String> = Vec::new();
122
123 for component in proc_template {
124 let rendered = render_component_with_format::<F>(component);
125 if !rendered.is_empty() {
126 parts.push(rendered);
127 }
128 }
129
130 let delim = delimiter.unwrap_or("");
131 let punctuation_in_quote = proc_template
132 .first()
133 .and_then(|c| c.config.as_ref())
134 .is_some_and(|cfg| cfg.punctuation_in_quote);
135
136 let mut content = String::new();
137 for (i, part) in parts.iter().enumerate() {
138 if i > 0 {
139 push_delimiter(&mut content, delim, punctuation_in_quote);
140 }
141 content.push_str(part);
142 }
143
144 let (open, close) = match wrap {
145 Some(WrapPunctuation::Parentheses) => ("(", ")"),
146 Some(WrapPunctuation::Brackets) => ("[", "]"),
147 Some(WrapPunctuation::Quotes) => ("\u{201C}", "\u{201D}"),
148 _ => (prefix.unwrap_or(""), suffix.unwrap_or("")),
149 };
150
151 format!("{open}{content}{close}")
152}
153
154#[cfg(test)]
155#[allow(
156 clippy::unwrap_used,
157 clippy::expect_used,
158 clippy::panic,
159 clippy::indexing_slicing,
160 clippy::todo,
161 clippy::unimplemented,
162 clippy::unreachable,
163 clippy::get_unwrap,
164 reason = "Panicking is acceptable and often desired in tests."
165)]
166mod tests {
167 use super::*;
168 use crate::render::component::ProcTemplateComponent;
169 use crate::render::typst::Typst;
170 use citum_schema::options::Config;
171 use citum_schema::template::{
172 ContributorForm, ContributorRole, DateForm, DateVariable, Rendering, TemplateComponent,
173 TemplateContributor, TemplateDate, TemplateTitle, TitleType,
174 };
175
176 #[test]
177 fn test_citation_to_string() {
178 let template = vec![
179 ProcTemplateComponent {
180 template_component: TemplateComponent::Contributor(TemplateContributor {
181 contributor: ContributorRole::Author,
182 form: ContributorForm::Short,
183 name_order: None,
184 delimiter: None,
185 rendering: Rendering::default(),
186 ..Default::default()
187 }),
188 template_index: None,
189 value: "Kuhn".to_string(),
190 prefix: None,
191 suffix: None,
192 ref_type: None,
193 config: None,
194 bibliography_config: None,
195 url: None,
196 item_language: None,
197 sentence_initial: false,
198 pre_formatted: false,
199 },
200 ProcTemplateComponent {
201 template_component: TemplateComponent::Date(TemplateDate {
202 date: DateVariable::Issued,
203 form: DateForm::Year,
204 rendering: Rendering::default(),
205 ..Default::default()
206 }),
207 template_index: None,
208 value: "1962".to_string(),
209 prefix: None,
210 suffix: None,
211 ref_type: None,
212 config: None,
213 bibliography_config: None,
214 url: None,
215 item_language: None,
216 sentence_initial: false,
217 pre_formatted: false,
218 },
219 ];
220
221 let result = citation_to_string(
222 &template,
223 Some(&WrapPunctuation::Parentheses),
224 None,
225 None,
226 Some(", "),
227 );
228 assert_eq!(result, "(Kuhn, 1962)");
229 }
230
231 #[test]
232 fn test_punctuation_in_quote_moves_comma_inside_closing_quote() {
233 let config = Config {
234 punctuation_in_quote: true,
235 ..Default::default()
236 };
237 let template = vec![
238 ProcTemplateComponent {
239 template_component: TemplateComponent::Title(TemplateTitle {
240 title: TitleType::Primary,
241 rendering: Rendering {
242 quote: Some(true),
243 ..Default::default()
244 },
245 ..Default::default()
246 }),
247 template_index: None,
248 value: "colon".to_string(),
249 prefix: None,
250 suffix: None,
251 ref_type: None,
252 config: Some(config.clone()),
253 bibliography_config: None,
254 url: None,
255 item_language: None,
256 sentence_initial: false,
257 pre_formatted: false,
258 },
259 ProcTemplateComponent {
260 template_component: TemplateComponent::Date(TemplateDate {
261 date: DateVariable::Issued,
262 form: DateForm::Year,
263 rendering: Rendering::default(),
264 ..Default::default()
265 }),
266 template_index: None,
267 value: "period".to_string(),
268 prefix: None,
269 suffix: None,
270 ref_type: None,
271 config: Some(config),
272 bibliography_config: None,
273 url: None,
274 item_language: None,
275 sentence_initial: false,
276 pre_formatted: false,
277 },
278 ];
279
280 let plain = citation_to_string(&template, None, None, None, Some(", "));
281 let typst =
282 citation_to_string_with_format::<Typst>(&template, None, None, None, Some(", "));
283
284 assert_eq!(plain, "“colon,” period");
285 assert_eq!(typst, "“colon,” period");
286 }
287
288 #[test]
289 fn test_punctuation_outside_quotes_preserves_full_monty_matrix() {
290 let config = Config {
291 punctuation_in_quote: false,
292 ..Default::default()
293 };
294 let suffixes = [
295 ("colon", ":"),
296 ("period", "."),
297 ("semicolon", ";"),
298 ("exclamation", "!"),
299 ("question", "?"),
300 ("comma", ","),
301 ];
302 let delimiters = [
303 ("ENDING IN COLON", ": "),
304 ("ENDING IN PERIOD", ". "),
305 ("ENDING IN SEMICOLON", "; "),
306 ("ENDING IN EXCLAMATION", "! "),
307 ("ENDING IN QUESTION", "? "),
308 ("ENDING IN COMMA", ", "),
309 ];
310
311 let mut lines = Vec::new();
312 for (heading, delimiter) in delimiters {
313 lines.push(heading.to_string());
314 for (value, suffix) in suffixes {
315 let template = full_monty_template(&config, heading, value, suffix);
316 lines.push(citation_to_string(
317 &template,
318 None,
319 None,
320 None,
321 Some(delimiter),
322 ));
323 }
324 }
325
326 let plain = lines.join("\n");
327 let expected = r"ENDING IN COLON
328“colon”: colon
329“period”.: colon
330“semicolon”; colon
331“exclamation”! colon
332“question”? colon
333“comma”,: colon
334ENDING IN PERIOD
335“colon”: period
336“period”. period
337“semicolon”; period
338“exclamation”! period
339“question”? period
340“comma”,. period
341ENDING IN SEMICOLON
342“colon”:; semicolon
343“period”.; semicolon
344“semicolon”; semicolon
345“exclamation”!; semicolon
346“question”?; semicolon
347“comma”,; semicolon
348ENDING IN EXCLAMATION
349“colon”! exclamation
350“period”.! exclamation
351“semicolon”! exclamation
352“exclamation”! exclamation
353“question”?! exclamation
354“comma”,! exclamation
355ENDING IN QUESTION
356“colon”? question
357“period”.? question
358“semicolon”? question
359“exclamation”!? question
360“question”? question
361“comma”,? question
362ENDING IN COMMA
363“colon”:, comma
364“period”., comma
365“semicolon”;, comma
366“exclamation”!, comma
367“question”?, comma
368“comma”, comma";
369
370 let mut typst_lines = Vec::new();
371 for (heading, delimiter) in delimiters {
372 typst_lines.push(heading.to_string());
373 for (value, suffix) in suffixes {
374 let template = full_monty_template(&config, heading, value, suffix);
375 typst_lines.push(citation_to_string_with_format::<Typst>(
376 &template,
377 None,
378 None,
379 None,
380 Some(delimiter),
381 ));
382 }
383 }
384 let typst = typst_lines.join("\n");
385
386 assert_eq!(plain, expected);
387 assert_eq!(typst, expected);
388 }
389
390 fn full_monty_template(
391 config: &Config,
392 heading: &str,
393 value: &str,
394 suffix: &str,
395 ) -> Vec<ProcTemplateComponent> {
396 vec![
397 ProcTemplateComponent {
398 template_component: TemplateComponent::Title(TemplateTitle {
399 title: TitleType::Primary,
400 rendering: Rendering {
401 quote: Some(true),
402 suffix: Some(suffix.to_string()),
403 ..Default::default()
404 },
405 ..Default::default()
406 }),
407 template_index: None,
408 value: value.to_string(),
409 prefix: None,
410 suffix: None,
411 ref_type: None,
412 config: Some(config.clone()),
413 bibliography_config: None,
414 url: None,
415 item_language: None,
416 sentence_initial: false,
417 pre_formatted: false,
418 },
419 ProcTemplateComponent {
420 template_component: TemplateComponent::Date(TemplateDate {
421 date: DateVariable::Issued,
422 form: DateForm::Year,
423 rendering: Rendering::default(),
424 ..Default::default()
425 }),
426 template_index: None,
427 value: {
428 #[allow(
429 clippy::string_slice,
430 reason = "heading is guaranteed to start with prefix"
431 )]
432 let val = heading["ENDING IN ".len()..].to_ascii_lowercase();
433 val
434 },
435 prefix: None,
436 suffix: None,
437 ref_type: None,
438 config: Some(config.clone()),
439 bibliography_config: None,
440 url: None,
441 item_language: None,
442 sentence_initial: false,
443 pre_formatted: false,
444 },
445 ]
446 }
447}