Skip to main content

citum_engine/processor/bibliography/
grouping.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Grouped bibliography rendering with configurable selectors and sorting.
7
8use super::RenderedBibliographyGroup;
9use crate::api::AnnotationStyle;
10use crate::grouping::SelectorEvaluator;
11use crate::processor::FinalizedRun;
12use crate::processor::Processor;
13use crate::processor::disambiguation::Disambiguator;
14use crate::reference::{Bibliography, Reference};
15use crate::render::ProcEntry;
16use crate::render::format::{OutputFormat, ProcEntryMetadata};
17use crate::sorting::ReferenceSorter;
18use crate::values::{
19    ProcHints, RenderContext, RenderOptions, format_contributors_short, resolve_multilingual_name,
20    resolve_multilingual_string,
21};
22use citum_schema::grouping::{BibliographyGroup, DisambiguationScope, GroupHeading};
23use citum_schema::options::{BibliographyPartitionHeading, BibliographySortPartitioning};
24use std::borrow::Cow;
25use std::collections::{HashMap, HashSet};
26use std::sync::Arc;
27
28use super::EntryRenderContext;
29
30/// Products requested from the shared flat bibliography render pass.
31#[derive(Clone, Copy, PartialEq, Eq)]
32enum FlatBibliographyOutput {
33    /// Produce only the formatted bibliography string.
34    ContentOnly,
35    /// Produce the formatted string and flat per-reference entry data.
36    ContentAndEntries,
37}
38
39impl Processor {
40    /// Resolve a localized or literal group heading.
41    pub(super) fn resolve_group_heading(&self, heading: &GroupHeading) -> Option<String> {
42        match heading {
43            GroupHeading::Literal { literal } => Some(literal.clone()),
44            GroupHeading::Term { term, form } => self.locale.resolved_general_term(
45                term,
46                &form.clone().unwrap_or(citum_schema::locale::TermForm::Long),
47                None,
48            ),
49            GroupHeading::Localized { localized } => self.resolve_localized_heading(localized),
50        }
51    }
52
53    /// Resolve a localized heading map based on the processor locale.
54    ///
55    /// Matches in order:
56    /// 1. Exact locale (e.g., "en-GB")
57    /// 2. Primary language (e.g., "en")
58    /// 3. Style default locale
59    /// 4. en-US fallback
60    /// 5. First alphabetically defined key
61    fn resolve_localized_heading(&self, localized: &HashMap<String, String>) -> Option<String> {
62        fn language_tag(locale: &str) -> &str {
63            locale.split('-').next().unwrap_or(locale)
64        }
65
66        let mut candidates = Vec::new();
67        let mut push_candidate = |locale: &str| {
68            let candidate = locale.to_string();
69            if !candidates.contains(&candidate) {
70                candidates.push(candidate);
71            }
72        };
73
74        push_candidate(&self.locale.locale);
75        push_candidate(language_tag(&self.locale.locale));
76
77        if let Some(default_locale) = self.style.info.default_locale.as_deref() {
78            push_candidate(default_locale);
79            push_candidate(language_tag(default_locale));
80        }
81
82        push_candidate("en-US");
83        push_candidate("en");
84
85        for locale in candidates {
86            if let Some(value) = localized.get(&locale) {
87                return Some(value.clone());
88            }
89        }
90
91        localized
92            .iter()
93            .min_by(|left, right| left.0.cmp(right.0))
94            .map(|(_locale, value)| value.clone())
95    }
96
97    /// Resolve a bibliography partition heading.
98    fn resolve_partition_heading(&self, heading: &BibliographyPartitionHeading) -> Option<String> {
99        match heading {
100            BibliographyPartitionHeading::Literal { literal } => Some(literal.clone()),
101            BibliographyPartitionHeading::Term { term, form } => self.locale.resolved_general_term(
102                term,
103                &form.clone().unwrap_or(citum_schema::locale::TermForm::Long),
104                None,
105            ),
106            BibliographyPartitionHeading::Localized { localized } => {
107                self.resolve_localized_heading(localized)
108            }
109        }
110    }
111
112    /// Find unassigned bibliography entries that match a group's selector.
113    fn collect_matching_group_refs<'a>(
114        &'a self,
115        bibliography: &'a [ProcEntry],
116        assigned: &HashSet<String>,
117        evaluator: &SelectorEvaluator<'_>,
118        group: &BibliographyGroup,
119    ) -> Vec<&'a Reference> {
120        bibliography
121            .iter()
122            .filter(|entry| !assigned.contains(&entry.id))
123            .filter_map(|entry| {
124                self.bibliography
125                    .get(&entry.id)
126                    .filter(|reference| evaluator.matches(reference, &group.selector))
127            })
128            .collect()
129    }
130
131    /// Returns `ProcEntry` stubs with only `id` populated, in sort order.
132    ///
133    /// Used for grouping paths that only need IDs for selector matching — avoids
134    /// the full PlainText render pass that `process_references` performs.
135    /// `pub(crate)` so callers that render multiple bibliography blocks in one
136    /// pass (or exercise that path in tests) can compute this once and share
137    /// it, instead of each block independently re-sorting the bibliography.
138    pub(crate) fn sorted_id_stubs(&self) -> Vec<ProcEntry> {
139        // Numeric citation numbers are already populated by `Processor::begin_run`,
140        // which every `FinalizedRun` this module consumes was produced from.
141        self.sort_references(self.bibliography.values().collect())
142            .into_iter()
143            .filter_map(|r| {
144                r.id().map(|id| ProcEntry {
145                    id: id.to_string(),
146                    template: vec![],
147                    metadata: ProcEntryMetadata::default(),
148                })
149            })
150            .collect()
151    }
152
153    /// Mark references as assigned to a bibliography group.
154    fn mark_group_members_assigned(assigned: &mut HashSet<String>, references: &[&Reference]) {
155        for reference in references {
156            if let Some(id) = reference.id() {
157                assigned.insert(id.to_string());
158            }
159        }
160    }
161
162    /// Calculate disambiguation hints locally within a bibliography group.
163    ///
164    /// Only calculates hints if the group specifies local disambiguation scope.
165    fn build_group_local_hints(
166        &self,
167        sorted_refs: &[&Reference],
168        group: &BibliographyGroup,
169    ) -> Option<HashMap<String, ProcHints>> {
170        if !matches!(group.disambiguate, Some(DisambiguationScope::Locally)) {
171            return None;
172        }
173
174        let mut group_bibliography = Bibliography::new();
175        for reference in sorted_refs {
176            group_bibliography.insert(
177                reference.id().unwrap_or_default().to_string(),
178                (*reference).clone(),
179            );
180        }
181
182        let resolved_sort = group
183            .sort
184            .as_ref()
185            .map(citum_schema::GroupSortEntry::resolve);
186        let bibliography_config = self.get_bibliography_config();
187        let disambiguator = if let Some(sort) = resolved_sort.as_ref() {
188            Disambiguator::with_group_sort(
189                &group_bibliography,
190                &bibliography_config,
191                &bibliography_config,
192                &self.locale,
193                sort,
194            )
195        } else {
196            Disambiguator::new(
197                &group_bibliography,
198                &bibliography_config,
199                &bibliography_config,
200                &self.locale,
201            )
202        };
203
204        Some(disambiguator.calculate_hints())
205    }
206
207    /// Resolve the effective style to use for a bibliography group.
208    fn effective_group_style<'a>(
209        &'a self,
210        group: &'a BibliographyGroup,
211    ) -> Cow<'a, citum_schema::Style> {
212        if let Some(group_template) = &group.template {
213            let mut local_style = self.style.clone();
214            if let Some(bibliography) = local_style.bibliography.as_mut() {
215                bibliography.template = Some(group_template.clone());
216            }
217            Cow::Owned(local_style)
218        } else {
219            Cow::Borrowed(&self.style)
220        }
221    }
222
223    /// Render bibliography entries for a specific group.
224    ///
225    /// Entry numbers are resolved sequentially first, then entries render
226    /// via [`render_numbered_refs`](Self::render_numbered_refs) —
227    /// sequentially through one shared `Renderer`, or across the rayon
228    /// thread pool (one fresh `Renderer` per task; see
229    /// [`EntryRenderContext`]) once the group is large enough — and finally
230    /// [`apply_substitution_post_pass`](super::Processor::apply_substitution_post_pass)
231    /// applies subsequent-author substitution sequentially over the
232    /// (order-preserved) results.
233    fn render_group_entries<F>(
234        &self,
235        _bibliography: &[ProcEntry],
236        sorted_refs: Vec<&Reference>,
237        group: &BibliographyGroup,
238        local_hints: Option<&HashMap<String, ProcHints>>,
239        run: &FinalizedRun,
240    ) -> Vec<ProcEntry>
241    where
242        F: OutputFormat<Output = String>,
243    {
244        // Always process entries with format F so that group components (pre_formatted=true)
245        // contain markup in the target format rather than PlainText (_..._).
246        let effective_style = self.effective_group_style(group);
247        let ctx = EntryRenderContext {
248            style: &effective_style,
249            hints: local_hints.unwrap_or(&self.hints),
250            config: Arc::new(self.get_bibliography_config().into_owned()),
251            bibliography_config: Arc::new(self.get_bibliography_options().into_owned()),
252            run,
253        };
254
255        let numbered_refs = super::number_sorted_refs(sorted_refs.into_iter(), run);
256        let rendered = self.render_numbered_refs::<F>(&numbered_refs, &ctx);
257
258        let substitute = ctx
259            .bibliography_config
260            .subsequent_author_substitute
261            .as_ref();
262        self.apply_substitution_post_pass::<F>(rendered, substitute, &ctx)
263    }
264
265    /// Append a rendered bibliography group to the output string.
266    fn append_rendered_group<F>(
267        &self,
268        result: &mut String,
269        group: &BibliographyGroup,
270        entries: Vec<ProcEntry>,
271        annotations: Option<&HashMap<String, String>>,
272        annotation_style: Option<&AnnotationStyle>,
273        suppress_heading: bool,
274    ) where
275        F: OutputFormat<Output = String>,
276    {
277        if !result.is_empty() {
278            result.push_str("\n\n");
279        }
280
281        if !suppress_heading
282            && let Some(heading) = group
283                .heading
284                .as_ref()
285                .and_then(|group_heading| self.resolve_group_heading(group_heading))
286        {
287            result.push_str(&self.render_group_heading::<F>(&heading));
288        }
289
290        result.push_str(&crate::render::refs_to_string_with_format::<F>(
291            entries,
292            annotations,
293            annotation_style,
294        ));
295    }
296
297    /// Append a rendered bibliography partition to the output string.
298    fn append_rendered_partition<F>(
299        &self,
300        result: &mut String,
301        heading: Option<&BibliographyPartitionHeading>,
302        entries: Vec<ProcEntry>,
303        annotations: Option<&HashMap<String, String>>,
304        annotation_style: Option<&AnnotationStyle>,
305    ) where
306        F: OutputFormat<Output = String>,
307    {
308        if !result.is_empty() {
309            result.push_str("\n\n");
310        }
311
312        if let Some(heading) =
313            heading.and_then(|group_heading| self.resolve_partition_heading(group_heading))
314        {
315            result.push_str(&self.render_group_heading::<F>(&heading));
316        }
317
318        result.push_str(&crate::render::refs_to_string_with_format::<F>(
319            entries,
320            annotations,
321            annotation_style,
322        ));
323    }
324
325    /// Orchestrate the rendering of automatic bibliography partitions with headings.
326    pub(super) fn render_with_partition_sections<F>(
327        &self,
328        sorted_refs: Vec<&Reference>,
329        partitioning: &BibliographySortPartitioning,
330        annotations: Option<&HashMap<String, String>>,
331        annotation_style: Option<&AnnotationStyle>,
332        run: &FinalizedRun,
333    ) -> String
334    where
335        F: OutputFormat<Output = String>,
336    {
337        let fmt = F::default();
338        let mut result = String::new();
339
340        for (partition_key, references) in
341            crate::sort_partitioning::partition_references(sorted_refs, &self.locale, partitioning)
342        {
343            let heading = partition_key
344                .as_ref()
345                .and_then(|key| partitioning.headings.get(key));
346            let entries = self.merge_compound_entries::<F>(
347                self.process_sorted_refs::<_, F>(references.into_iter(), run),
348                run,
349            );
350            self.append_rendered_partition::<F>(
351                &mut result,
352                heading,
353                entries,
354                annotations,
355                annotation_style,
356            );
357        }
358
359        fmt.finish(result)
360    }
361
362    /// Render a filtered subset of entries using custom bibliography grouping.
363    ///
364    /// This uses a two-pass grouping strategy:
365    /// 1. Collect and render all populated groups.
366    /// 2. Determine if heading suppression applies (only one group populated).
367    /// 3. Append groups and any remaining unassigned entries.
368    pub(super) fn render_with_custom_groups_filtered<F>(
369        &self,
370        all_entries: &[ProcEntry],
371        groups: &[BibliographyGroup],
372        selected: &HashSet<String>,
373        annotations: Option<&HashMap<String, String>>,
374        annotation_style: Option<&AnnotationStyle>,
375        run: &FinalizedRun,
376    ) -> String
377    where
378        F: OutputFormat<Output = String>,
379    {
380        let fmt = F::default();
381        let cited_ids = &run.state().cited_ids;
382        let evaluator = SelectorEvaluator::new(cited_ids);
383        let bibliography_config = self.get_bibliography_config();
384        let sorter = ReferenceSorter::with_bibliography_config(&self.locale, &bibliography_config);
385
386        let mut assigned = HashSet::new();
387        let mut result = String::new();
388
389        // First pass: collect all populated groups with their rendered entries
390        let mut populated_groups: Vec<(&BibliographyGroup, Vec<ProcEntry>)> = Vec::new();
391
392        for group in groups {
393            let matching_refs =
394                self.collect_matching_group_refs(all_entries, &assigned, &evaluator, group);
395
396            let matching_refs: Vec<&Reference> = matching_refs
397                .into_iter()
398                .filter(|r| r.id().as_deref().is_some_and(|id| selected.contains(id)))
399                .collect();
400
401            if matching_refs.is_empty() {
402                continue;
403            }
404
405            Self::mark_group_members_assigned(&mut assigned, &matching_refs);
406
407            let sorted_refs = if let Some(sort_spec) = &group.sort {
408                sorter.sort_references(matching_refs, &sort_spec.resolve())
409            } else {
410                matching_refs
411            };
412            let local_hints = self.build_group_local_hints(&sorted_refs, group);
413            let entries = self.merge_compound_entries::<F>(
414                self.render_group_entries::<F>(
415                    all_entries,
416                    sorted_refs,
417                    group,
418                    local_hints.as_ref(),
419                    run,
420                ),
421                run,
422            );
423
424            populated_groups.push((group, entries));
425        }
426
427        // Compute unassigned entries to determine if heading suppression applies
428        let unassigned_refs: Vec<&Reference> = all_entries
429            .iter()
430            .filter(|entry| !assigned.contains(&entry.id) && selected.contains(&entry.id))
431            .filter_map(|entry| self.bibliography.get(&entry.id))
432            .collect();
433
434        let suppress_heading = populated_groups.len() == 1 && unassigned_refs.is_empty();
435
436        // Second pass: render populated groups with optional heading suppression
437        for (group, entries) in populated_groups {
438            self.append_rendered_group::<F>(
439                &mut result,
440                group,
441                entries,
442                annotations,
443                annotation_style,
444                suppress_heading,
445            );
446        }
447
448        self.append_unassigned_entries_filtered::<F>(
449            &mut result,
450            all_entries,
451            &assigned,
452            selected,
453            annotations,
454            annotation_style,
455            run,
456        );
457        fmt.finish(result)
458    }
459
460    /// Append unassigned bibliography entries to the output string.
461    #[allow(
462        clippy::too_many_arguments,
463        reason = "internal helper, all params load-bearing"
464    )]
465    fn append_unassigned_entries_filtered<F>(
466        &self,
467        result: &mut String,
468        bibliography: &[ProcEntry],
469        assigned: &HashSet<String>,
470        selected: &HashSet<String>,
471        annotations: Option<&HashMap<String, String>>,
472        annotation_style: Option<&AnnotationStyle>,
473        run: &FinalizedRun,
474    ) where
475        F: OutputFormat<Output = String>,
476    {
477        let unassigned_refs: Vec<&Reference> = bibliography
478            .iter()
479            .filter(|entry| !assigned.contains(&entry.id) && selected.contains(&entry.id))
480            .filter_map(|entry| self.bibliography.get(&entry.id))
481            .collect();
482
483        if unassigned_refs.is_empty() {
484            return;
485        }
486
487        // Re-process references to ensure correct author substitution and disambiguation
488        // within the unassigned subset.
489        let unassigned = self.merge_compound_entries::<F>(
490            self.process_sorted_refs::<_, F>(unassigned_refs.into_iter(), run),
491            run,
492        );
493
494        if !result.is_empty() {
495            result.push_str("\n\n");
496        }
497
498        result.push_str(&crate::render::refs_to_string_with_format::<F>(
499            unassigned,
500            annotations,
501            annotation_style,
502        ));
503    }
504
505    /// Render already-merged compound entries as a flat bibliography.
506    ///
507    /// The compound path renders every configured member before merging. A
508    /// compound set is a single bibliographic unit: a cited-only document
509    /// retains a merged row if *any* configured member of that set is cited,
510    /// showing the full row (leader plus every tail) rather than hiding
511    /// non-cited members. Rows outside any compound set still filter by
512    /// their own cited status. All-references callers render every merged
513    /// row.
514    fn render_flat_compound_entries<F>(
515        &self,
516        bibliography: &[ProcEntry],
517        restrict_to_cited: bool,
518        annotations: Option<&HashMap<String, String>>,
519        annotation_style: Option<&AnnotationStyle>,
520        run: &FinalizedRun,
521    ) -> String
522    where
523        F: OutputFormat<Output = String>,
524    {
525        let fmt = F::default();
526        let selected = if restrict_to_cited {
527            let cited_ids = &run.state().cited_ids;
528            let compound_groups = &run.state().compound_groups;
529            let ref_to_group = Self::build_compound_group_lookup(compound_groups);
530            let row_is_cited = |id: &str| match ref_to_group.get(id) {
531                Some(group_number) => compound_groups
532                    .get(group_number)
533                    .is_some_and(|members| members.iter().any(|member| cited_ids.contains(member))),
534                None => cited_ids.contains(id),
535            };
536            Cow::Owned(
537                bibliography
538                    .iter()
539                    .filter(|entry| row_is_cited(&entry.id))
540                    .cloned()
541                    .collect(),
542            )
543        } else {
544            Cow::Borrowed(bibliography)
545        };
546
547        let result = if selected.is_empty() {
548            String::new()
549        } else {
550            crate::render::refs_to_string_slice_with_format::<F>(
551                selected.as_ref(),
552                annotations,
553                annotation_style,
554            )
555        };
556
557        fmt.finish(result)
558    }
559
560    /// Render all loaded references with the style's effective grouping policy.
561    ///
562    /// Enabled manual groups take precedence over automatic partition sections,
563    /// which in turn take precedence over flat rendering. Group selectors apply
564    /// to individual references before compound numeric rows are merged, so each
565    /// rendered group only includes the members that matched its selector.
566    pub fn render_grouped_bibliography_with_format<F>(&self, run: &FinalizedRun) -> String
567    where
568        F: OutputFormat<Output = String>,
569    {
570        self.render_grouped_bibliography_with_format_and_annotations::<F>(None, None, run)
571    }
572
573    /// Render the bibliography with grouping and annotations.
574    pub fn render_grouped_bibliography_with_format_and_annotations<F>(
575        &self,
576        annotations: Option<&HashMap<String, String>>,
577        annotation_style: Option<&AnnotationStyle>,
578        run: &FinalizedRun,
579    ) -> String
580    where
581        F: OutputFormat<Output = String>,
582    {
583        self.render_grouped_bibliography_inner::<F>(false, annotations, annotation_style, run)
584    }
585
586    /// One-shot convenience for [`Processor::render_grouped_bibliography_with_format`]:
587    /// begins a throwaway run internally.
588    pub fn render_grouped_bibliography_with_format_standalone<F>(&self) -> String
589    where
590        F: OutputFormat<Output = String>,
591    {
592        let run = self.begin_run().finalize();
593        self.render_grouped_bibliography_with_format::<F>(&run)
594    }
595
596    /// One-shot convenience for
597    /// [`Processor::render_grouped_bibliography_with_format_and_annotations`]:
598    /// begins a throwaway run internally.
599    pub fn render_grouped_bibliography_with_format_and_annotations_standalone<F>(
600        &self,
601        annotations: Option<&HashMap<String, String>>,
602        annotation_style: Option<&AnnotationStyle>,
603    ) -> String
604    where
605        F: OutputFormat<Output = String>,
606    {
607        let run = self.begin_run().finalize();
608        self.render_grouped_bibliography_with_format_and_annotations::<F>(
609            annotations,
610            annotation_style,
611            &run,
612        )
613    }
614
615    /// Unified document bibliography facade — returns content and per-entry data together.
616    ///
617    /// This is the single entry point for all document-context bibliography rendering:
618    /// batch (`format_document`), interactive session (`DocumentSession`), and the
619    /// document-string (`process_document`) path all funnel through here.
620    ///
621    /// When `restrict_to_cited` is `true` (the document case), only references present
622    /// in `run`'s `cited_ids` — cited in-text or registered via `nocite` — are included.
623    /// When `false`, all loaded references are eligible; this hook is reserved for the
624    /// `allrefs` escape hatch (csl26-f9ri) and is not yet exposed publicly.
625    ///
626    /// Both `content` and `entries` are computed from the same eligible subset so
627    /// subsequent-author substitution stays consistent across both outputs.
628    ///
629    /// The flat and sort-partitioned-sections cases render each eligible
630    /// reference's template exactly once — see
631    /// [`render_flat_bibliography`](Self::render_flat_bibliography) — instead
632    /// of once for `content` and again for `entries`. Custom
633    /// bibliography groups (`style.bibliography.groups`) need group-local
634    /// disambiguation and per-group templates that a flat entry list can't
635    /// carry. Compound-numeric merging needs to see every configured group
636    /// member whether cited or not (see
637    /// [`merge_compound_entries`](Self::merge_compound_entries)), so it keeps
638    /// its historical full-member content pass.
639    pub(crate) fn render_document_bibliography<F>(
640        &self,
641        restrict_to_cited: bool,
642        annotations: Option<&HashMap<String, String>>,
643        annotation_style: Option<&AnnotationStyle>,
644        run: &FinalizedRun,
645    ) -> super::DocumentBibliography
646    where
647        F: OutputFormat<Output = String>,
648    {
649        let has_custom_groups = self.effective_custom_groups().is_some();
650
651        if has_custom_groups || !run.state().compound_groups.is_empty() {
652            let content = self.render_grouped_bibliography_inner::<F>(
653                restrict_to_cited,
654                annotations,
655                annotation_style,
656                run,
657            );
658            let cited_ids: Vec<String> = run.state().cited_ids.iter().cloned().collect();
659            let entries = if restrict_to_cited {
660                self.process_selected_references_with_format::<F, _>(cited_ids, run)
661                    .bibliography
662            } else {
663                self.process_references_with_format::<F>(run).bibliography
664            };
665            return super::DocumentBibliography { content, entries };
666        }
667
668        let bibliography_options = self.get_bibliography_options();
669        let partitioning = bibliography_options
670            .sort_partitioning
671            .as_ref()
672            .filter(|partitioning| crate::sort_partitioning::should_render_sections(partitioning));
673
674        self.render_flat_bibliography::<F>(
675            restrict_to_cited,
676            partitioning,
677            FlatBibliographyOutput::ContentAndEntries,
678            annotations,
679            annotation_style,
680            run,
681        )
682    }
683
684    /// Collect the eligible references — all loaded, or only the cited/`nocite`
685    /// subset when `restrict_to_cited` is `true` — in bibliography sort order.
686    fn sorted_eligible_refs(&self, restrict_to_cited: bool, run: &FinalizedRun) -> Vec<&Reference> {
687        let mut refs: Vec<&Reference> = self.bibliography.values().collect();
688        if restrict_to_cited {
689            let cited = &run.state().cited_ids;
690            refs.retain(|reference| {
691                reference
692                    .id()
693                    .as_deref()
694                    .is_some_and(|id| cited.contains(id))
695            });
696        }
697        self.sort_references(refs)
698    }
699
700    /// Render a flat or automatically partitioned bibliography in one pass.
701    ///
702    /// Shared by document and grouped bibliography surfaces once the caller
703    /// has established there are no custom or compound-numeric groups active.
704    /// When `restrict_to_cited` is `true`, only cited and `nocite` references
705    /// are eligible; otherwise every loaded reference is eligible. Renders each
706    /// eligible reference's template exactly once (`render_numbered_refs` — the
707    /// expensive step, resolving names/dates/titles through the full template)
708    /// and reuses that render for both outputs:
709    ///
710    /// - `entries`: one continuous subsequent-author-substitution pass over the
711    ///   flat, globally sorted eligible set — matches the historical
712    ///   `process_selected_references_with_format` contract, including its
713    ///   ordering.
714    /// - `content`: without partitioning, the same flat pass rendered to a
715    ///   string. With `partitioning` requesting visible sections, an
716    ///   independent substitution pass runs *per section* — substitution
717    ///   state must reset at each section boundary to match historical
718    ///   [`render_with_partition_sections`](Self::render_with_partition_sections)
719    ///   output — reusing the one template render already produced above.
720    ///   Only this lightweight linear post-pass reruns per section; the
721    ///   expensive template render does not.
722    ///
723    /// Entry numbering (`number_sorted_refs`) is resolved once, in flat sorted
724    /// order, for both outputs. Numeric bibliography styles pre-assign
725    /// citation numbers document-wide during `begin_run` and look them up from
726    /// that shared map regardless of section membership, so this is a no-op
727    /// difference for them. Only the position-based fallback used by
728    /// non-numeric styles could differ from a per-section index — but
729    /// non-numeric styles do not render a citation-number variable, so that
730    /// fallback value is never observable in output.
731    fn render_flat_bibliography<F>(
732        &self,
733        restrict_to_cited: bool,
734        partitioning: Option<&BibliographySortPartitioning>,
735        output: FlatBibliographyOutput,
736        annotations: Option<&HashMap<String, String>>,
737        annotation_style: Option<&AnnotationStyle>,
738        run: &FinalizedRun,
739    ) -> super::DocumentBibliography
740    where
741        F: OutputFormat<Output = String>,
742    {
743        let sorted_refs = self.sorted_eligible_refs(restrict_to_cited, run);
744
745        if output == FlatBibliographyOutput::ContentOnly
746            && let Some(partitioning) = partitioning
747        {
748            let content = self.render_with_partition_sections::<F>(
749                sorted_refs,
750                partitioning,
751                annotations,
752                annotation_style,
753                run,
754            );
755            return super::DocumentBibliography {
756                content,
757                entries: Vec::new(),
758            };
759        }
760
761        let ctx = self.flat_render_context(run);
762        let numbered_refs = super::number_sorted_refs(sorted_refs.iter().copied(), run);
763        let rendered = self.render_numbered_refs::<F>(&numbered_refs, &ctx);
764
765        let substitute = ctx
766            .bibliography_config
767            .subsequent_author_substitute
768            .as_ref();
769
770        if let Some(partitioning) = partitioning {
771            // Only ContentAndEntries reaches this branch: ContentOnly with
772            // partitioning returned through `render_with_partition_sections`
773            // above, so `entries` is always wanted here.
774            let entries =
775                self.apply_substitution_post_pass::<F>(rendered.clone(), substitute, &ctx);
776            let mut result = String::new();
777            for (partition_key, refs_in_section) in crate::sort_partitioning::partition_references(
778                sorted_refs,
779                &self.locale,
780                partitioning,
781            ) {
782                let heading = partition_key
783                    .as_ref()
784                    .and_then(|key| partitioning.headings.get(key));
785                let section_ids: HashSet<String> = refs_in_section
786                    .iter()
787                    .filter_map(|reference| reference.id().map(|id| id.to_string()))
788                    .collect();
789                let section_rendered = rendered
790                    .iter()
791                    .filter(|(reference, _)| {
792                        reference
793                            .id()
794                            .as_deref()
795                            .is_some_and(|id| section_ids.contains(id))
796                    })
797                    .cloned()
798                    .collect();
799                let section_entries =
800                    self.apply_substitution_post_pass::<F>(section_rendered, substitute, &ctx);
801                self.append_rendered_partition::<F>(
802                    &mut result,
803                    heading,
804                    section_entries,
805                    annotations,
806                    annotation_style,
807                );
808            }
809            return super::DocumentBibliography {
810                content: F::default().finish(result),
811                entries,
812            };
813        }
814
815        let rendered_entries = self.apply_substitution_post_pass::<F>(rendered, substitute, &ctx);
816        let content = if rendered_entries.is_empty() {
817            String::new()
818        } else {
819            crate::render::refs_to_string_slice_with_format::<F>(
820                &rendered_entries,
821                annotations,
822                annotation_style,
823            )
824        };
825        let entries = match output {
826            FlatBibliographyOutput::ContentAndEntries => rendered_entries,
827            FlatBibliographyOutput::ContentOnly => Vec::new(),
828        };
829
830        super::DocumentBibliography { content, entries }
831    }
832
833    /// Shared implementation for grouped bibliography rendering.
834    ///
835    /// When `restrict_to_cited` is `true`, each branch limits its candidate
836    /// set to references present in `run`'s `cited_ids`. When `false`, all
837    /// loaded references are eligible (the all-references behaviour used by
838    /// the standalone grouped API, FFI, and tests).
839    fn render_grouped_bibliography_inner<F>(
840        &self,
841        restrict_to_cited: bool,
842        annotations: Option<&HashMap<String, String>>,
843        annotation_style: Option<&AnnotationStyle>,
844        run: &FinalizedRun,
845    ) -> String
846    where
847        F: OutputFormat<Output = String>,
848    {
849        if let Some(groups) = self.effective_custom_groups() {
850            let id_stubs = self.sorted_id_stubs();
851            let selected = if restrict_to_cited {
852                let cited = &run.state().cited_ids;
853                id_stubs
854                    .iter()
855                    .filter(|e| cited.contains(&e.id))
856                    .map(|e| e.id.clone())
857                    .collect::<HashSet<_>>()
858            } else {
859                id_stubs
860                    .iter()
861                    .map(|e| e.id.clone())
862                    .collect::<HashSet<_>>()
863            };
864            return self.render_with_custom_groups_filtered::<F>(
865                &id_stubs,
866                groups,
867                &selected,
868                annotations,
869                annotation_style,
870                run,
871            );
872        }
873
874        let bibliography_options = self.get_bibliography_options();
875        let partitioning = bibliography_options
876            .sort_partitioning
877            .as_ref()
878            .filter(|partitioning| crate::sort_partitioning::should_render_sections(partitioning));
879
880        if !run.state().compound_groups.is_empty() {
881            if let Some(partitioning) = partitioning {
882                let sorted_refs = self.sorted_eligible_refs(restrict_to_cited, run);
883                return self.render_with_partition_sections::<F>(
884                    sorted_refs,
885                    partitioning,
886                    annotations,
887                    annotation_style,
888                    run,
889                );
890            }
891
892            let all_entries = self.process_references_with_format::<F>(run).bibliography;
893            let merged = self.merge_compound_entries::<F>(all_entries, run);
894            return self.render_flat_compound_entries::<F>(
895                &merged,
896                restrict_to_cited,
897                annotations,
898                annotation_style,
899                run,
900            );
901        }
902
903        self.render_flat_bibliography::<F>(
904            restrict_to_cited,
905            partitioning,
906            FlatBibliographyOutput::ContentOnly,
907            annotations,
908            annotation_style,
909            run,
910        )
911        .content
912    }
913
914    /// Extract and render entries for a bibliography group.
915    ///
916    /// Returns the individual processed entries for the group, threading
917    /// the `assigned` dedup set to ensure each reference appears in only one
918    /// group. `spine` is the document-wide sorted ID stub list (see
919    /// [`sorted_id_stubs`](Self::sorted_id_stubs)); callers that render
920    /// multiple groups in one pass (e.g.
921    /// [`render_document_bibliography_blocks`](Self::render_document_bibliography_blocks))
922    /// compute it once and pass the same slice to every group instead of
923    /// re-sorting the whole bibliography per group.
924    fn entries_for_bibliography_group<F>(
925        &self,
926        spine: &[ProcEntry],
927        group: &BibliographyGroup,
928        assigned: &mut HashSet<String>,
929        run: &FinalizedRun,
930    ) -> Vec<crate::render::ProcEntry>
931    where
932        F: OutputFormat<Output = String>,
933    {
934        let cited_ids = &run.state().cited_ids;
935        let evaluator = SelectorEvaluator::new(cited_ids);
936        let bibliography_config = self.get_bibliography_config();
937        let sorter = ReferenceSorter::with_bibliography_config(&self.locale, &bibliography_config);
938
939        let matching_refs = self.collect_matching_group_refs(spine, assigned, &evaluator, group);
940        Self::mark_group_members_assigned(assigned, &matching_refs);
941
942        if matching_refs.is_empty() {
943            return Vec::new();
944        }
945
946        let sorted_refs = if let Some(sort_spec) = &group.sort {
947            sorter.sort_references(matching_refs, &sort_spec.resolve())
948        } else {
949            matching_refs
950        };
951
952        let local_hints = self.build_group_local_hints(&sorted_refs, group);
953        self.merge_compound_entries::<F>(
954            self.render_group_entries::<F>(spine, sorted_refs, group, local_hints.as_ref(), run),
955            run,
956        )
957    }
958
959    /// Render one bibliography block for document output.
960    ///
961    /// Returns heading and body separately so callers can insert headings
962    /// in their own output format.
963    ///
964    /// `spine` is the document-wide sorted ID spine (see
965    /// [`sorted_id_stubs`](Self::sorted_id_stubs)). Standalone callers pass
966    /// their own `self.sorted_id_stubs()`;
967    /// [`render_document_bibliography_blocks`](Self::render_document_bibliography_blocks)
968    /// computes it once and shares the same slice across every block,
969    /// avoiding a full bibliography re-sort per block.
970    #[allow(
971        clippy::too_many_arguments,
972        reason = "internal helper, all params load-bearing"
973    )]
974    pub(crate) fn render_document_bibliography_block<F>(
975        &self,
976        spine: &[ProcEntry],
977        group: &BibliographyGroup,
978        assigned: &mut HashSet<String>,
979        annotations: Option<&HashMap<String, String>>,
980        annotation_style: Option<&AnnotationStyle>,
981        run: &FinalizedRun,
982    ) -> RenderedBibliographyGroup
983    where
984        F: OutputFormat<Output = String>,
985    {
986        let mut headingless = group.clone();
987        let heading = headingless
988            .heading
989            .take()
990            .and_then(|group_heading| self.resolve_group_heading(&group_heading));
991
992        let entries = self.entries_for_bibliography_group::<F>(spine, &headingless, assigned, run);
993        let body = crate::render::refs_to_string_slice_with_format::<F>(
994            &entries,
995            annotations,
996            annotation_style,
997        );
998
999        RenderedBibliographyGroup {
1000            heading,
1001            body,
1002            entries,
1003        }
1004    }
1005
1006    /// Render an ordered sequence of sectional bibliography blocks.
1007    ///
1008    /// Threads a single `assigned` dedup set so each reference appears in
1009    /// only one block. Returns rendered groups with heading, body, and
1010    /// entries. Computes the sorted ID spine once for the whole call (see
1011    /// [`sorted_id_stubs`](Self::sorted_id_stubs)) and shares it across every
1012    /// block, instead of each block independently re-sorting the full
1013    /// bibliography.
1014    pub(crate) fn render_document_bibliography_blocks<F>(
1015        &self,
1016        groups: &[BibliographyGroup],
1017        annotations: Option<&HashMap<String, String>>,
1018        annotation_style: Option<&AnnotationStyle>,
1019        run: &FinalizedRun,
1020    ) -> Vec<RenderedBibliographyGroup>
1021    where
1022        F: OutputFormat<Output = String>,
1023    {
1024        let spine = self.sorted_id_stubs();
1025        let mut assigned = std::collections::HashSet::new();
1026        groups
1027            .iter()
1028            .map(|group| {
1029                self.render_document_bibliography_block::<F>(
1030                    &spine,
1031                    group,
1032                    &mut assigned,
1033                    annotations,
1034                    annotation_style,
1035                    run,
1036                )
1037            })
1038            .collect()
1039    }
1040
1041    pub(super) fn extract_metadata(
1042        &self,
1043        reference: &Reference,
1044        ctx: &EntryRenderContext<'_>,
1045    ) -> ProcEntryMetadata {
1046        let bibliography_config = &ctx.config;
1047        let options = RenderOptions {
1048            config: bibliography_config.clone(),
1049            bibliography_config: Some(ctx.bibliography_config.clone()),
1050            locale: &self.locale,
1051            context: RenderContext::Bibliography,
1052            mode: citum_schema::citation::CitationMode::NonIntegral,
1053            suppress_author: false,
1054            locator_raw: None,
1055            ref_type: None,
1056            show_semantics: self.show_semantics,
1057            current_template_index: None,
1058            abbreviation_map: self.abbreviation_map.as_ref(),
1059        };
1060
1061        let ml = bibliography_config.multilingual.as_ref();
1062        let preferred_transliteration = ml.and_then(|m| m.preferred_transliteration.as_deref());
1063        let preferred_script = ml.and_then(|m| m.preferred_script.as_ref());
1064
1065        ProcEntryMetadata {
1066            author: reference.author().map(|author| {
1067                let names = resolve_multilingual_name(
1068                    &author,
1069                    ml.and_then(|m| m.name_mode.as_ref()),
1070                    preferred_transliteration,
1071                    preferred_script,
1072                    &self.locale.locale,
1073                );
1074                format_contributors_short(&names, &options)
1075            }),
1076            year: reference
1077                .effective_issued_date()
1078                .map(|issued| issued.year().clone()),
1079            title: reference.title().map(|title| {
1080                use citum_schema::reference::types::{MultilingualString, Title};
1081                match &title {
1082                    Title::Multilingual(m) => resolve_multilingual_string(
1083                        &MultilingualString::Complex(m.clone()),
1084                        ml.and_then(|ml| ml.title_mode.as_ref()),
1085                        preferred_transliteration,
1086                        preferred_script,
1087                        &self.locale.locale,
1088                    ),
1089                    _ => title.to_string(),
1090                }
1091            }),
1092        }
1093    }
1094
1095    fn render_group_heading<F>(&self, heading: &str) -> String
1096    where
1097        F: OutputFormat<Output = String>,
1098    {
1099        let fmt = F::default();
1100        fmt.finish(fmt.unnumbered_heading(2, fmt.text(heading)))
1101    }
1102}