Skip to main content

citum_engine/processor/rendering/
mod.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Rendering logic for citation and bibliography output.
7//!
8//! This module handles template-based rendering of citations and bibliographies,
9//! including handling of localization, numbering, formatting, and special modes
10//! like integral (narrative) citations for numeric and label styles.
11
12use crate::error::ProcessorError;
13use crate::reference::{Bibliography, Reference};
14use crate::values::{ProcHints, RenderContext, RenderOptions};
15use citum_schema::citation::CitationLocator;
16use citum_schema::locale::Locale;
17use citum_schema::options::{Config, bibliography::BibliographyConfig};
18use citum_schema::template::TemplateComponent;
19use grouped::component_predicates::resolve_type_variant;
20use indexmap::IndexMap;
21use std::borrow::Cow;
22use std::cell::RefCell;
23use std::collections::{HashMap, HashSet};
24use std::sync::{Arc, RwLock};
25
26/// The renderer for citation and bibliography templates.
27///
28/// The `Renderer` is responsible for taking compiled templates and applying them
29/// to bibliographic data, handling localization, numbering, and formatting.
30pub struct Renderer<'a> {
31    /// The style definition containing templates and options.
32    pub style: &'a citum_schema::Style,
33    /// The bibliography containing the reference data.
34    pub bibliography: &'a Bibliography,
35    /// The locale used for terms and formatting.
36    pub locale: &'a Locale,
37    /// The active configuration options.
38    pub config: Arc<Config>,
39    /// The active bibliography-only configuration.
40    pub bibliography_config: Option<Arc<BibliographyConfig>>,
41    /// Pre-calculated hints for optimization.
42    pub hints: &'a HashMap<String, ProcHints>,
43    /// Shared state for citation numbers (used in numeric styles).
44    ///
45    /// `RwLock`, not `RefCell`: bibliography entries render in parallel
46    /// (behind the `parallel` feature) once above `PARALLEL_MIN_ENTRIES`,
47    /// and each per-entry `Renderer` borrows this same run-scoped map.
48    pub citation_numbers: &'a RwLock<HashMap<String, usize>>,
49    /// Optional compound set membership indexed by reference id.
50    pub compound_set_by_ref: &'a HashMap<String, String>,
51    /// Optional 0-based member index within each compound set.
52    pub compound_member_index: &'a HashMap<String, usize>,
53    /// Compound sets keyed by set id.
54    pub compound_sets: &'a IndexMap<String, Vec<String>>,
55    /// Whether to output semantic markup (HTML spans, Djot attributes).
56    pub show_semantics: bool,
57    /// Whether to attach source template indices to rendered semantic wrappers.
58    pub inject_ast_indices: bool,
59    /// Mapping from filtered to original template indices (for grouped citations).
60    pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
61    /// Document-level abbreviation map for post-render substitution.
62    pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
63    /// First note number per reference id (populated by normalize_note_context).
64    pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
65}
66
67/// Borrowed compound-set context for rendering.
68pub struct CompoundRenderData<'a> {
69    /// Optional compound set membership indexed by reference id.
70    pub set_by_ref: &'a HashMap<String, String>,
71    /// Optional 0-based member index within each compound set.
72    pub member_index: &'a HashMap<String, usize>,
73    /// Compound sets keyed by set id.
74    pub sets: &'a IndexMap<String, Vec<String>>,
75}
76
77mod collapse;
78mod grouped;
79mod grouped_fallback;
80mod helpers;
81
82#[cfg(test)]
83#[allow(
84    clippy::unwrap_used,
85    clippy::expect_used,
86    clippy::panic,
87    clippy::indexing_slicing,
88    clippy::todo,
89    clippy::unimplemented,
90    clippy::unreachable,
91    clippy::get_unwrap,
92    reason = "Panicking is acceptable and often desired in tests."
93)]
94mod tests;
95
96pub use grouped_fallback::GroupRenderParams;
97pub use grouped_fallback::TemplateRenderParams;
98pub(super) use helpers::{
99    find_grouping_component, has_contributor_component, leading_group_affix,
100    remove_first_contributor_with_role, strip_author_component, strip_leading_group_affixes,
101};
102
103/// Internal render request used to keep template-processing call sites compact.
104pub struct TemplateRenderRequest<'a> {
105    /// The template to render.
106    pub template: &'a [TemplateComponent],
107    /// The rendering context (Citation or Bibliography).
108    pub context: RenderContext,
109    /// The citation mode (Integral or `NonIntegral`).
110    pub mode: citum_schema::citation::CitationMode,
111    /// Whether to suppress the author in output.
112    pub suppress_author: bool,
113    /// The raw citation locator if present (for new rendering logic).
114    pub locator_raw: Option<&'a CitationLocator>,
115    /// The citation number for numeric styles.
116    pub citation_number: usize,
117    /// The citation position (e.g., Ibid).
118    pub position: Option<citum_schema::citation::Position>,
119    /// Optional note-start text-case policy for note-style repeated-note output.
120    pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
121    /// Integral name state for name formatting.
122    pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
123    /// Org abbreviation state for org-name formatting.
124    pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
125    /// First note number for this reference (note styles, subsequent position).
126    pub first_reference_note_number: Option<u32>,
127}
128
129/// Per-item state resolved for rendering one ungrouped citation item.
130struct UngroupedItemRenderState<'a> {
131    reference: &'a Reference,
132    template: Cow<'a, [TemplateComponent]>,
133    delimiter: &'a str,
134}
135
136/// Shared, citation-wide parameters threaded into each ungrouped item render.
137#[derive(Clone, Copy)]
138struct UngroupedItemRenderParams<'a> {
139    mode: &'a citum_schema::citation::CitationMode,
140    suppress_author: bool,
141    position: Option<&'a citum_schema::citation::Position>,
142    note_start_text_case: Option<citum_schema::NoteStartTextCase>,
143}
144
145#[derive(Clone, Default)]
146struct TemplateComponentTracker {
147    rendered_vars: HashSet<String>,
148    substituted_bases: HashSet<String>,
149}
150
151impl TemplateComponentTracker {
152    fn should_skip(&self, var_key: Option<&str>) -> bool {
153        let Some(var_key) = var_key else {
154            return false;
155        };
156        let base = key_base(var_key);
157        self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
158    }
159
160    fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
161        if let Some(var_key) = var_key {
162            self.rendered_vars.insert(var_key);
163        }
164        if let Some(substituted_key) = substituted_key {
165            self.rendered_vars.insert(substituted_key.to_string());
166            self.substituted_bases
167                .insert(key_base(substituted_key).into_owned());
168        }
169    }
170
171    fn merge_from(&mut self, other: Self) {
172        self.rendered_vars.extend(other.rendered_vars);
173        self.substituted_bases.extend(other.substituted_bases);
174    }
175}
176
177/// Core style resources borrowed by every [`Renderer`] instance.
178///
179/// Bundles the four immutable resolution inputs so that [`Renderer::new`] stays
180/// within clippy's argument-count limit.
181pub struct RendererResources<'a> {
182    /// The style definition containing templates and options.
183    pub style: &'a citum_schema::Style,
184    /// The bibliography containing the reference data.
185    pub bibliography: &'a Bibliography,
186    /// The locale used for terms and formatting.
187    pub locale: &'a Locale,
188    /// The active configuration options.
189    pub config: Arc<Config>,
190    /// The active bibliography-only configuration.
191    pub bibliography_config: Option<Arc<BibliographyConfig>>,
192    /// First note number per reference id (note styles; `None` for bibliography rendering).
193    pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
194}
195
196impl<'a> Renderer<'a> {
197    /// Creates a new `Renderer` instance.
198    pub fn new(
199        resources: RendererResources<'a>,
200        hints: &'a HashMap<String, ProcHints>,
201        citation_numbers: &'a RwLock<HashMap<String, usize>>,
202        compound: CompoundRenderData<'a>,
203        show_semantics: bool,
204        inject_ast_indices: bool,
205        abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
206    ) -> Self {
207        Self {
208            style: resources.style,
209            bibliography: resources.bibliography,
210            locale: resources.locale,
211            config: resources.config,
212            bibliography_config: resources.bibliography_config,
213            hints,
214            citation_numbers,
215            compound_set_by_ref: compound.set_by_ref,
216            compound_member_index: compound.member_index,
217            compound_sets: compound.sets,
218            show_semantics,
219            inject_ast_indices,
220            filtered_to_original_index: RefCell::new(None),
221            abbreviation_map,
222            first_note_by_id: resources.first_note_by_id,
223        }
224    }
225
226    /// Resolve multilingual contributor names using the style's config.
227    fn resolve_contributor_names(
228        &self,
229        contributor: &citum_schema::reference::contributor::Contributor,
230    ) -> Vec<crate::reference::FlatName> {
231        let ml = self.config.multilingual.as_ref();
232        crate::values::resolve_multilingual_name(
233            contributor,
234            ml.and_then(|m| m.name_mode.as_ref()),
235            ml.and_then(|m| m.preferred_transliteration.as_deref()),
236            ml.and_then(|m| m.preferred_script.as_ref()),
237            &self.locale.locale,
238        )
239    }
240
241    /// Generate an alphabetic or numeric sub-label (e.g., "a", "1") for a
242    /// reference member of a compound set.
243    fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
244        let compound = self
245            .bibliography_config
246            .as_ref()
247            .and_then(|b| b.compound_numeric.as_ref())?;
248        let set_id = self.compound_set_by_ref.get(ref_id)?;
249        let members = self.compound_sets.get(set_id)?;
250        if members.len() <= 1 {
251            return None;
252        }
253        if !compound.subentry {
254            return None;
255        }
256        let idx = *self.compound_member_index.get(ref_id)?;
257        match compound.sub_label {
258            citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
259                crate::values::int_to_letter((idx + 1) as u32)
260            }
261            citum_schema::options::bibliography::SubLabelStyle::Numeric => {
262                Some((idx + 1).to_string())
263            }
264        }
265    }
266
267    /// Determines if the processor should render author-plus-number text for a numeric style
268    /// when in "integral" (narrative) citation mode.
269    ///
270    /// This happens when the style is numeric and the user requests a narrative
271    /// citation (e.g., "Smith [1]"), but hasn't provided an explicit narrative template.
272    fn should_render_author_number_for_numeric_integral(
273        &self,
274        mode: &citum_schema::citation::CitationMode,
275    ) -> bool {
276        matches!(mode, citum_schema::citation::CitationMode::Integral)
277            && self.config.processing.as_ref().is_some_and(|processing| {
278                matches!(processing, citum_schema::options::Processing::Numeric)
279            })
280            && !self.has_explicit_integral_template()
281    }
282
283    /// Whether the style provides an explicit integral (narrative) template.
284    fn has_explicit_integral_template(&self) -> bool {
285        self.style.citation.as_ref().is_some_and(|c| {
286            c.integral.as_ref().is_some_and(|i| {
287                i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
288            })
289        })
290    }
291
292    /// Determine if compound subentries should be collapsed for this citation.
293    fn should_collapse_compound_subentries(
294        &self,
295        mode: &citum_schema::citation::CitationMode,
296    ) -> bool {
297        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
298            return false;
299        }
300
301        self.bibliography_config
302            .as_ref()
303            .and_then(|b| b.compound_numeric.as_ref())
304            .is_some_and(|c| c.subentry && c.collapse_subentries)
305    }
306
307    /// Determine if citation numbers should be collapsed into ranges.
308    fn should_collapse_citation_numbers(
309        &self,
310        spec: &citum_schema::CitationSpec,
311        mode: &citum_schema::citation::CitationMode,
312    ) -> bool {
313        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
314            return false;
315        }
316
317        let is_numeric = self
318            .config
319            .processing
320            .as_ref()
321            .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
322
323        is_numeric
324            && matches!(
325                spec.collapse,
326                Some(citum_schema::CitationCollapse::CitationNumber)
327            )
328    }
329
330    /// Heuristic for ensuring proper spacing after a citation prefix.
331    fn normalize_prefix_spacing(prefix: &str) -> String {
332        if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
333            format!("{prefix} ")
334        } else {
335            prefix.to_string()
336        }
337    }
338
339    /// Ensure suffix has proper spacing (add space if suffix doesn't start with
340    /// punctuation and isn't empty).
341    fn ensure_suffix_spacing(suffix: &str) -> String {
342        if suffix.is_empty() {
343            String::new()
344        } else if suffix.starts_with(char::is_whitespace)
345            || suffix.starts_with(',')
346            || suffix.starts_with(';')
347            || suffix.starts_with('.')
348        {
349            // Already has leading space or punctuation
350            suffix.to_string()
351        } else {
352            // Add space before suffix to separate from content
353            format!(" {suffix}")
354        }
355    }
356
357    /// Apply prefix and suffix spacing heuristics to a rendered string.
358    fn affix_content<F>(
359        &self,
360        fmt: &F,
361        content: String,
362        prefix: Option<&str>,
363        suffix: Option<&str>,
364    ) -> String
365    where
366        F: crate::render::format::OutputFormat<Output = String>,
367    {
368        let prefix = prefix.unwrap_or("");
369        let suffix = suffix.unwrap_or("");
370        if prefix.is_empty() && suffix.is_empty() {
371            content
372        } else {
373            fmt.affix(
374                &Self::normalize_prefix_spacing(prefix),
375                content,
376                &Self::ensure_suffix_spacing(suffix),
377            )
378        }
379    }
380
381    /// Pair rendered content with associated reference IDs to form a semantic chunk.
382    fn build_citation_chunk<F>(
383        &self,
384        fmt: &F,
385        ids: Vec<String>,
386        content: String,
387        prefix: Option<&str>,
388        suffix: Option<&str>,
389    ) -> Option<(Vec<String>, String)>
390    where
391        F: crate::render::format::OutputFormat<Output = String>,
392    {
393        if content.is_empty() {
394            None
395        } else {
396            Some((ids, self.affix_content(fmt, content, prefix, suffix)))
397        }
398    }
399
400    /// Build a citation chunk for a single item from its rendered content.
401    fn build_item_chunk<F>(
402        &self,
403        fmt: &F,
404        item: &crate::reference::CitationItem,
405        content: String,
406    ) -> Option<(Vec<String>, String)>
407    where
408        F: crate::render::format::OutputFormat<Output = String>,
409    {
410        self.build_citation_chunk(
411            fmt,
412            vec![item.id.clone()],
413            content,
414            item.prefix.as_deref(),
415            item.suffix.as_deref(),
416        )
417    }
418
419    /// Create a template render request for a single citation item.
420    fn citation_render_request<'b>(
421        &self,
422        item: &'b crate::reference::CitationItem,
423        template: &'b [TemplateComponent],
424        mode: &citum_schema::citation::CitationMode,
425        suppress_author: bool,
426        position: Option<&citum_schema::citation::Position>,
427        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
428    ) -> TemplateRenderRequest<'b> {
429        TemplateRenderRequest {
430            template,
431            context: RenderContext::Citation,
432            mode: mode.clone(),
433            suppress_author,
434            locator_raw: item.locator.as_ref(),
435            citation_number: self.get_or_assign_citation_number(&item.id),
436            position: position.cloned(),
437            note_start_text_case,
438            integral_name_state: item.integral_name_state,
439            org_abbreviation_state: item.org_abbreviation_state,
440            first_reference_note_number: self.first_note_by_id.as_ref().and_then(|m| {
441                m.read()
442                    .unwrap_or_else(std::sync::PoisonError::into_inner)
443                    .get(&item.id)
444                    .copied()
445            }),
446        }
447    }
448
449    /// Render a single item to a formatted string using a template.
450    fn render_item_from_template_with_format<F>(
451        &self,
452        reference: &Reference,
453        request: TemplateRenderRequest<'_>,
454        delimiter: &str,
455    ) -> Option<String>
456    where
457        F: crate::render::format::OutputFormat<Output = String>,
458    {
459        self.process_template_request_with_format::<F>(reference, request)
460            .map(|proc| {
461                crate::render::citation::citation_to_string_with_format::<F>(
462                    &proc,
463                    None,
464                    None,
465                    None,
466                    Some(delimiter),
467                )
468            })
469    }
470
471    /// Resolve the reference, template, and delimiter needed to render one
472    /// ungrouped citation item, applying type-variant and language fallbacks.
473    fn resolve_ungrouped_item_render_state<'b>(
474        &'b self,
475        item: &'b crate::reference::CitationItem,
476        spec: &'b citum_schema::CitationSpec,
477        intra_delimiter: &'b str,
478    ) -> Result<UngroupedItemRenderState<'b>, ProcessorError> {
479        let reference = self
480            .bibliography
481            .get(&item.id)
482            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
483        let ref_type = reference.ref_type();
484        let item_language = crate::values::effective_item_language(reference);
485        let template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
486            .map(Cow::Borrowed)
487            .or_else(|| {
488                spec.resolve_template_for_language(item_language.as_deref())
489                    .map(Cow::Owned)
490            })
491            .unwrap_or(Cow::Borrowed(&[]));
492
493        Ok(UngroupedItemRenderState {
494            reference,
495            template,
496            delimiter: spec.delimiter.as_deref().unwrap_or(intra_delimiter),
497        })
498    }
499
500    /// Initialize render options for a citation.
501    fn citation_render_options<'b>(
502        &'b self,
503        mode: citum_schema::citation::CitationMode,
504        suppress_author: bool,
505        locator_raw: Option<&'b CitationLocator>,
506        ref_type: Option<String>,
507    ) -> RenderOptions<'b> {
508        RenderOptions {
509            config: self.config.clone(),
510            bibliography_config: self.bibliography_config.clone(),
511            locale: self.locale,
512            context: RenderContext::Citation,
513            mode,
514            suppress_author,
515            locator_raw,
516            ref_type,
517            show_semantics: self.show_semantics,
518            current_template_index: None,
519            abbreviation_map: self.abbreviation_map,
520        }
521    }
522
523    /// Render author + citation number for numeric integral citations.
524    ///
525    /// Default implementation for narrative citations in numeric styles (e.g., "Smith [1]").
526    fn render_author_number_for_numeric_integral_with_format<F>(
527        &self,
528        reference: &Reference,
529        item: &crate::reference::CitationItem,
530        citation_number: usize,
531    ) -> String
532    where
533        F: crate::render::format::OutputFormat<Output = String>,
534    {
535        let fmt = F::default();
536        let options = self.citation_render_options(
537            citum_schema::citation::CitationMode::Integral,
538            false,
539            item.locator.as_ref(),
540            Some(reference.ref_type()),
541        );
542
543        // Render author in short form
544        let author_part = if let Some(authors) = reference.author() {
545            let names_vec = self.resolve_contributor_names(&authors);
546            fmt.text(&crate::values::format_contributors_short(
547                &names_vec, &options,
548            ))
549        } else {
550            String::new()
551        };
552
553        // Include compound sub-label (e.g. "a", "b") when applicable.
554        let ref_id = reference.id().unwrap_or_default().to_string();
555        let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
556
557        // Format: "Author [Na]"
558        if author_part.is_empty() {
559            // Fallback: just citation number if no author
560            format!("[{citation_number}{sub_label}]")
561        } else {
562            format!("{author_part} [{citation_number}{sub_label}]")
563        }
564    }
565
566    /// Render one item as author + citation number for numeric integral cites.
567    fn render_numeric_integral_item_chunk_with_format<F>(
568        &self,
569        fmt: &F,
570        item: &crate::reference::CitationItem,
571    ) -> Result<Option<(Vec<String>, String)>, ProcessorError>
572    where
573        F: crate::render::format::OutputFormat<Output = String>,
574    {
575        let reference = self
576            .bibliography
577            .get(&item.id)
578            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
579        let citation_number = self.get_or_assign_citation_number(&item.id);
580        let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
581            reference,
582            item,
583            citation_number,
584        );
585        Ok(self.build_item_chunk(fmt, item, item_str))
586    }
587
588    /// Render one ungrouped item from its resolved template state.
589    fn render_template_item_chunk_with_format<F>(
590        &self,
591        fmt: &F,
592        item: &crate::reference::CitationItem,
593        state: UngroupedItemRenderState<'_>,
594        params: UngroupedItemRenderParams<'_>,
595    ) -> Option<(Vec<String>, String)>
596    where
597        F: crate::render::format::OutputFormat<Output = String>,
598    {
599        let request = self.citation_render_request(
600            item,
601            &state.template,
602            params.mode,
603            params.suppress_author,
604            params.position,
605            params.note_start_text_case,
606        );
607        self.render_item_from_template_with_format::<F>(state.reference, request, state.delimiter)
608            .and_then(|item_str| self.build_item_chunk(fmt, item, item_str))
609    }
610
611    /// Render citation items without grouping, using plain text format.
612    ///
613    /// # Errors
614    ///
615    /// Returns an error when a referenced item is missing or item rendering
616    /// fails.
617    pub fn render_ungrouped_citation(
618        &self,
619        items: &[crate::reference::CitationItem],
620        spec: &citum_schema::CitationSpec,
621        mode: &citum_schema::citation::CitationMode,
622        intra_delimiter: &str,
623        suppress_author: bool,
624        position: Option<&citum_schema::citation::Position>,
625    ) -> Result<Vec<String>, ProcessorError> {
626        self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
627            items,
628            spec,
629            mode,
630            intra_delimiter,
631            suppress_author,
632            position,
633            spec.note_start_text_case,
634        )
635    }
636
637    /// Render citation items without grouping, generic over the output format.
638    ///
639    /// This is the core logic for iterating over citation items, looking up references,
640    /// and applying the appropriate template or fallback logic.
641    ///
642    /// # Errors
643    ///
644    /// Returns an error when a referenced item is missing or item rendering
645    /// fails.
646    #[allow(
647        clippy::too_many_arguments,
648        reason = "Ungrouped citation rendering now needs explicit note-start context."
649    )]
650    pub fn render_ungrouped_citation_with_format<F>(
651        &self,
652        items: &[crate::reference::CitationItem],
653        spec: &citum_schema::CitationSpec,
654        mode: &citum_schema::citation::CitationMode,
655        intra_delimiter: &str,
656        suppress_author: bool,
657        position: Option<&citum_schema::citation::Position>,
658        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
659    ) -> Result<Vec<String>, ProcessorError>
660    where
661        F: crate::render::format::OutputFormat<Output = String>,
662    {
663        let fmt = F::default();
664        let mut chunks: Vec<(Vec<String>, String)> = Vec::new();
665
666        // For numeric styles with integral mode, render author + citation number instead.
667        let use_author_number = self.should_render_author_number_for_numeric_integral(mode);
668        let params = UngroupedItemRenderParams {
669            mode,
670            suppress_author,
671            position,
672            note_start_text_case,
673        };
674
675        for item in items {
676            let chunk = if use_author_number {
677                self.render_numeric_integral_item_chunk_with_format::<F>(&fmt, item)?
678            } else {
679                let state =
680                    self.resolve_ungrouped_item_render_state(item, spec, intra_delimiter)?;
681                self.render_template_item_chunk_with_format::<F>(&fmt, item, state, params)
682            };
683
684            if let Some(chunk) = chunk {
685                chunks.push(chunk);
686            }
687        }
688
689        if self.should_collapse_compound_subentries(mode) {
690            chunks = self.collapse_compound_citation_chunks(chunks);
691        }
692        if self.should_collapse_citation_numbers(spec, mode) {
693            chunks = self.collapse_numeric_citation_chunks(chunks);
694        }
695
696        Ok(chunks
697            .into_iter()
698            .map(|(ids, content)| fmt.citation(ids, content))
699            .collect())
700    }
701}
702
703fn key_base(key: &str) -> Cow<'_, str> {
704    let mut parts = key.splitn(3, ':');
705    match (parts.next(), parts.next()) {
706        (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
707        _ => Cow::Borrowed(key),
708    }
709}
710
711/// Get a unique key for a template component's variable.
712///
713/// The key includes rendering context (prefix/suffix) to allow the same variable
714/// to render multiple times if it appears in semantically different contexts.
715/// This enables styles like Chicago that require year after author AND after publisher.
716#[must_use]
717pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
718    use citum_schema::template::Rendering;
719    use std::fmt::Write;
720
721    fn push_context_suffix(key: &mut String, rendering: &Rendering) {
722        match (&rendering.prefix, &rendering.suffix) {
723            (Some(prefix), Some(suffix)) => {
724                key.push(':');
725                key.push_str(prefix);
726                key.push('_');
727                key.push_str(suffix);
728            }
729            (Some(prefix), None) => {
730                key.push(':');
731                key.push_str(prefix);
732            }
733            (None, Some(suffix)) => {
734                key.push(':');
735                key.push_str(suffix);
736            }
737            (None, None) => {}
738        }
739    }
740
741    fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
742        let mut key = String::new();
743        write!(&mut key, "{kind}:{value:?}").ok()?;
744        push_context_suffix(&mut key, rendering);
745        Some(key)
746    }
747
748    match component {
749        TemplateComponent::Contributor(c) => c.contributor.as_single().map_or_else(
750            || make_key("contributor", &c.contributor, &c.rendering),
751            |role| make_key("contributor", role, &c.rendering),
752        ),
753        TemplateComponent::Date(d) => make_key("date", &d.date, &d.rendering),
754        TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
755        TemplateComponent::Title(t) => {
756            let mut key = format!("title:{:?}", t.title);
757            if let Some(form) = &t.form {
758                write!(&mut key, ":{form:?}").ok()?;
759            }
760            push_context_suffix(&mut key, &t.rendering);
761            Some(key)
762        }
763        TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
764        TemplateComponent::Group(_) => None,
765        _ => None,
766    }
767}