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::{
18    CitationLabelMode, Config, LabelWrap, bibliography::BibliographyConfig,
19};
20use citum_schema::template::TemplateComponent;
21use grouped::component_predicates::{resolve_localized_type_variant, resolve_type_variant};
22use indexmap::IndexMap;
23use std::borrow::Cow;
24use std::cell::RefCell;
25use std::collections::{HashMap, HashSet};
26use std::sync::{Arc, OnceLock, RwLock};
27
28fn embedded_render_locales() -> &'static HashMap<String, Locale> {
29    static LOCALES: OnceLock<HashMap<String, Locale>> = OnceLock::new();
30    LOCALES.get_or_init(|| {
31        let mut locales = HashMap::new();
32        for id in citum_schema::embedded::EMBEDDED_LOCALE_IDS {
33            let Some(locale) = citum_schema::embedded::get_locale(id) else {
34                continue;
35            };
36            locales.insert(id.to_ascii_lowercase(), locale);
37        }
38        locales
39    })
40}
41
42/// Look up a loaded embedded locale exactly matching `locale_id`, falling
43/// back to another loaded locale sharing the same primary BCP 47 subtag
44/// (e.g. `de-AT` falls back to a loaded `de-DE`). Returns `None` when
45/// neither matches; the caller decides the ultimate fallback (the style
46/// locale, per `docs/specs/PER_ITEM_TERM_LOCALE.md` §3 and
47/// `MULTILINGUAL.md` §3.4).
48pub(crate) fn lookup_embedded_locale(locale_id: &str) -> Option<&'static Locale> {
49    let locales = embedded_render_locales();
50    let key = locale_id.to_ascii_lowercase();
51    locales.get(&key).or_else(|| {
52        let primary = key.split(['-', '_']).next()?;
53        locales.iter().find_map(|(candidate, locale)| {
54            candidate
55                .split(['-', '_'])
56                .next()
57                .is_some_and(|candidate_primary| candidate_primary == primary)
58                .then_some(locale)
59        })
60    })
61}
62
63/// The renderer for citation and bibliography templates.
64///
65/// The `Renderer` is responsible for taking compiled templates and applying them
66/// to bibliographic data, handling localization, numbering, and formatting.
67pub struct Renderer<'a> {
68    /// The style definition containing templates and options.
69    pub style: &'a citum_schema::Style,
70    /// The bibliography containing the reference data.
71    pub bibliography: &'a Bibliography,
72    /// The locale used for terms and formatting.
73    pub locale: &'a Locale,
74    /// The active configuration options.
75    pub config: Arc<Config>,
76    /// The active bibliography-only configuration.
77    pub bibliography_config: Option<Arc<BibliographyConfig>>,
78    /// Pre-calculated hints for optimization.
79    pub hints: &'a HashMap<String, ProcHints>,
80    /// Shared state for citation numbers (used in numeric styles).
81    ///
82    /// `RwLock`, not `RefCell`: bibliography entries render in parallel
83    /// (behind the `parallel` feature) once above `PARALLEL_MIN_ENTRIES`,
84    /// and each per-entry `Renderer` borrows this same run-scoped map.
85    pub citation_numbers: &'a RwLock<HashMap<String, usize>>,
86    /// Optional compound set membership indexed by reference id.
87    pub compound_set_by_ref: &'a HashMap<String, String>,
88    /// Optional 0-based member index within each compound set.
89    pub compound_member_index: &'a HashMap<String, usize>,
90    /// Compound sets keyed by set id.
91    pub compound_sets: &'a IndexMap<String, Vec<String>>,
92    /// Whether to output semantic markup (HTML spans, Djot attributes).
93    pub show_semantics: bool,
94    /// Whether to attach source template indices to rendered semantic wrappers.
95    pub inject_ast_indices: bool,
96    /// Mapping from filtered to original template indices (for grouped citations).
97    pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
98    /// Document-level abbreviation map for post-render substitution.
99    pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
100    /// First note number per reference id (populated by normalize_note_context).
101    pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
102}
103
104/// Borrowed compound-set context for rendering.
105pub struct CompoundRenderData<'a> {
106    /// Optional compound set membership indexed by reference id.
107    pub set_by_ref: &'a HashMap<String, String>,
108    /// Optional 0-based member index within each compound set.
109    pub member_index: &'a HashMap<String, usize>,
110    /// Compound sets keyed by set id.
111    pub sets: &'a IndexMap<String, Vec<String>>,
112}
113
114mod collapse;
115mod grouped;
116mod grouped_fallback;
117mod helpers;
118mod marker;
119
120#[cfg(test)]
121#[allow(
122    clippy::unwrap_used,
123    clippy::expect_used,
124    clippy::panic,
125    clippy::indexing_slicing,
126    clippy::todo,
127    clippy::unimplemented,
128    clippy::unreachable,
129    clippy::get_unwrap,
130    reason = "Panicking is acceptable and often desired in tests."
131)]
132mod tests;
133
134pub use grouped_fallback::GroupRenderParams;
135pub use grouped_fallback::TemplateRenderParams;
136pub(super) use helpers::{
137    find_grouping_component, has_contributor_component, leading_group_affix,
138    remove_first_contributor_with_role, strip_author_component, strip_leading_group_affixes,
139};
140
141/// Internal render request used to keep template-processing call sites compact.
142pub struct TemplateRenderRequest<'a> {
143    /// The template to render.
144    pub template: &'a [TemplateComponent],
145    /// The rendering context (Citation or Bibliography).
146    pub context: RenderContext,
147    /// The citation mode (Integral or `NonIntegral`).
148    pub mode: citum_schema::citation::CitationMode,
149    /// Whether to suppress the author in output.
150    pub suppress_author: bool,
151    /// The raw citation locator if present (for new rendering logic).
152    pub locator_raw: Option<&'a CitationLocator>,
153    /// The citation number for numeric styles.
154    pub citation_number: usize,
155    /// The citation position (e.g., Ibid).
156    pub position: Option<citum_schema::citation::Position>,
157    /// Optional note-start text-case policy for note-style repeated-note output.
158    pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
159    /// Integral name state for name formatting.
160    pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
161    /// Org abbreviation state for org-name formatting.
162    pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
163    /// First note number for this reference (note styles, subsequent position).
164    pub first_reference_note_number: Option<u32>,
165}
166
167/// Per-item state resolved for rendering one ungrouped citation item.
168struct UngroupedItemRenderState<'a> {
169    reference: &'a Reference,
170    template: Cow<'a, [TemplateComponent]>,
171    delimiter: &'a str,
172    /// The reference marker this item renders, if the style declares one.
173    marker: Option<marker::CitationMarkerSpec>,
174}
175
176/// A resolved reference marker: its value plus how to present it.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub(super) struct ResolvedMarker {
179    /// The generated token.
180    pub value: marker::MarkerValue,
181    /// How the marker is placed and wrapped.
182    pub spec: marker::CitationMarkerSpec,
183}
184
185/// A rendered citation item plus the metadata needed for semantic collapsing.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub(super) struct CitationChunk {
188    /// Reference IDs represented by this chunk.
189    pub ids: Vec<String>,
190    /// Rendered chunk content before final numeric-label presentation.
191    pub content: String,
192    /// The reference marker, when this chunk is exactly its marker and so can
193    /// still take part in numeric collapse. Presentation is realized after
194    /// collapse; a chunk that has already been composed carries `None`.
195    pub marker: Option<ResolvedMarker>,
196}
197
198/// Shared, citation-wide parameters threaded into each ungrouped item render.
199#[derive(Clone, Copy)]
200struct UngroupedItemRenderParams<'a> {
201    mode: &'a citum_schema::citation::CitationMode,
202    suppress_author: bool,
203    position: Option<&'a citum_schema::citation::Position>,
204    note_start_text_case: Option<citum_schema::NoteStartTextCase>,
205}
206
207#[derive(Clone, Default)]
208struct TemplateComponentTracker {
209    rendered_vars: HashSet<String>,
210    substituted_bases: HashSet<String>,
211}
212
213impl TemplateComponentTracker {
214    fn should_skip(&self, var_key: Option<&str>) -> bool {
215        let Some(var_key) = var_key else {
216            return false;
217        };
218        let base = key_base(var_key);
219        self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
220    }
221
222    fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
223        if let Some(var_key) = var_key {
224            self.rendered_vars.insert(var_key);
225        }
226        if let Some(substituted_key) = substituted_key {
227            self.rendered_vars.insert(substituted_key.to_string());
228            self.substituted_bases
229                .insert(key_base(substituted_key).into_owned());
230        }
231    }
232
233    fn merge_from(&mut self, other: Self) {
234        self.rendered_vars.extend(other.rendered_vars);
235        self.substituted_bases.extend(other.substituted_bases);
236    }
237}
238
239/// Core style resources borrowed by every [`Renderer`] instance.
240///
241/// Bundles the four immutable resolution inputs so that [`Renderer::new`] stays
242/// within clippy's argument-count limit.
243pub struct RendererResources<'a> {
244    /// The style definition containing templates and options.
245    pub style: &'a citum_schema::Style,
246    /// The bibliography containing the reference data.
247    pub bibliography: &'a Bibliography,
248    /// The locale used for terms and formatting.
249    pub locale: &'a Locale,
250    /// The active configuration options.
251    pub config: Arc<Config>,
252    /// The active bibliography-only configuration.
253    pub bibliography_config: Option<Arc<BibliographyConfig>>,
254    /// First note number per reference id (note styles; `None` for bibliography rendering).
255    pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
256}
257
258impl<'a> Renderer<'a> {
259    /// Creates a new `Renderer` instance.
260    pub fn new(
261        resources: RendererResources<'a>,
262        hints: &'a HashMap<String, ProcHints>,
263        citation_numbers: &'a RwLock<HashMap<String, usize>>,
264        compound: CompoundRenderData<'a>,
265        show_semantics: bool,
266        inject_ast_indices: bool,
267        abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
268    ) -> Self {
269        Self {
270            style: resources.style,
271            bibliography: resources.bibliography,
272            locale: resources.locale,
273            config: resources.config,
274            bibliography_config: resources.bibliography_config,
275            hints,
276            citation_numbers,
277            compound_set_by_ref: compound.set_by_ref,
278            compound_member_index: compound.member_index,
279            compound_sets: compound.sets,
280            show_semantics,
281            inject_ast_indices,
282            filtered_to_original_index: RefCell::new(None),
283            abbreviation_map,
284            first_note_by_id: resources.first_note_by_id,
285        }
286    }
287
288    /// Select the rendering locale for one reference.
289    ///
290    /// A matched `citation.locales[]`/`bibliography.locales[]` branch is
291    /// authoritative and returns its embedded locale unchanged (structure
292    /// and rendering locale, including typography, both come from the
293    /// branch). Otherwise, under `options.multilingual.term-locale: item`,
294    /// returns a hybrid locale that speaks the item's terms/dates inside the
295    /// style's typography (see `docs/specs/PER_ITEM_TERM_LOCALE.md`); an
296    /// item language with no loaded locale falls back to the style locale
297    /// silently here — [`crate::api::warnings::term_locale_fallback_warnings`]
298    /// surfaces that case as a diagnostic. Otherwise returns the style
299    /// locale, today's default behavior byte for byte.
300    fn locale_for_reference(
301        &self,
302        reference: &Reference,
303        context: RenderContext,
304    ) -> Cow<'a, Locale> {
305        let language = crate::values::effective_item_language(reference);
306        let selected = match context {
307            RenderContext::Citation => self
308                .style
309                .citation
310                .as_ref()
311                .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
312            RenderContext::Bibliography => self
313                .style
314                .bibliography
315                .as_ref()
316                .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
317        };
318
319        if let Some(locale_id) = selected.and_then(|resolved| resolved.locale) {
320            return Cow::Borrowed(lookup_embedded_locale(&locale_id).unwrap_or(self.locale));
321        }
322
323        let term_locale_is_item = self
324            .config
325            .multilingual
326            .as_ref()
327            .is_some_and(|ml| ml.term_locale == citum_schema::options::TermLocale::Item);
328
329        if term_locale_is_item
330            && let Some(item_locale) = language.as_deref().and_then(lookup_embedded_locale)
331        {
332            return Cow::Owned(self.locale.with_term_surfaces_from(item_locale));
333        }
334
335        Cow::Borrowed(self.locale)
336    }
337
338    /// Resolve multilingual contributor names using the style's config.
339    fn resolve_contributor_names(
340        &self,
341        contributor: &citum_schema::reference::contributor::Contributor,
342    ) -> Vec<crate::reference::FlatName> {
343        let ml = self.config.multilingual.as_ref();
344        crate::values::resolve_multilingual_name(
345            contributor,
346            ml.and_then(|m| m.name_mode.as_ref()),
347            ml.and_then(|m| m.preferred_transliteration.as_deref()),
348            ml.and_then(|m| m.preferred_script.as_ref()),
349            &self.locale.locale,
350        )
351    }
352
353    /// Generate an alphabetic or numeric sub-label (e.g., "a", "1") for a
354    /// reference member of a compound set.
355    fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
356        let compound = self
357            .bibliography_config
358            .as_ref()
359            .and_then(|b| b.compound_numeric.as_ref())?;
360        let set_id = self.compound_set_by_ref.get(ref_id)?;
361        let members = self.compound_sets.get(set_id)?;
362        if members.len() <= 1 {
363            return None;
364        }
365        if !compound.subentry {
366            return None;
367        }
368        let idx = *self.compound_member_index.get(ref_id)?;
369        match compound.sub_label {
370            citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
371                crate::values::int_to_letter((idx + 1) as u32)
372            }
373            citum_schema::options::bibliography::SubLabelStyle::Numeric => {
374                Some((idx + 1).to_string())
375            }
376        }
377    }
378
379    /// Resolve the effective declarative citation label mode for one citation spec.
380    fn citation_label_mode(&self, spec: &citum_schema::CitationSpec) -> Option<CitationLabelMode> {
381        marker::citation_label_mode(&self.config, spec)
382    }
383
384    /// Apply a citation label wrapper after semantic numeric collapse.
385    fn wrap_citation_label_with_format<F>(
386        &self,
387        fmt: &F,
388        content: String,
389        wrap: Option<LabelWrap>,
390        ref_id: Option<&str>,
391    ) -> String
392    where
393        F: crate::render::format::OutputFormat<Output = String>,
394    {
395        let Some(wrap) = wrap else {
396            return content;
397        };
398        if wrap == LabelWrap::None {
399            return content;
400        }
401        if wrap == LabelWrap::Superscript {
402            return fmt.superscript(content);
403        }
404        let language = ref_id
405            .and_then(|id| self.bibliography.get(id))
406            .and_then(crate::values::effective_item_language);
407        let (script, realization) = crate::values::punctuation_realization_context(
408            language.as_deref(),
409            self.config.multilingual.as_ref(),
410            self.locale.punctuation_realization.as_ref(),
411        );
412        let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
413        let Some(config) = wrap.as_wrap_config() else {
414            return content;
415        };
416        fmt.wrap_punctuation(
417            &config.punctuation,
418            content,
419            &marks,
420            script,
421            realization.as_deref(),
422        )
423    }
424
425    /// Determines if the processor should render author-plus-number text for a numeric style
426    /// when in "integral" (narrative) citation mode.
427    ///
428    /// This happens when the style is numeric and the user requests a narrative
429    /// citation (e.g., "Smith [1]"), but hasn't provided an explicit narrative template.
430    fn should_render_author_number_for_numeric_integral(
431        &self,
432        mode: &citum_schema::citation::CitationMode,
433    ) -> bool {
434        matches!(mode, citum_schema::citation::CitationMode::Integral)
435            && self.config.processing.as_ref().is_some_and(|processing| {
436                matches!(processing, citum_schema::options::Processing::Numeric)
437            })
438            && !self.has_explicit_integral_template()
439    }
440
441    /// Whether the style provides an explicit integral (narrative) template.
442    fn has_explicit_integral_template(&self) -> bool {
443        self.style.citation.as_ref().is_some_and(|c| {
444            c.integral.as_ref().is_some_and(|i| {
445                i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
446            })
447        })
448    }
449
450    /// Determine if compound subentries should be collapsed for this citation.
451    fn should_collapse_compound_subentries(
452        &self,
453        mode: &citum_schema::citation::CitationMode,
454    ) -> bool {
455        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
456            return false;
457        }
458
459        self.bibliography_config
460            .as_ref()
461            .and_then(|b| b.compound_numeric.as_ref())
462            .is_some_and(|c| c.subentry && c.collapse_subentries)
463    }
464
465    /// Determine if citation numbers should be collapsed into ranges.
466    fn should_collapse_citation_numbers(
467        &self,
468        spec: &citum_schema::CitationSpec,
469        mode: &citum_schema::citation::CitationMode,
470    ) -> bool {
471        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
472            return false;
473        }
474
475        let is_numeric = self
476            .config
477            .processing
478            .as_ref()
479            .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
480
481        is_numeric
482            && matches!(
483                spec.collapse,
484                Some(citum_schema::CitationCollapse::CitationNumber)
485            )
486    }
487
488    /// Heuristic for ensuring proper spacing after a citation prefix.
489    fn normalize_prefix_spacing(prefix: &str) -> String {
490        if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
491            format!("{prefix} ")
492        } else {
493            prefix.to_string()
494        }
495    }
496
497    /// Ensure suffix has proper spacing (add space if suffix doesn't start with
498    /// punctuation and isn't empty).
499    fn ensure_suffix_spacing(suffix: &str) -> String {
500        if suffix.is_empty() {
501            String::new()
502        } else if suffix.starts_with(char::is_whitespace)
503            || suffix.starts_with(',')
504            || suffix.starts_with(';')
505            || suffix.starts_with('.')
506        {
507            // Already has leading space or punctuation
508            suffix.to_string()
509        } else {
510            // Add space before suffix to separate from content
511            format!(" {suffix}")
512        }
513    }
514
515    /// Whether `options.multilingual.scripts.latin.punctuation: latin` applies to the
516    /// reference behind `ref_id`.
517    ///
518    /// Citation-cluster-level `prefix`/`suffix`/`delimiter` (e.g. GB/T author-date's
519    /// full-width `( )` wrap) are applied in [`Self::affix_content`], outside each
520    /// component's own rendering — component-internal punctuation is already remapped
521    /// by `render::component::wants_latin_punctuation`. This mirrors that check using
522    /// the citation item's resolved reference.
523    fn wants_latin_punctuation_for_id(&self, ref_id: &str) -> bool {
524        let configured = self.config.multilingual.as_ref().is_some_and(|ml| {
525            ml.scripts.get("latin").is_some_and(|script| {
526                script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
527            })
528        });
529
530        configured
531            && self.bibliography.get(ref_id).is_some_and(|reference| {
532                crate::values::is_latin_script_language(
533                    crate::values::effective_item_language(reference).as_deref(),
534                )
535            })
536    }
537
538    /// Apply prefix and suffix spacing heuristics to a rendered string.
539    ///
540    /// `ref_id` identifies the reference this content belongs to, so a
541    /// script-aware punctuation remap (see [`Self::wants_latin_punctuation_for_id`])
542    /// can be applied to affixes assembled outside component rendering. Pass
543    /// `None` when no single reference applies (e.g. author-only content).
544    fn affix_content<F>(
545        &self,
546        fmt: &F,
547        content: String,
548        prefix: Option<&str>,
549        suffix: Option<&str>,
550        ref_id: Option<&str>,
551    ) -> String
552    where
553        F: crate::render::format::OutputFormat<Output = String>,
554    {
555        let prefix = prefix.unwrap_or("");
556        let suffix = suffix.unwrap_or("");
557        let affixed = if prefix.is_empty() && suffix.is_empty() {
558            content
559        } else {
560            fmt.affix(
561                &Self::normalize_prefix_spacing(prefix),
562                content,
563                &Self::ensure_suffix_spacing(suffix),
564            )
565        };
566
567        if ref_id.is_some_and(|id| self.wants_latin_punctuation_for_id(id)) {
568            crate::render::component::remap_to_latin_punctuation(affixed)
569        } else {
570            affixed
571        }
572    }
573
574    /// Pair rendered content with associated reference IDs to form a semantic chunk.
575    /// Present a bibliography marker: its wrap, any wrap-implied suffix, then
576    /// the `label-separator` that joins it to the entry body.
577    fn present_bibliography_marker_with_format<F>(
578        &self,
579        fmt: &F,
580        spec: &marker::BibliographyMarkerSpec,
581        value: &marker::MarkerValue,
582        ref_id: Option<&str>,
583    ) -> String
584    where
585        F: crate::render::format::OutputFormat<Output = String>,
586    {
587        let text = fmt.text(&value.as_localized_text(&self.locale.number_formats.digit_system));
588        let wrapped = match spec.wrap {
589            Some(wrap) => self.wrap_bibliography_label_with_format(fmt, text, wrap, ref_id),
590            None => text,
591        };
592        let suffix = spec
593            .wrap
594            .and_then(citum_schema::options::BibliographyLabelWrap::as_suffix)
595            .unwrap_or_default();
596        format!("{wrapped}{suffix}{}", spec.separator)
597    }
598
599    /// Apply a bibliography label wrap, reusing the citation wrap machinery so
600    /// punctuation realization and quote marks resolve identically.
601    fn wrap_bibliography_label_with_format<F>(
602        &self,
603        fmt: &F,
604        content: String,
605        wrap: citum_schema::options::BibliographyLabelWrap,
606        ref_id: Option<&str>,
607    ) -> String
608    where
609        F: crate::render::format::OutputFormat<Output = String>,
610    {
611        let Some(config) = wrap.as_wrap_config() else {
612            return content;
613        };
614        let language = ref_id
615            .and_then(|id| self.bibliography.get(id))
616            .and_then(crate::values::effective_item_language);
617        let (script, realization) = crate::values::punctuation_realization_context(
618            language.as_deref(),
619            self.config.multilingual.as_ref(),
620            self.locale.punctuation_realization.as_ref(),
621        );
622        let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
623        fmt.wrap_punctuation(
624            &config.punctuation,
625            content,
626            &marks,
627            script,
628            realization.as_deref(),
629        )
630    }
631
632    /// Render a resolved marker to text, with its own `label-wrap` applied.
633    fn present_marker_with_format<F>(
634        &self,
635        fmt: &F,
636        resolved: &ResolvedMarker,
637        ref_id: Option<&str>,
638    ) -> String
639    where
640        F: crate::render::format::OutputFormat<Output = String>,
641    {
642        let text = fmt.text(
643            &resolved
644                .value
645                .as_localized_text(&self.locale.number_formats.digit_system),
646        );
647        self.wrap_citation_label_with_format(fmt, text, resolved.spec.label_wrap, ref_id)
648    }
649
650    /// Join a marker to the item body it belongs to, honouring placement.
651    fn compose_marker_with_body(
652        marker: String,
653        body: String,
654        placement: marker::MarkerPlacement,
655        delimiter: &str,
656    ) -> String {
657        if body.is_empty() {
658            return marker;
659        }
660        match placement {
661            marker::MarkerPlacement::Leading => format!("{marker}{delimiter}{body}"),
662            marker::MarkerPlacement::Trailing => format!("{body}{delimiter}{marker}"),
663        }
664    }
665
666    /// Pair rendered content with associated reference IDs to form a semantic chunk.
667    ///
668    /// A chunk whose body is empty keeps its marker unrendered so numeric
669    /// collapse can still merge it; every other chunk is composed here, because
670    /// the marker and any `item-wrap` sit *inside* the cite's own prefix and
671    /// suffix ("see also [2]", not "[see also 2]").
672    #[allow(
673        clippy::too_many_arguments,
674        reason = "chunk assembly keeps rendering affixes and marker metadata together"
675    )]
676    fn build_citation_chunk<F>(
677        &self,
678        fmt: &F,
679        ids: Vec<String>,
680        body: String,
681        prefix: Option<&str>,
682        suffix: Option<&str>,
683        resolved: Option<ResolvedMarker>,
684        delimiter: &str,
685    ) -> Option<CitationChunk>
686    where
687        F: crate::render::format::OutputFormat<Output = String>,
688    {
689        let ref_id = ids.first().cloned();
690        let ref_id = ref_id.as_deref();
691        let collapsible = body.is_empty()
692            && prefix.is_none()
693            && suffix.is_none()
694            && resolved
695                .as_ref()
696                .is_some_and(|resolved| resolved.value.is_numeric());
697
698        if collapsible {
699            return Some(CitationChunk {
700                ids,
701                content: String::new(),
702                marker: resolved,
703            });
704        }
705
706        let content = match &resolved {
707            Some(resolved) => {
708                let marker_text = self.present_marker_with_format(fmt, resolved, ref_id);
709                let composed = Self::compose_marker_with_body(
710                    marker_text,
711                    body,
712                    resolved.spec.placement,
713                    delimiter,
714                );
715                self.wrap_citation_label_with_format(fmt, composed, resolved.spec.item_wrap, ref_id)
716            }
717            None => body,
718        };
719        if content.is_empty() {
720            return None;
721        }
722        Some(CitationChunk {
723            ids,
724            content: self.affix_content(fmt, content, prefix, suffix, ref_id),
725            marker: None,
726        })
727    }
728
729    /// Build a citation chunk for a single item from its rendered body.
730    fn build_item_chunk<F>(
731        &self,
732        fmt: &F,
733        item: &crate::reference::CitationItem,
734        reference: &Reference,
735        body: String,
736        spec: Option<marker::CitationMarkerSpec>,
737        delimiter: &str,
738    ) -> Option<CitationChunk>
739    where
740        F: crate::render::format::OutputFormat<Output = String>,
741    {
742        let resolved = spec.and_then(|spec| {
743            marker::marker_value(
744                spec.kind,
745                &self.config,
746                reference,
747                Some(self.get_or_assign_citation_number(&item.id)),
748                self.citation_sub_label_for_ref(&item.id),
749                self.hints.get(&item.id),
750            )
751            .map(|value| ResolvedMarker { value, spec })
752        });
753        self.build_citation_chunk(
754            fmt,
755            vec![item.id.clone()],
756            body,
757            item.prefix.as_deref(),
758            item.suffix.as_deref(),
759            resolved,
760            delimiter,
761        )
762    }
763
764    /// Create a template render request for a single citation item.
765    fn citation_render_request<'b>(
766        &self,
767        item: &'b crate::reference::CitationItem,
768        template: &'b [TemplateComponent],
769        mode: &citum_schema::citation::CitationMode,
770        suppress_author: bool,
771        position: Option<&citum_schema::citation::Position>,
772        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
773    ) -> TemplateRenderRequest<'b> {
774        TemplateRenderRequest {
775            template,
776            context: RenderContext::Citation,
777            mode: mode.clone(),
778            suppress_author,
779            locator_raw: item.locator.as_ref(),
780            citation_number: self.get_or_assign_citation_number(&item.id),
781            position: position.cloned(),
782            note_start_text_case,
783            integral_name_state: item.integral_name_state,
784            org_abbreviation_state: item.org_abbreviation_state,
785            first_reference_note_number: self.first_note_by_id.as_ref().and_then(|m| {
786                m.read()
787                    .unwrap_or_else(std::sync::PoisonError::into_inner)
788                    .get(&item.id)
789                    .copied()
790            }),
791        }
792    }
793
794    /// Render a single item to a formatted string using a template.
795    fn render_item_from_template_with_format<F>(
796        &self,
797        reference: &Reference,
798        request: TemplateRenderRequest<'_>,
799        delimiter: &str,
800    ) -> Option<String>
801    where
802        F: crate::render::format::OutputFormat<Output = String>,
803    {
804        self.process_template_request_with_format::<F>(reference, request)
805            .map(|proc| {
806                crate::render::citation::citation_to_string_with_format::<F>(
807                    &proc,
808                    None,
809                    None,
810                    None,
811                    Some(delimiter),
812                )
813            })
814    }
815
816    /// Resolve the reference, template, and delimiter needed to render one
817    /// ungrouped citation item, applying type-variant and language fallbacks.
818    fn resolve_ungrouped_item_render_state<'b>(
819        &'b self,
820        item: &'b crate::reference::CitationItem,
821        spec: &'b citum_schema::CitationSpec,
822        mode: &'b citum_schema::citation::CitationMode,
823        intra_delimiter: &'b str,
824    ) -> Result<UngroupedItemRenderState<'b>, ProcessorError> {
825        let reference = self
826            .bibliography
827            .get(&item.id)
828            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
829        let ref_type = reference.ref_type();
830        let item_language = crate::values::effective_item_language(reference);
831        let localized = spec.resolve_localized_template(item_language.as_deref());
832        let template = localized
833            .as_ref()
834            .filter(|resolved| resolved.type_variants.is_some())
835            .cloned()
836            .map(|resolved| {
837                Cow::Owned(resolve_localized_type_variant(
838                    resolved,
839                    spec.type_variants.as_ref(),
840                    &ref_type,
841                ))
842            })
843            .or_else(|| {
844                resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
845            })
846            .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)))
847            .unwrap_or(Cow::Borrowed(&[] as &[TemplateComponent]));
848
849        Ok(UngroupedItemRenderState {
850            reference,
851            template,
852            delimiter: intra_delimiter,
853            marker: marker::resolve_citation_marker(&self.config, spec, mode),
854        })
855    }
856
857    /// Initialize render options for a citation.
858    ///
859    /// `locale` is resolved by the caller via [`Self::locale_for_reference`]
860    /// so the `Cow` it may own outlives this borrow of `RenderOptions`.
861    fn citation_render_options<'b>(
862        &'b self,
863        locale: &'b Locale,
864        mode: citum_schema::citation::CitationMode,
865        suppress_author: bool,
866        locator_raw: Option<&'b CitationLocator>,
867        ref_type: Option<String>,
868    ) -> RenderOptions<'b> {
869        RenderOptions {
870            config: self.config.clone(),
871            bibliography_config: self.bibliography_config.clone(),
872            locale,
873            context: RenderContext::Citation,
874            mode,
875            suppress_author,
876            locator_raw,
877            ref_type,
878            show_semantics: self.show_semantics,
879            current_template_index: None,
880            abbreviation_map: self.abbreviation_map,
881        }
882    }
883
884    /// Render author + citation number for numeric integral citations.
885    ///
886    /// Default implementation for narrative citations in numeric styles (e.g., "Smith [1]").
887    fn render_author_number_for_numeric_integral_with_format<F>(
888        &self,
889        fmt: &F,
890        reference: &Reference,
891        item: &crate::reference::CitationItem,
892        citation_number: usize,
893        label_wrap: Option<LabelWrap>,
894    ) -> String
895    where
896        F: crate::render::format::OutputFormat<Output = String>,
897    {
898        let locale = self.locale_for_reference(reference, RenderContext::Citation);
899        let options = self.citation_render_options(
900            locale.as_ref(),
901            citum_schema::citation::CitationMode::Integral,
902            false,
903            item.locator.as_ref(),
904            Some(reference.ref_type()),
905        );
906
907        // Render author in short form
908        let author_part = if let Some(authors) = reference.author() {
909            let names_vec = self.resolve_contributor_names(&authors);
910            fmt.text(&crate::values::format_contributors_short(
911                &names_vec, &options,
912            ))
913        } else {
914            String::new()
915        };
916
917        // Include compound sub-label (e.g. "a", "b") when applicable.
918        let ref_id = reference.id().unwrap_or_default().to_string();
919        let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
920
921        let raw_label = format!("{citation_number}{sub_label}");
922        let label = match label_wrap {
923            Some(wrap) => self.wrap_citation_label_with_format::<F>(
924                fmt,
925                raw_label,
926                Some(wrap),
927                Some(&item.id),
928            ),
929            None => format!("[{raw_label}]"),
930        };
931
932        // Format: "Author [Na]" by default, with an explicit label-wrap override.
933        if author_part.is_empty() {
934            // Fallback: just citation number if no author.
935            label
936        } else {
937            format!("{author_part} {label}")
938        }
939    }
940
941    /// Render one item as author + citation number for numeric integral cites.
942    fn render_numeric_integral_item_chunk_with_format<F>(
943        &self,
944        fmt: &F,
945        item: &crate::reference::CitationItem,
946        label_wrap: Option<LabelWrap>,
947    ) -> Result<Option<CitationChunk>, ProcessorError>
948    where
949        F: crate::render::format::OutputFormat<Output = String>,
950    {
951        let reference = self
952            .bibliography
953            .get(&item.id)
954            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
955        let citation_number = self.get_or_assign_citation_number(&item.id);
956        let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
957            fmt,
958            reference,
959            item,
960            citation_number,
961            label_wrap,
962        );
963        Ok(self.build_item_chunk(fmt, item, reference, item_str, None, ""))
964    }
965
966    /// Render one ungrouped item from its resolved template state.
967    fn render_template_item_chunk_with_format<F>(
968        &self,
969        fmt: &F,
970        item: &crate::reference::CitationItem,
971        state: UngroupedItemRenderState<'_>,
972        params: UngroupedItemRenderParams<'_>,
973    ) -> Option<CitationChunk>
974    where
975        F: crate::render::format::OutputFormat<Output = String>,
976    {
977        let request = self.citation_render_request(
978            item,
979            &state.template,
980            params.mode,
981            params.suppress_author,
982            params.position,
983            params.note_start_text_case,
984        );
985        // A marker-only style has an empty body template, so the body render
986        // yields nothing; the marker still has to produce a chunk.
987        let body = self
988            .render_item_from_template_with_format::<F>(state.reference, request, state.delimiter)
989            .unwrap_or_default();
990        if body.is_empty() && state.marker.is_none() {
991            return None;
992        }
993        self.build_item_chunk(
994            fmt,
995            item,
996            state.reference,
997            body,
998            state.marker,
999            state.delimiter,
1000        )
1001    }
1002
1003    /// Render citation items without grouping, using plain text format.
1004    ///
1005    /// # Errors
1006    ///
1007    /// Returns an error when a referenced item is missing or item rendering
1008    /// fails.
1009    pub fn render_ungrouped_citation(
1010        &self,
1011        items: &[crate::reference::CitationItem],
1012        spec: &citum_schema::CitationSpec,
1013        mode: &citum_schema::citation::CitationMode,
1014        intra_delimiter: &str,
1015        suppress_author: bool,
1016        position: Option<&citum_schema::citation::Position>,
1017    ) -> Result<Vec<String>, ProcessorError> {
1018        self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
1019            items,
1020            spec,
1021            mode,
1022            intra_delimiter,
1023            suppress_author,
1024            position,
1025            spec.note_start_text_case,
1026        )
1027    }
1028
1029    /// Render citation items without grouping, generic over the output format.
1030    ///
1031    /// This is the core logic for iterating over citation items, looking up references,
1032    /// and applying the appropriate template or fallback logic.
1033    ///
1034    /// # Errors
1035    ///
1036    /// Returns an error when a referenced item is missing or item rendering
1037    /// fails.
1038    #[allow(
1039        clippy::too_many_arguments,
1040        reason = "Ungrouped citation rendering now needs explicit note-start context."
1041    )]
1042    pub fn render_ungrouped_citation_with_format<F>(
1043        &self,
1044        items: &[crate::reference::CitationItem],
1045        spec: &citum_schema::CitationSpec,
1046        mode: &citum_schema::citation::CitationMode,
1047        intra_delimiter: &str,
1048        suppress_author: bool,
1049        position: Option<&citum_schema::citation::Position>,
1050        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
1051    ) -> Result<Vec<String>, ProcessorError>
1052    where
1053        F: crate::render::format::OutputFormat<Output = String>,
1054    {
1055        let fmt = F::default();
1056        let mut chunks: Vec<CitationChunk> = Vec::new();
1057
1058        // For numeric styles with integral mode, render author + citation number instead.
1059        let use_author_number = self.should_render_author_number_for_numeric_integral(mode)
1060            && self.citation_label_mode(spec) != Some(CitationLabelMode::None);
1061        let params = UngroupedItemRenderParams {
1062            mode,
1063            suppress_author,
1064            position,
1065            note_start_text_case,
1066        };
1067
1068        for item in items {
1069            let chunk = if use_author_number {
1070                self.render_numeric_integral_item_chunk_with_format::<F>(
1071                    &fmt,
1072                    item,
1073                    spec.options.as_ref().and_then(|options| options.label_wrap),
1074                )?
1075            } else {
1076                let state =
1077                    self.resolve_ungrouped_item_render_state(item, spec, mode, intra_delimiter)?;
1078                self.render_template_item_chunk_with_format::<F>(&fmt, item, state, params)
1079            };
1080
1081            if let Some(chunk) = chunk {
1082                chunks.push(chunk);
1083            }
1084        }
1085
1086        if self.should_collapse_compound_subentries(mode) {
1087            chunks = self.collapse_compound_citation_chunks(chunks);
1088        }
1089        if self.should_collapse_citation_numbers(spec, mode) {
1090            chunks = self.collapse_numeric_citation_chunks(chunks);
1091        }
1092
1093        Ok(chunks
1094            .into_iter()
1095            .map(|chunk| {
1096                let ref_id = chunk.ids.first().map(String::as_str);
1097                // A chunk that still carries a marker was held back for
1098                // collapse; its presentation is realized here, once, with the
1099                // label wrap enclosing the marker and the item wrap enclosing
1100                // the whole item.
1101                let content = match &chunk.marker {
1102                    Some(resolved) => {
1103                        let marker_text = self.present_marker_with_format(&fmt, resolved, ref_id);
1104                        self.wrap_citation_label_with_format(
1105                            &fmt,
1106                            marker_text,
1107                            resolved.spec.item_wrap,
1108                            ref_id,
1109                        )
1110                    }
1111                    None => chunk.content,
1112                };
1113                fmt.citation(chunk.ids, content)
1114            })
1115            .collect())
1116    }
1117}
1118
1119fn key_base(key: &str) -> Cow<'_, str> {
1120    let mut parts = key.splitn(3, ':');
1121    match (parts.next(), parts.next()) {
1122        (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
1123        _ => Cow::Borrowed(key),
1124    }
1125}
1126
1127/// Get a unique key for a template component's variable, for
1128/// [`TemplateComponentTracker`] dedup/substitution tracking.
1129///
1130/// Contributor/Variable/Number/Identifier key by variable + rendering context
1131/// (prefix/suffix); Title also keys by form. `Date` components are exempt —
1132/// see the `Date` arm below.
1133#[must_use]
1134pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
1135    use citum_schema::template::Rendering;
1136    use std::fmt::Write;
1137
1138    fn push_context_suffix(key: &mut String, rendering: &Rendering) {
1139        match (&rendering.prefix, &rendering.suffix) {
1140            (Some(prefix), Some(suffix)) => {
1141                key.push(':');
1142                key.push_str(prefix);
1143                key.push('_');
1144                key.push_str(suffix);
1145            }
1146            (Some(prefix), None) => {
1147                key.push(':');
1148                key.push_str(prefix);
1149            }
1150            (None, Some(suffix)) => {
1151                key.push(':');
1152                key.push_str(suffix);
1153            }
1154            (None, None) => {}
1155        }
1156    }
1157
1158    fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
1159        let mut key = String::new();
1160        write!(&mut key, "{kind}:{value:?}").ok()?;
1161        push_context_suffix(&mut key, rendering);
1162        Some(key)
1163    }
1164
1165    match component {
1166        TemplateComponent::Contributor(c) => c.contributor.as_single().map_or_else(
1167            || make_key("contributor", &c.contributor, &c.rendering),
1168            |role| make_key("contributor", role, &c.rendering),
1169        ),
1170        // Dates are never auto-suppressed for reappearing in a template — CSL
1171        // restricts variable-consumption tracking to cs:substitute (names
1172        // only). A style that writes `date: issued` twice (e.g. a short
1173        // citation year up front, a full precise date later) means for both
1174        // to render regardless of matching form or rendering context.
1175        TemplateComponent::Date(_) => None,
1176        TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
1177        TemplateComponent::Title(t) => {
1178            let mut key = format!("title:{:?}", t.title);
1179            if let Some(form) = &t.form {
1180                write!(&mut key, ":{form:?}").ok()?;
1181            }
1182            push_context_suffix(&mut key, &t.rendering);
1183            Some(key)
1184        }
1185        TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
1186        TemplateComponent::Identifier(i) => make_key("identifier", &i.identifier, &i.rendering),
1187        TemplateComponent::Group(_) => None,
1188        _ => None,
1189    }
1190}