Skip to main content

citum_engine/processor/
citation.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Citation rendering orchestration.
7//!
8//! This module resolves the effective citation spec for each citation, prepares
9//! renderer delimiters and affixes. Template-level rendering, including
10//! sentence-initial note-start handling, lives in `rendering`.
11
12use super::Processor;
13use super::rendering::{CompoundRenderData, GroupRenderParams, Renderer, RendererResources};
14use crate::error::ProcessorError;
15use crate::reference::Citation;
16use citum_schema::NoteStartTextCase;
17use citum_schema::locale::{GeneralTerm, Locale, TermForm};
18use citum_schema::template::DelimiterPunctuation;
19use indexmap::IndexMap;
20use std::collections::HashMap;
21
22fn join_integral_groups(rendered_groups: Vec<String>, locale: &Locale) -> String {
23    match rendered_groups.len() {
24        0 => String::new(),
25        1 => rendered_groups.into_iter().next().unwrap_or_default(),
26        2 => {
27            let conjunction = locale
28                .resolved_general_term(&GeneralTerm::And, &TermForm::Long, None)
29                .unwrap_or_else(|| locale.and_term(false).to_string());
30            rendered_groups.join(&format!(" {} ", conjunction.trim()))
31        }
32        _ => {
33            let conjunction = locale
34                .resolved_general_term(&GeneralTerm::And, &TermForm::Long, None)
35                .unwrap_or_else(|| locale.and_term(false).to_string());
36            let final_delimiter = if locale.grammar_options.serial_comma {
37                format!(", {} ", conjunction.trim())
38            } else {
39                format!(" {} ", conjunction.trim())
40            };
41
42            let mut rendered_groups = rendered_groups;
43            let last = rendered_groups.pop().unwrap_or_default();
44            format!("{}{}{}", rendered_groups.join(", "), final_delimiter, last)
45        }
46    }
47}
48
49impl Processor {
50    fn sentence_initial_note_start_text_case(
51        &self,
52        citation: &Citation,
53        effective_spec: &citum_schema::CitationSpec,
54    ) -> Option<NoteStartTextCase> {
55        let spec_prefix = effective_spec.prefix.as_deref().unwrap_or("");
56        if self.is_note_style()
57            && matches!(
58                citation.position,
59                Some(
60                    citum_schema::citation::Position::Ibid
61                        | citum_schema::citation::Position::IbidWithLocator
62                )
63            )
64            && matches!(
65                citation.mode,
66                citum_schema::citation::CitationMode::NonIntegral
67            )
68            && citation.prefix.as_deref().unwrap_or("").is_empty()
69            && spec_prefix.is_empty()
70        {
71            effective_spec.note_start_text_case
72        } else {
73            None
74        }
75    }
76
77    fn resolve_positioned_citation_spec(
78        &self,
79        citation: &Citation,
80    ) -> std::borrow::Cow<'_, citum_schema::CitationSpec> {
81        self.style.citation.as_ref().map_or_else(
82            || std::borrow::Cow::Owned(citum_schema::CitationSpec::default()),
83            |spec| spec.resolve_for_position(citation.position.as_ref()),
84        )
85    }
86
87    fn track_cited_ids_and_init_numbers(&self, citation: &Citation) {
88        self.initialize_numeric_citation_numbers();
89        let mut cited_ids = self.cited_ids.borrow_mut();
90        for item in &citation.items {
91            cited_ids.insert(item.id.clone());
92        }
93    }
94
95    fn resolve_effective_citation_spec(&self, citation: &Citation) -> citum_schema::CitationSpec {
96        self.resolve_positioned_citation_spec(citation)
97            .into_owned()
98            .resolve_for_mode(&citation.mode)
99            .into_owned()
100    }
101
102    fn resolve_citation_delimiters<'a>(
103        &self,
104        effective_spec: &'a citum_schema::CitationSpec,
105    ) -> (&'a str, &'a str) {
106        let intra_delimiter = effective_spec.delimiter.as_deref().unwrap_or(", ");
107        let inter_delimiter = effective_spec
108            .multi_cite_delimiter
109            .as_deref()
110            .unwrap_or("; ");
111
112        (
113            if matches!(
114                DelimiterPunctuation::from_csl_string(intra_delimiter),
115                DelimiterPunctuation::None
116            ) {
117                ""
118            } else {
119                intra_delimiter
120            },
121            if matches!(
122                DelimiterPunctuation::from_csl_string(inter_delimiter),
123                DelimiterPunctuation::None
124            ) {
125                ""
126            } else {
127                inter_delimiter
128            },
129        )
130    }
131
132    /// Register a dynamic compound group for a `grouped` citation.
133    ///
134    /// The first item in `citation.items` is the head; subsequent items are tails.
135    /// Skips silently when:
136    /// - The style has no `compound-numeric` bibliography configuration (non-numeric style).
137    /// - A static compound set already covers the head or any tail (static sets take precedence).
138    /// - The head or any tail was previously cited in any context (first occurrence wins).
139    ///
140    /// This method must be called before `track_cited_ids_and_init_numbers` so that
141    /// `cited_ids` reflects only references from prior citations, not the current one.
142    fn resolve_dynamic_group(&self, citation: &Citation) {
143        if self.get_bibliography_options().compound_numeric.is_none() {
144            return;
145        }
146
147        if citation.items.len() < 2 {
148            return;
149        }
150
151        #[allow(clippy::indexing_slicing, reason = "citation.items.len() >= 2")]
152        let head_id = &citation.items[0].id;
153        #[allow(clippy::indexing_slicing, reason = "citation.items.len() >= 2")]
154        let tail_ids: Vec<String> = citation.items[1..].iter().map(|i| i.id.clone()).collect();
155
156        // Static sets take precedence — skip if head or any tail is in a static set.
157        if self.compound_set_by_ref.contains_key(head_id) {
158            return;
159        }
160        for tail in &tail_ids {
161            if self.compound_set_by_ref.contains_key(tail.as_str()) {
162                return;
163            }
164        }
165
166        // First-occurrence wins: reject if the head or any tail was already cited in any
167        // context — whether via a prior dynamic group or a previous ungrouped citation.
168        // Because this method is called before cited_ids is updated for the current
169        // citation, `cited_ids` contains only references from earlier citations.
170        {
171            let dyn_set = self.dynamic_compound_set_by_ref.borrow();
172            let cited = self.cited_ids.borrow();
173
174            if dyn_set.contains_key(head_id.as_str()) || cited.contains(head_id.as_str()) {
175                return;
176            }
177            for tail in &tail_ids {
178                if dyn_set.contains_key(tail.as_str()) || cited.contains(tail.as_str()) {
179                    return;
180                }
181            }
182        }
183
184        let head_number = {
185            let numbers = self.citation_numbers.borrow();
186            let Some(&n) = numbers.get(head_id.as_str()) else {
187                return;
188            };
189            n
190        };
191
192        // Assign all tails the same citation number as the head.
193        {
194            let mut numbers = self.citation_numbers.borrow_mut();
195            for tail in &tail_ids {
196                numbers.insert(tail.clone(), head_number);
197            }
198        }
199
200        // Build the ordered member list for this group.
201        let all_members: Vec<String> = std::iter::once(head_id.clone())
202            .chain(tail_ids.iter().cloned())
203            .collect();
204
205        // Populate dynamic index maps so the renderer can assign sub-labels.
206        {
207            let mut dyn_set = self.dynamic_compound_set_by_ref.borrow_mut();
208            let mut dyn_idx = self.dynamic_compound_member_index.borrow_mut();
209            for (idx, member) in all_members.iter().enumerate() {
210                dyn_set.insert(member.clone(), head_id.clone());
211                dyn_idx.insert(member.clone(), idx);
212            }
213        }
214
215        // Inject into compound_groups for bibliography rendering.
216        {
217            let mut groups = self.compound_groups.borrow_mut();
218            let members = groups
219                .entry(head_number)
220                .or_insert_with(|| vec![head_id.clone()]);
221            for tail in &tail_ids {
222                if !members.contains(tail) {
223                    members.push(tail.clone());
224                }
225            }
226        }
227
228        // Register dynamic set so citation_sub_label_for_ref can find members.
229        self.dynamic_compound_sets
230            .borrow_mut()
231            .insert(head_id.clone(), all_members);
232    }
233
234    /// Build the merged static + dynamic compound lookup maps for the renderer.
235    ///
236    /// When no dynamic groups exist (the common case) the static maps are returned
237    /// via references with no allocation. Owned merged maps are only constructed when
238    /// at least one dynamic group is registered.
239    fn merged_compound_data(
240        &self,
241    ) -> (
242        Option<HashMap<String, String>>,
243        Option<HashMap<String, usize>>,
244        Option<IndexMap<String, Vec<String>>>,
245    ) {
246        if self.dynamic_compound_set_by_ref.borrow().is_empty() {
247            return (None, None, None);
248        }
249        let merged_set: HashMap<String, String> = self
250            .compound_set_by_ref
251            .iter()
252            .chain(self.dynamic_compound_set_by_ref.borrow().iter())
253            .map(|(k, v)| (k.clone(), v.clone()))
254            .collect();
255        let merged_idx: HashMap<String, usize> = self
256            .compound_member_index
257            .iter()
258            .chain(self.dynamic_compound_member_index.borrow().iter())
259            .map(|(k, v)| (k.clone(), *v))
260            .collect();
261        let merged_sets: IndexMap<String, Vec<String>> = self
262            .compound_sets
263            .iter()
264            .chain(self.dynamic_compound_sets.borrow().iter())
265            .map(|(k, v)| (k.clone(), v.clone()))
266            .collect();
267        (Some(merged_set), Some(merged_idx), Some(merged_sets))
268    }
269
270    fn render_citation_content<F>(
271        &self,
272        citation: &Citation,
273        effective_spec: &citum_schema::CitationSpec,
274        renderer_delimiter: &str,
275        renderer_inter_delimiter: &str,
276        note_start_text_case: Option<NoteStartTextCase>,
277    ) -> Result<String, ProcessorError>
278    where
279        F: crate::render::format::OutputFormat<Output = String>,
280    {
281        // Grouped citations preserve item order (dynamic grouping was already resolved
282        // in process_citation_with_format before cited_ids was updated).
283        let sorted_items = if citation.grouped {
284            citation.items.clone()
285        } else {
286            self.sort_citation_items(citation.items.clone(), effective_spec)
287        };
288
289        // Build merged compound lookup maps (static + dynamic).
290        // Return owned maps only when dynamic groups exist; otherwise use static maps directly.
291        let (dyn_set_owned, dyn_idx_owned, dyn_sets_owned) = self.merged_compound_data();
292        let effective_set_by_ref = dyn_set_owned.as_ref().unwrap_or(&self.compound_set_by_ref);
293        let effective_member_index = dyn_idx_owned
294            .as_ref()
295            .unwrap_or(&self.compound_member_index);
296        let effective_compound_sets = dyn_sets_owned.as_ref().unwrap_or(&self.compound_sets);
297
298        let citation_config = self.get_citation_config();
299        let renderer = Renderer::new(
300            RendererResources {
301                style: &self.style,
302                bibliography: &self.bibliography,
303                locale: &self.locale,
304                config: &citation_config,
305                bibliography_config: Some(self.get_bibliography_options().into_owned()),
306            },
307            &self.hints,
308            &self.citation_numbers,
309            CompoundRenderData {
310                set_by_ref: effective_set_by_ref,
311                member_index: effective_member_index,
312                sets: effective_compound_sets,
313            },
314            self.show_semantics,
315            self.inject_ast_indices,
316            self.abbreviation_map.as_ref(),
317        );
318        let processing = citation_config.processing.clone().unwrap_or_default();
319        let has_explicit_integral_multi_cite_delimiter = matches!(
320            citation.mode,
321            citum_schema::citation::CitationMode::Integral
322        ) && self
323            .resolve_positioned_citation_spec(citation)
324            .integral
325            .as_ref()
326            .and_then(|spec| spec.multi_cite_delimiter.as_ref())
327            .is_some();
328        let rendered_groups = if matches!(
329            processing,
330            citum_schema::options::Processing::Numeric
331                | citum_schema::options::Processing::Label(_)
332        ) {
333            renderer.render_ungrouped_citation_with_format::<F>(
334                &sorted_items,
335                effective_spec,
336                &citation.mode,
337                renderer_delimiter,
338                citation.suppress_author,
339                citation.position.as_ref(),
340                note_start_text_case,
341            )?
342        } else {
343            renderer.render_grouped_citation_with_format::<F>(
344                &sorted_items,
345                &GroupRenderParams {
346                    spec: effective_spec,
347                    mode: &citation.mode,
348                    intra_delimiter: renderer_delimiter,
349                    suppress_author: citation.suppress_author,
350                    position: citation.position.as_ref(),
351                    note_start_text_case,
352                },
353            )?
354        };
355
356        Ok(
357            if matches!(
358                citation.mode,
359                citum_schema::citation::CitationMode::Integral
360            ) && !has_explicit_integral_multi_cite_delimiter
361            {
362                join_integral_groups(rendered_groups, &self.locale)
363            } else {
364                F::default().join(rendered_groups, renderer_inter_delimiter)
365            },
366        )
367    }
368
369    fn apply_citation_input_affixes<F>(
370        &self,
371        citation: &Citation,
372        content: String,
373        fmt: &F,
374    ) -> String
375    where
376        F: crate::render::format::OutputFormat<Output = String>,
377    {
378        let citation_prefix = citation.prefix.as_deref().unwrap_or("");
379        let citation_suffix = citation.suffix.as_deref().unwrap_or("");
380
381        if citation_prefix.is_empty() && citation_suffix.is_empty() {
382            return content;
383        }
384
385        let formatted_prefix =
386            if !citation_prefix.is_empty() && !citation_prefix.ends_with(char::is_whitespace) {
387                format!("{citation_prefix} ")
388            } else {
389                citation_prefix.to_string()
390            };
391
392        let formatted_suffix =
393            if !citation_suffix.is_empty() && !citation_suffix.starts_with(char::is_whitespace) {
394                format!(" {citation_suffix}")
395            } else {
396                citation_suffix.to_string()
397            };
398
399        fmt.affix(&formatted_prefix, content, &formatted_suffix)
400    }
401
402    fn apply_spec_wrap_and_affixes<F>(
403        &self,
404        citation: &Citation,
405        effective_spec: &citum_schema::CitationSpec,
406        output: String,
407        fmt: &F,
408    ) -> String
409    where
410        F: crate::render::format::OutputFormat<Output = String>,
411    {
412        let spec_prefix = effective_spec.prefix.as_deref().unwrap_or("");
413        let spec_suffix = effective_spec.suffix.as_deref().unwrap_or("");
414
415        if matches!(
416            citation.mode,
417            citum_schema::citation::CitationMode::Integral
418        ) {
419            if !spec_prefix.is_empty() || !spec_suffix.is_empty() {
420                fmt.affix(spec_prefix, output, spec_suffix)
421            } else {
422                output
423            }
424        } else if let Some(wrap) = effective_spec.wrap.as_ref() {
425            let inner_prefix = wrap.inner_prefix.as_deref().unwrap_or("");
426            let inner_suffix = wrap.inner_suffix.as_deref().unwrap_or("");
427            let inner_wrapped = if !inner_prefix.is_empty() || !inner_suffix.is_empty() {
428                fmt.inner_affix(inner_prefix, output, inner_suffix)
429            } else {
430                output
431            };
432            fmt.wrap_punctuation(&wrap.punctuation, inner_wrapped)
433        } else if !spec_prefix.is_empty() || !spec_suffix.is_empty() {
434            fmt.affix(spec_prefix, output, spec_suffix)
435        } else {
436            output
437        }
438    }
439
440    /// Render a single citation to plain text.
441    ///
442    /// This is the primary entry point for citation processing. It handles:
443    /// 1. Looking up references in the bibliography.
444    /// 2. Annotating positions (ibid, subsequent, etc.).
445    /// 3. Resolving disambiguation (name expansion, year suffixes).
446    /// 4. Applying the style's citation template.
447    ///
448    /// Returns the formatted citation string or an error if processing fails.
449    ///
450    /// # Errors
451    ///
452    /// Returns an error when referenced items are missing or rendering fails.
453    pub fn process_citation(&self, citation: &Citation) -> Result<String, ProcessorError> {
454        self.process_citation_with_format::<crate::render::plain::PlainText>(citation)
455    }
456
457    /// Render a citation to a string using a specific output format.
458    ///
459    /// This resolves the effective citation spec for the citation's mode and
460    /// position, renders the citation body, and applies input and style affixes.
461    ///
462    /// # Errors
463    ///
464    /// Returns an error when referenced items are missing or rendering fails.
465    pub fn process_citation_with_format<F>(
466        &self,
467        citation: &Citation,
468    ) -> Result<String, ProcessorError>
469    where
470        F: crate::render::format::OutputFormat<Output = String>,
471    {
472        let fmt = F::default();
473
474        // For grouped citations, resolve the dynamic compound group BEFORE updating
475        // cited_ids with the current citation's items. This ensures the first-occurrence
476        // check in resolve_dynamic_group sees only references from prior citations.
477        if citation.grouped {
478            self.initialize_numeric_citation_numbers();
479            self.resolve_dynamic_group(citation);
480        }
481
482        self.track_cited_ids_and_init_numbers(citation);
483
484        let effective_spec = self.resolve_effective_citation_spec(citation);
485        let note_start_text_case =
486            self.sentence_initial_note_start_text_case(citation, &effective_spec);
487        let (renderer_delimiter, renderer_inter_delimiter) =
488            self.resolve_citation_delimiters(&effective_spec);
489        let content = self.render_citation_content::<F>(
490            citation,
491            &effective_spec,
492            renderer_delimiter,
493            renderer_inter_delimiter,
494            note_start_text_case,
495        )?;
496        let output = self.apply_citation_input_affixes(citation, content, &fmt);
497        let wrapped = self.apply_spec_wrap_and_affixes(citation, &effective_spec, output, &fmt);
498
499        Ok(fmt.finish(wrapped))
500    }
501
502    /// Render multiple citations in document order.
503    ///
504    /// For note-based styles, normalizes context and assigns citation positions.
505    ///
506    /// # Errors
507    ///
508    /// Returns an error when any citation in the sequence fails to render.
509    pub fn process_citations(&self, citations: &[Citation]) -> Result<Vec<String>, ProcessorError> {
510        self.process_citations_with_format::<crate::render::plain::PlainText>(citations)
511    }
512
513    /// Render multiple citations with a custom output format.
514    ///
515    /// # Errors
516    ///
517    /// Returns an error when any citation in the sequence fails to render.
518    pub fn process_citations_with_format<F>(
519        &self,
520        citations: &[Citation],
521    ) -> Result<Vec<String>, ProcessorError>
522    where
523        F: crate::render::format::OutputFormat<Output = String>,
524    {
525        let mut normalized = self.normalize_note_context(citations);
526        self.annotate_positions(&mut normalized);
527        normalized
528            .iter()
529            .map(|citation| self.process_citation_with_format::<F>(citation))
530            .collect()
531    }
532}