Skip to main content

citum_engine/processor/
setup.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Processor construction and configuration helpers.
7//!
8//! This module owns setup-time concerns for [`Processor`]: constructor paths,
9//! locale/config resolution, compound-set validation, and numeric citation
10//! number initialization. It intentionally does not contain citation or
11//! bibliography rendering logic.
12
13use super::Processor;
14use super::disambiguation::Disambiguator;
15use super::run_state::RunState;
16use crate::error::ProcessorError;
17use crate::reference::{Bibliography, CitationItem, Reference};
18use crate::values::ProcHints;
19use citum_schema::Style;
20use citum_schema::locale::Locale;
21use citum_schema::options::{
22    BibliographyPartitionKind, BibliographyPartitionMode, BibliographySortPartitioning, Config,
23    PunctuationConfig, SortingMultilingualMode, bibliography::BibliographyConfig,
24};
25use indexmap::IndexMap;
26use std::collections::HashMap;
27
28impl Default for Processor {
29    fn default() -> Self {
30        let compound_sets = IndexMap::new();
31        let (compound_set_by_ref, compound_member_index) =
32            Self::build_compound_set_indexes(&compound_sets);
33        Self {
34            style: Style::default(),
35            bibliography: Bibliography::default(),
36            locale: Locale::en_us(),
37            default_config: Config::default(),
38            hints: HashMap::new(),
39            compound_sets,
40            compound_set_by_ref,
41            compound_member_index,
42            show_semantics: true,
43            inject_ast_indices: false,
44            abbreviation_map: None,
45        }
46    }
47}
48
49impl Processor {
50    /// Whether the active locale's grammar options may supply style defaults.
51    ///
52    /// A locale substituted for one that could not be resolved (see
53    /// [`citum_schema::locale::Locale::resolved_by_fallback`]) carries another
54    /// language's conventions; applying its grammar options would silently
55    /// impose them on a style that asked for something else.
56    fn locale_grammar_is_authoritative(&self) -> bool {
57        !self.locale.resolved_by_fallback
58    }
59
60    /// Whether a raw YAML mapping has an authored `key` entry.
61    fn raw_mapping_has_key(value: &serde_yaml::Value, key: &str) -> bool {
62        value
63            .as_mapping()
64            .is_some_and(|m| m.get(serde_yaml::Value::String(key.to_string())).is_some())
65    }
66
67    /// Whether `punctuation-in-quote` was explicitly authored, as opposed to
68    /// arriving at its current resolved value purely through defaults.
69    ///
70    /// A plain `bool` can't distinguish an authored `false` from an unset
71    /// field defaulting to `false` (csl26-yxay), so the locale default below
72    /// must not apply when the style actually chose `false` on purpose.
73    /// Checks the two raw-YAML-aware sources of authored intent available at
74    /// this point: the leaf style's own captured document (top-level
75    /// `options.punctuation-in-quote`), and `scoped_raw` — the
76    /// extends-chain-merged raw `citation.options` / `bibliography.options`
77    /// mapping for the scope being resolved (unlike the top-level `options`
78    /// block, [`citum_schema::options::cascade::ScopedRawOptions`] survives
79    /// `extends` resolution, so this also catches an ancestor's scoped
80    /// authorship that no descendant repeats).
81    ///
82    /// Not caught: an ancestor's *global* `options.punctuation-in-quote`
83    /// that no descendant repeats anywhere (global `raw_yaml` is leaf-only;
84    /// see `Style::raw_yaml`). No embedded or exemplar style currently hits
85    /// this — none sets `punctuation-in-quote: false` at all — and closing
86    /// it fully needs csl26-yxay's `Option<bool>` migration.
87    fn punctuation_in_quote_explicitly_authored(
88        &self,
89        scoped_raw: Option<&serde_yaml::Value>,
90    ) -> bool {
91        let leaf_authored = self
92            .style
93            .raw_yaml
94            .as_ref()
95            .and_then(|doc| doc.get("options"))
96            .is_some_and(|options| Self::raw_mapping_has_key(options, "punctuation-in-quote"));
97
98        let scope_authored =
99            scoped_raw.is_some_and(|raw| Self::raw_mapping_has_key(raw, "punctuation-in-quote"));
100
101        leaf_authored || scope_authored
102    }
103
104    /// Fill unset style punctuation options from the active locale.
105    fn resolve_punctuation_defaults(
106        &self,
107        config: &mut Config,
108        scoped_raw: Option<&serde_yaml::Value>,
109    ) {
110        if !self.locale_grammar_is_authoritative() {
111            return;
112        }
113
114        if !config.punctuation_in_quote
115            && !self.punctuation_in_quote_explicitly_authored(scoped_raw)
116        {
117            config.punctuation_in_quote = self.locale.grammar_options.punctuation_in_quote;
118        }
119
120        let punctuation = config
121            .punctuation
122            .get_or_insert_with(PunctuationConfig::default);
123        punctuation
124            .strong_terminal_comma_policy
125            .get_or_insert(self.locale.grammar_options.strong_terminal_comma_policy);
126        punctuation
127            .delimiter_suppressing_terminal_marks
128            .get_or_insert_with(|| {
129                self.locale
130                    .grammar_options
131                    .delimiter_suppressing_terminal_marks
132                    .clone()
133            });
134    }
135
136    /// Return whether applying locale punctuation defaults would change `config`.
137    fn punctuation_defaults_require_resolution(
138        &self,
139        config: &Config,
140        scoped_raw: Option<&serde_yaml::Value>,
141    ) -> bool {
142        if !self.locale_grammar_is_authoritative() {
143            return false;
144        }
145
146        let punctuation_in_quote_is_unset = !config.punctuation_in_quote
147            && self.locale.grammar_options.punctuation_in_quote
148            && !self.punctuation_in_quote_explicitly_authored(scoped_raw);
149
150        let punctuation = config.punctuation.as_ref();
151        let policy_is_unset = punctuation
152            .and_then(|options| options.strong_terminal_comma_policy)
153            .is_none();
154        let marks_are_unset = punctuation
155            .and_then(|options| options.delimiter_suppressing_terminal_marks.as_ref())
156            .is_none();
157
158        punctuation_in_quote_is_unset
159            || (policy_is_unset
160                && self.locale.grammar_options.strong_terminal_comma_policy
161                    != citum_schema::options::StrongTerminalCommaPolicy::default())
162            || (marks_are_unset
163                && self
164                    .locale
165                    .grammar_options
166                    .delimiter_suppressing_terminal_marks
167                    != "?!…")
168    }
169
170    /// Apply locale punctuation defaults only when they change the effective config.
171    fn with_punctuation_defaults<'a>(
172        &self,
173        config: std::borrow::Cow<'a, Config>,
174        scoped_raw: Option<&serde_yaml::Value>,
175    ) -> std::borrow::Cow<'a, Config> {
176        if !self.punctuation_defaults_require_resolution(&config, scoped_raw) {
177            return config;
178        }
179
180        let mut config = config.into_owned();
181        self.resolve_punctuation_defaults(&mut config, scoped_raw);
182        std::borrow::Cow::Owned(config)
183    }
184
185    /// Core internal constructor path.
186    ///
187    /// Resolves the style presets before initializing the processor.
188    fn build_processor(
189        style: Style,
190        bibliography: Bibliography,
191        locale: Locale,
192        compound_sets: IndexMap<String, Vec<String>>,
193    ) -> Self {
194        let style = style.into_resolved();
195        Self::build_processor_pre_resolved(style, bibliography, locale, compound_sets)
196    }
197
198    /// Build a processor from an already-resolved style, skipping preset resolution.
199    ///
200    /// Use this when the style was cloned from a processor that has already
201    /// called `into_resolved()`, to avoid a second resolution pass that would
202    /// re-apply preset defaults and overwrite null-cleared fields.
203    pub(super) fn build_processor_pre_resolved(
204        style: Style,
205        bibliography: Bibliography,
206        locale: Locale,
207        compound_sets: IndexMap<String, Vec<String>>,
208    ) -> Self {
209        let (compound_set_by_ref, compound_member_index) =
210            Self::build_compound_set_indexes(&compound_sets);
211        let mut processor = Processor {
212            style,
213            bibliography,
214            locale,
215            default_config: Config::default(),
216            hints: HashMap::new(),
217            compound_sets,
218            compound_set_by_ref,
219            compound_member_index,
220            show_semantics: true,
221            inject_ast_indices: false,
222            abbreviation_map: None,
223        };
224
225        // Pre-calculate hints for disambiguation.
226        processor.hints = processor.calculate_hints();
227        processor
228    }
229
230    /// Validate compound sets against the bibliography.
231    fn try_validate_compound_sets(
232        bibliography: &Bibliography,
233        compound_sets: IndexMap<String, Vec<String>>,
234    ) -> Result<IndexMap<String, Vec<String>>, ProcessorError> {
235        super::validate_compound_sets(Some(compound_sets), bibliography)
236            .map(Option::unwrap_or_default)
237    }
238
239    /// Validate compound sets, falling back to an empty map on error.
240    fn validate_compound_sets_or_default(
241        bibliography: &Bibliography,
242        compound_sets: IndexMap<String, Vec<String>>,
243    ) -> IndexMap<String, Vec<String>> {
244        Self::try_validate_compound_sets(bibliography, compound_sets).unwrap_or_default()
245    }
246
247    /// Build flat reverse-lookup maps for compound sets.
248    ///
249    /// Maps reference IDs to their parent set ID and their 0-based position
250    /// within that set.
251    fn build_compound_set_indexes(
252        sets: &IndexMap<String, Vec<String>>,
253    ) -> (HashMap<String, String>, HashMap<String, usize>) {
254        let mut by_ref = HashMap::new();
255        let mut member_index = HashMap::new();
256        for (set_id, members) in sets {
257            for (idx, member) in members.iter().enumerate() {
258                by_ref.insert(member.clone(), set_id.clone());
259                member_index.insert(member.clone(), idx);
260            }
261        }
262        (by_ref, member_index)
263    }
264
265    /// Check whether the style uses note-based citations (footnotes/endnotes).
266    pub(crate) fn is_note_style(&self) -> bool {
267        self.get_config()
268            .processing
269            .as_ref()
270            .is_some_and(|processing| matches!(processing, citum_schema::options::Processing::Note))
271    }
272
273    /// Check whether the style uses numeric citation rendering.
274    fn is_numeric_style(&self) -> bool {
275        self.get_config()
276            .processing
277            .as_ref()
278            .is_some_and(|processing| {
279                matches!(processing, citum_schema::options::Processing::Numeric)
280            })
281    }
282
283    /// Check whether the style uses numeric bibliography rendering.
284    fn is_numeric_bibliography_style(&self) -> bool {
285        self.get_bibliography_config()
286            .processing
287            .as_ref()
288            .is_some_and(|processing| {
289                matches!(processing, citum_schema::options::Processing::Numeric)
290            })
291    }
292
293    /// Resolve the effective bibliography sort specification.
294    ///
295    /// Accounts for style overrides, processing-family preset defaults, and
296    /// (when neither applies) an explicit config-level `sort:`. The returned
297    /// `bool` is `true` only when the sort originates from that last,
298    /// explicit config-level step; the caller then applies the deterministic
299    /// entry-ID tiebreaker so config-driven sorts (`Processing::Numeric` /
300    /// `Custom` with an explicit `sort:`) stay oracle-fidelity-stable.
301    /// Styles with a processing-family preset default (`AuthorDate*`, `Note`,
302    /// `Label`) never reach the config-level step; those without any
303    /// `processing:` fall back to the default family (`AuthorDate`), whose
304    /// config carries the author-date sort preset.
305    fn resolved_bibliography_sort(&self) -> Option<(citum_schema::grouping::GroupSort, bool)> {
306        if let Some(sort_spec) = self
307            .style
308            .bibliography
309            .as_ref()
310            .and_then(|bibliography| bibliography.sort.as_ref())
311        {
312            return Some((sort_spec.resolve(), false));
313        }
314
315        let bibliography_config = self.get_bibliography_config();
316
317        if let Some(preset) = bibliography_config
318            .processing
319            .as_ref()
320            .and_then(citum_schema::options::Processing::default_bibliography_sort)
321        {
322            return Some((preset.group_sort(), false));
323        }
324
325        bibliography_config
326            .processing
327            .clone()
328            .unwrap_or_default()
329            .config()
330            .sort
331            .map(|sort_entry| (sort_entry.resolve().group_sort(), true))
332    }
333
334    /// Initialize numeric citation numbers from bibliography insertion order.
335    ///
336    /// citeproc-js registers all bibliography items before citation rendering in
337    /// the oracle workflow, so numeric labels are stable by reference registry
338    /// order rather than first-citation order.
339    ///
340    /// When the style declares an explicit bibliography sort, or the
341    /// processing family provides a bibliography default, citation numbers
342    /// must follow that resolved bibliography order.
343    pub(crate) fn initialize_numeric_citation_numbers(&self, run: &mut RunState) {
344        if !self.is_numeric_style() {
345            return;
346        }
347
348        self.initialize_numeric_numbers(run, self.sort_citation_number_order());
349    }
350
351    /// Initialize numeric bibliography numbers from resolved bibliography order.
352    pub(crate) fn initialize_numeric_bibliography_numbers(&self, run: &mut RunState) {
353        if !self.is_numeric_bibliography_style() {
354            return;
355        }
356
357        self.initialize_numeric_numbers(run, self.sort_bibliography_number_order());
358    }
359
360    /// Initialize citation numbers if the map is currently empty.
361    fn initialize_numeric_numbers(&self, run: &mut RunState, ordered_ids: Vec<String>) {
362        if !run
363            .citation_numbers
364            .read()
365            .unwrap_or_else(std::sync::PoisonError::into_inner)
366            .is_empty()
367        {
368            return;
369        }
370
371        self.initialize_numeric_citation_numbers_from_ordered_ids(run, ordered_ids);
372    }
373
374    /// Calculate the document-wide reference order for citation numbering.
375    fn sort_citation_number_order(&self) -> Vec<String> {
376        self.sort_references(self.bibliography.values().collect())
377            .into_iter()
378            .filter_map(citum_schema::reference::InputReference::id)
379            .map(String::from)
380            .collect()
381    }
382
383    /// Calculate the reference order for bibliography numbering.
384    fn sort_bibliography_number_order(&self) -> Vec<String> {
385        self.sort_references(self.bibliography.values().collect())
386            .into_iter()
387            .filter_map(citum_schema::reference::InputReference::id)
388            .map(String::from)
389            .collect()
390    }
391
392    /// Assign stable numeric labels to references based on a document order.
393    ///
394    /// Also populates compound groups for numeric styles that enable compound
395    /// numbering.
396    fn initialize_numeric_citation_numbers_from_ordered_ids(
397        &self,
398        run: &mut RunState,
399        ordered_ids: Vec<String>,
400    ) {
401        let mut numbers = run
402            .citation_numbers
403            .write()
404            .unwrap_or_else(std::sync::PoisonError::into_inner);
405        if !numbers.is_empty() {
406            return;
407        }
408
409        let compound_config = self.get_bibliography_options().compound_numeric.clone();
410
411        if compound_config.is_some() {
412            let mut set_first_seen: IndexMap<String, usize> = IndexMap::new();
413            let mut current_number = 1usize;
414            run.compound_groups.clear();
415
416            for ref_id in &ordered_ids {
417                if let Some(set_id) = self.compound_set_by_ref.get(ref_id) {
418                    if let Some(&number) = set_first_seen.get(set_id) {
419                        numbers.insert(ref_id.clone(), number);
420                    } else {
421                        set_first_seen.insert(set_id.clone(), current_number);
422                        if let Some(members) = self.compound_sets.get(set_id) {
423                            let present_members: Vec<String> = members
424                                .iter()
425                                .filter(|id| self.bibliography.contains_key(*id))
426                                .cloned()
427                                .collect();
428                            for member in &present_members {
429                                numbers.insert(member.clone(), current_number);
430                            }
431                            if present_members.len() > 1 {
432                                run.compound_groups.insert(current_number, present_members);
433                            }
434                        } else {
435                            numbers.insert(ref_id.clone(), current_number);
436                        }
437                        current_number += 1;
438                    }
439                } else if !numbers.contains_key(ref_id) {
440                    numbers.insert(ref_id.clone(), current_number);
441                    current_number += 1;
442                }
443            }
444        } else {
445            for (index, ref_id) in ordered_ids.into_iter().enumerate() {
446                numbers.insert(ref_id, index + 1);
447            }
448        }
449    }
450
451    /// Begin a new render run.
452    ///
453    /// Allocates fresh per-run state (citation numbers, cite-order tracking,
454    /// dynamic compound groups, first-note tracking) and performs numeric
455    /// citation-number pre-initialization from the processor's immutable
456    /// bibliography/style data. Registration methods (`&self, &mut RunState`)
457    /// populate the returned `RunState` in citation-processing order; call
458    /// [`RunState::finalize`] before rendering. See
459    /// `docs/specs/EXPLICIT_RENDER_RUN_STATE.md`.
460    #[must_use]
461    pub fn begin_run(&self) -> RunState {
462        let mut run = RunState::default();
463        self.initialize_numeric_citation_numbers(&mut run);
464        self.initialize_numeric_bibliography_numbers(&mut run);
465        run
466    }
467
468    /// Create a new processor with default English locale (en-US).
469    #[must_use]
470    pub fn new(style: Style, bibliography: Bibliography) -> Self {
471        Self::with_compound_sets(style, bibliography, IndexMap::new())
472    }
473
474    /// Create a new processor with explicit compound sets, returning an error for invalid sets.
475    ///
476    /// # Errors
477    ///
478    /// Returns an error when any compound set references unknown bibliography
479    /// entries or reuses the same member more than once.
480    pub fn try_with_compound_sets(
481        style: Style,
482        bibliography: Bibliography,
483        compound_sets: IndexMap<String, Vec<String>>,
484    ) -> Result<Self, ProcessorError> {
485        Self::try_with_locale_and_compound_sets(style, bibliography, Locale::en_us(), compound_sets)
486    }
487
488    /// Create a new processor with explicit compound sets.
489    ///
490    /// If `compound_sets` is invalid, this constructor ignores the supplied sets
491    /// and falls back to a processor without compound sets.
492    #[must_use]
493    pub fn with_compound_sets(
494        style: Style,
495        bibliography: Bibliography,
496        compound_sets: IndexMap<String, Vec<String>>,
497    ) -> Self {
498        let validated_sets = Self::validate_compound_sets_or_default(&bibliography, compound_sets);
499        Self::build_processor(style, bibliography, Locale::en_us(), validated_sets)
500    }
501
502    /// Create a new processor with a specified locale.
503    ///
504    /// The locale determines term translations and locale-specific formatting behavior.
505    #[must_use]
506    pub fn with_locale(style: Style, bibliography: Bibliography, locale: Locale) -> Self {
507        Self::with_locale_and_compound_sets(style, bibliography, locale, IndexMap::new())
508    }
509
510    /// Create a new processor with explicit locale and compound sets, returning
511    /// an error for invalid sets.
512    ///
513    /// # Errors
514    ///
515    /// Returns an error when any compound set references unknown bibliography
516    /// entries or reuses the same member more than once.
517    pub fn try_with_locale_and_compound_sets(
518        style: Style,
519        bibliography: Bibliography,
520        locale: Locale,
521        compound_sets: IndexMap<String, Vec<String>>,
522    ) -> Result<Self, ProcessorError> {
523        let validated_sets = Self::try_validate_compound_sets(&bibliography, compound_sets)?;
524        Ok(Self::build_processor(
525            style,
526            bibliography,
527            locale,
528            validated_sets,
529        ))
530    }
531
532    /// Create a new processor with a specified locale and explicit compound sets.
533    ///
534    /// The locale determines term translations and locale-specific formatting behavior.
535    ///
536    /// If `compound_sets` is invalid, this constructor ignores the supplied sets
537    /// and falls back to a processor without compound sets.
538    #[must_use]
539    pub fn with_locale_and_compound_sets(
540        style: Style,
541        bibliography: Bibliography,
542        locale: Locale,
543        compound_sets: IndexMap<String, Vec<String>>,
544    ) -> Self {
545        let validated_sets = Self::validate_compound_sets_or_default(&bibliography, compound_sets);
546        Self::build_processor(style, bibliography, locale, validated_sets)
547    }
548
549    /// Create a new processor, loading the locale from disk.
550    ///
551    /// Loads the locale specified in the style's `default_locale` field from the given directory,
552    /// falling back to en-US if not found or not specified.
553    #[must_use]
554    pub fn with_style_locale(
555        style: Style,
556        bibliography: Bibliography,
557        locales_dir: &std::path::Path,
558    ) -> Self {
559        let style = style.into_resolved();
560        let locale = if let Some(ref locale_id) = style.info.default_locale {
561            Locale::load(locale_id, locales_dir)
562        } else {
563            Locale::en_us()
564        };
565        Self::with_locale_and_compound_sets(style, bibliography, locale, IndexMap::new())
566    }
567
568    /// Return a copy of the processor that injects source template indices into semantic HTML.
569    #[must_use]
570    pub fn with_inject_ast_indices(mut self, inject_ast_indices: bool) -> Self {
571        self.inject_ast_indices = inject_ast_indices;
572        self
573    }
574
575    /// Enable or disable source template index injection for semantic HTML output.
576    pub fn set_inject_ast_indices(&mut self, inject_ast_indices: bool) {
577        self.inject_ast_indices = inject_ast_indices;
578    }
579
580    /// Return the global style configuration.
581    pub fn get_config(&self) -> &Config {
582        self.style.options.as_ref().unwrap_or(&self.default_config)
583    }
584
585    /// Return merged config for citation rendering.
586    ///
587    /// Combines global style options with citation-specific overrides, borrowing
588    /// the global configuration when no merge or locale resolution is required.
589    pub fn get_citation_config(&self) -> std::borrow::Cow<'_, Config> {
590        let base = self.get_config();
591        let config = match self
592            .style
593            .citation
594            .as_ref()
595            .and_then(|citation| citation.options.as_ref())
596        {
597            Some(citation_options) => std::borrow::Cow::Owned(
598                citation_options
599                    .merged_with_raw(base, self.style.scoped_raw_options.citation.as_ref()),
600            ),
601            None => std::borrow::Cow::Borrowed(base),
602        };
603        self.with_punctuation_defaults(config, self.style.scoped_raw_options.citation.as_ref())
604    }
605
606    /// Return merged shared config for bibliography rendering.
607    ///
608    /// Combines global shared style options with bibliography-local shared overrides,
609    /// borrowing the global configuration when no merge or locale resolution is required.
610    pub fn get_bibliography_config(&self) -> std::borrow::Cow<'_, Config> {
611        let base = self.get_config();
612        let config = match self
613            .style
614            .bibliography
615            .as_ref()
616            .and_then(|bibliography| bibliography.options.as_ref())
617        {
618            Some(bibliography_options) => std::borrow::Cow::Owned(
619                bibliography_options
620                    .merged_with_raw(base, self.style.scoped_raw_options.bibliography.as_ref()),
621            ),
622            None => std::borrow::Cow::Borrowed(base),
623        };
624        self.with_punctuation_defaults(config, self.style.scoped_raw_options.bibliography.as_ref())
625    }
626
627    /// Return effective bibliography-only configuration.
628    pub fn get_bibliography_options(&self) -> std::borrow::Cow<'_, BibliographyConfig> {
629        match self
630            .style
631            .bibliography
632            .as_ref()
633            .and_then(|bibliography| bibliography.options.as_ref())
634        {
635            Some(bibliography_options) => {
636                std::borrow::Cow::Owned(bibliography_options.to_bibliography_config())
637            }
638            None => std::borrow::Cow::Owned(BibliographyConfig::default()),
639        }
640    }
641
642    /// Sort references according to the style's bibliography sort specification.
643    ///
644    /// Uses style-specified sort keys (author, title, issued, etc.) and sort order.
645    pub fn sort_references<'a>(&self, references: Vec<&'a Reference>) -> Vec<&'a Reference> {
646        let bibliography_config = self.get_bibliography_config();
647        let mut sorted_refs = match self.resolved_bibliography_sort() {
648            Some((sort_spec, true)) => {
649                let mut sorter = crate::sorting::ReferenceSorter::with_bibliography_config(
650                    &self.locale,
651                    &bibliography_config,
652                );
653                if let Some(spec) = self.style.bibliography.as_ref() {
654                    sorter = sorter.with_bibliography_spec(spec);
655                }
656                sorter.sort_references_with_id_tiebreak(references, &sort_spec)
657            }
658            Some((sort_spec, false)) => {
659                let mut sorter = crate::sorting::ReferenceSorter::with_bibliography_config(
660                    &self.locale,
661                    &bibliography_config,
662                );
663                if let Some(spec) = self.style.bibliography.as_ref() {
664                    sorter = sorter.with_bibliography_spec(spec);
665                }
666                sorter.sort_references(references, &sort_spec)
667            }
668            None => references,
669        };
670
671        let bibliography_options = self.get_bibliography_options();
672        if let Some(partitioning) =
673            effective_sort_partitioning(&bibliography_options, &bibliography_config).as_ref()
674            && crate::sort_partitioning::should_sort_flat(partitioning)
675        {
676            crate::sort_partitioning::sort_by_partition(
677                sorted_refs.as_mut_slice(),
678                &self.locale,
679                partitioning,
680            );
681        }
682
683        sorted_refs
684    }
685
686    /// Sort citation items according to the style's citation sort specification.
687    pub fn sort_citation_items(
688        &self,
689        items: Vec<CitationItem>,
690        spec: &citum_schema::CitationSpec,
691    ) -> Vec<CitationItem> {
692        if let Some(sort_spec) = &spec.sort {
693            let items_with_refs: Vec<(CitationItem, Option<&Reference>)> = items
694                .into_iter()
695                .map(|item| {
696                    let reference = self.bibliography.get(&item.id);
697                    (item, reference)
698                })
699                .collect();
700
701            let resolved_sort = sort_spec.resolve();
702            let citation_config = self.get_citation_config();
703            let sorter = crate::sorting::ReferenceSorter::with_bibliography_config(
704                &self.locale,
705                &citation_config,
706            )
707            .with_citation_spec(spec);
708            let sorted =
709                sorter.sort_by_keys(items_with_refs, &resolved_sort.template, |item| item.1);
710
711            return sorted.into_iter().map(|(item, _reference)| item).collect();
712        }
713
714        items
715    }
716
717    /// Calculate disambiguation hints needed for the style.
718    ///
719    /// Analyzes the bibliography to determine which items need disambiguation
720    /// (year suffixes, etc.) and calculates hints for efficient rendering.
721    pub fn calculate_hints(&self) -> HashMap<String, ProcHints> {
722        let citation_config = self.get_citation_config();
723        let config = citation_config.as_ref();
724        let bibliography_config = self.get_bibliography_config();
725        let bibliography_sort = self.resolved_bibliography_sort();
726
727        let mut disambiguator = if let Some((resolved_sort, id_tiebreak)) = &bibliography_sort {
728            Disambiguator::with_group_sort(
729                &self.bibliography,
730                config,
731                &bibliography_config,
732                &self.locale,
733                resolved_sort,
734            )
735            .with_id_tiebreak(*id_tiebreak)
736        } else {
737            Disambiguator::new(
738                &self.bibliography,
739                config,
740                &bibliography_config,
741                &self.locale,
742            )
743        };
744
745        if let Some(citation_spec) = self.style.citation.as_ref() {
746            disambiguator = disambiguator.with_citation_spec(citation_spec);
747        }
748        if let Some(bibliography_spec) = self.style.bibliography.as_ref() {
749            disambiguator = disambiguator.with_bibliography_spec(bibliography_spec);
750        }
751
752        disambiguator.calculate_hints()
753    }
754}
755
756fn effective_sort_partitioning(
757    bibliography_options: &BibliographyConfig,
758    bibliography_config: &Config,
759) -> Option<BibliographySortPartitioning> {
760    if let Some(partitioning) = &bibliography_options.sort_partitioning {
761        return Some(partitioning.clone());
762    }
763
764    bibliography_config
765        .sorting
766        .as_ref()
767        .is_some_and(|sorting| {
768            sorting.effective_multilingual() == SortingMultilingualMode::PerScript
769        })
770        .then(|| BibliographySortPartitioning {
771            by: BibliographyPartitionKind::Script,
772            mode: BibliographyPartitionMode::SortOnly,
773            order: Vec::new(),
774            headings: HashMap::new(),
775            unknown_fields: Default::default(),
776        })
777}