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