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