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