Skip to main content

citum_engine/render/
citation.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6use 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/// Append `delim` to `content`, applying house-style punctuation rules at the join point.
58///
59/// Three cases are handled in priority order:
60/// 1. **Punctuation-in-quote** – when `punctuation_in_quote` is set and `delim` starts with
61///    a comma, the comma is pulled *inside* a preceding closing quotation mark (`"` or `\u{201D}`)
62///    before appending the rest of the delimiter.
63/// 2. **Punctuation collision** – when the last char of `content` and the first char of `delim`
64///    are both terminal punctuation, the pair is resolved via [`resolve_punctuation_collision`]
65///    (e.g. `".` + `". "` → `". "` rather than `".. "`).
66/// 3. **Default** – append `delim` verbatim.
67#[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        // Case 1: pull the leading comma inside the closing quotation mark.
81        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        // Case 2: two adjacent terminal punctuation marks — merge them and skip the duplicate.
91        content.pop();
92        content.push_str(&resolve_punctuation_collision(last, first));
93        content.push_str(&delim[first.len_utf8()..]);
94    } else {
95        // Case 3: no special rule — append the delimiter verbatim.
96        content.push_str(delim);
97    }
98}
99
100/// Render a processed template into a final citation string using `PlainText` format.
101#[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/// Render a processed template into a final citation string using a specific format.
113#[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}