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    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    /// Core internal constructor path.
51    ///
52    /// Resolves the style presets before initializing the processor.
53    fn build_processor(
54        style: Style,
55        bibliography: Bibliography,
56        locale: Locale,
57        compound_sets: IndexMap<String, Vec<String>>,
58    ) -> Self {
59        let style = style.into_resolved();
60        Self::build_processor_pre_resolved(style, bibliography, locale, compound_sets)
61    }
62
63    /// Build a processor from an already-resolved style, skipping preset resolution.
64    ///
65    /// Use this when the style was cloned from a processor that has already
66    /// called `into_resolved()`, to avoid a second resolution pass that would
67    /// re-apply preset defaults and overwrite null-cleared fields.
68    pub(super) fn build_processor_pre_resolved(
69        style: Style,
70        bibliography: Bibliography,
71        locale: Locale,
72        compound_sets: IndexMap<String, Vec<String>>,
73    ) -> Self {
74        let (compound_set_by_ref, compound_member_index) =
75            Self::build_compound_set_indexes(&compound_sets);
76        let mut processor = Processor {
77            style,
78            bibliography,
79            locale,
80            default_config: Config::default(),
81            hints: HashMap::new(),
82            compound_sets,
83            compound_set_by_ref,
84            compound_member_index,
85            show_semantics: true,
86            inject_ast_indices: false,
87            abbreviation_map: None,
88        };
89
90        // Pre-calculate hints for disambiguation.
91        processor.hints = processor.calculate_hints();
92        processor
93    }
94
95    /// Validate compound sets against the bibliography.
96    fn try_validate_compound_sets(
97        bibliography: &Bibliography,
98        compound_sets: IndexMap<String, Vec<String>>,
99    ) -> Result<IndexMap<String, Vec<String>>, ProcessorError> {
100        super::validate_compound_sets(Some(compound_sets), bibliography)
101            .map(Option::unwrap_or_default)
102    }
103
104    /// Validate compound sets, falling back to an empty map on error.
105    fn validate_compound_sets_or_default(
106        bibliography: &Bibliography,
107        compound_sets: IndexMap<String, Vec<String>>,
108    ) -> IndexMap<String, Vec<String>> {
109        Self::try_validate_compound_sets(bibliography, compound_sets).unwrap_or_default()
110    }
111
112    /// Build flat reverse-lookup maps for compound sets.
113    ///
114    /// Maps reference IDs to their parent set ID and their 0-based position
115    /// within that set.
116    fn build_compound_set_indexes(
117        sets: &IndexMap<String, Vec<String>>,
118    ) -> (HashMap<String, String>, HashMap<String, usize>) {
119        let mut by_ref = HashMap::new();
120        let mut member_index = HashMap::new();
121        for (set_id, members) in sets {
122            for (idx, member) in members.iter().enumerate() {
123                by_ref.insert(member.clone(), set_id.clone());
124                member_index.insert(member.clone(), idx);
125            }
126        }
127        (by_ref, member_index)
128    }
129
130    /// Check whether the style uses note-based citations (footnotes/endnotes).
131    pub(crate) fn is_note_style(&self) -> bool {
132        self.get_config()
133            .processing
134            .as_ref()
135            .is_some_and(|processing| matches!(processing, citum_schema::options::Processing::Note))
136    }
137
138    /// Check whether the style uses numeric citation rendering.
139    fn is_numeric_style(&self) -> bool {
140        self.get_config()
141            .processing
142            .as_ref()
143            .is_some_and(|processing| {
144                matches!(processing, citum_schema::options::Processing::Numeric)
145            })
146    }
147
148    /// Check whether the style uses numeric bibliography rendering.
149    fn is_numeric_bibliography_style(&self) -> bool {
150        self.get_bibliography_config()
151            .processing
152            .as_ref()
153            .is_some_and(|processing| {
154                matches!(processing, citum_schema::options::Processing::Numeric)
155            })
156    }
157
158    /// Resolve the effective bibliography sort specification.
159    ///
160    /// Accounts for style overrides, processing-family preset defaults, and
161    /// (when neither applies) an explicit config-level `sort:`. The returned
162    /// `bool` is `true` only when the sort originates from that last,
163    /// explicit config-level step; the caller then applies the deterministic
164    /// entry-ID tiebreaker so config-driven sorts (`Processing::Numeric` /
165    /// `Custom` with an explicit `sort:`) stay oracle-fidelity-stable.
166    /// Styles with a processing-family preset default (`AuthorDate*`, `Note`,
167    /// `Label`) never reach the config-level step; those without any
168    /// `processing:` fall back to the default family (`AuthorDate`), whose
169    /// config carries the author-date sort preset.
170    fn resolved_bibliography_sort(&self) -> Option<(citum_schema::grouping::GroupSort, bool)> {
171        if let Some(sort_spec) = self
172            .style
173            .bibliography
174            .as_ref()
175            .and_then(|bibliography| bibliography.sort.as_ref())
176        {
177            return Some((sort_spec.resolve(), false));
178        }
179
180        let bibliography_config = self.get_bibliography_config();
181
182        if let Some(preset) = bibliography_config
183            .processing
184            .as_ref()
185            .and_then(citum_schema::options::Processing::default_bibliography_sort)
186        {
187            return Some((preset.group_sort(), false));
188        }
189
190        bibliography_config
191            .processing
192            .clone()
193            .unwrap_or_default()
194            .config()
195            .sort
196            .map(|sort_entry| (sort_entry.resolve().group_sort(), true))
197    }
198
199    /// Initialize numeric citation numbers from bibliography insertion order.
200    ///
201    /// citeproc-js registers all bibliography items before citation rendering in
202    /// the oracle workflow, so numeric labels are stable by reference registry
203    /// order rather than first-citation order.
204    ///
205    /// When the style declares an explicit bibliography sort, or the
206    /// processing family provides a bibliography default, citation numbers
207    /// must follow that resolved bibliography order.
208    pub(crate) fn initialize_numeric_citation_numbers(&self, run: &mut RunState) {
209        if !self.is_numeric_style() {
210            return;
211        }
212
213        self.initialize_numeric_numbers(run, self.sort_citation_number_order());
214    }
215
216    /// Initialize numeric bibliography numbers from resolved bibliography order.
217    pub(crate) fn initialize_numeric_bibliography_numbers(&self, run: &mut RunState) {
218        if !self.is_numeric_bibliography_style() {
219            return;
220        }
221
222        self.initialize_numeric_numbers(run, self.sort_bibliography_number_order());
223    }
224
225    /// Initialize citation numbers if the map is currently empty.
226    fn initialize_numeric_numbers(&self, run: &mut RunState, ordered_ids: Vec<String>) {
227        if !run
228            .citation_numbers
229            .read()
230            .unwrap_or_else(std::sync::PoisonError::into_inner)
231            .is_empty()
232        {
233            return;
234        }
235
236        self.initialize_numeric_citation_numbers_from_ordered_ids(run, ordered_ids);
237    }
238
239    /// Calculate the document-wide reference order for citation numbering.
240    fn sort_citation_number_order(&self) -> Vec<String> {
241        self.sort_references(self.bibliography.values().collect())
242            .into_iter()
243            .filter_map(citum_schema::reference::InputReference::id)
244            .map(String::from)
245            .collect()
246    }
247
248    /// Calculate the reference order for bibliography numbering.
249    fn sort_bibliography_number_order(&self) -> Vec<String> {
250        self.sort_references(self.bibliography.values().collect())
251            .into_iter()
252            .filter_map(citum_schema::reference::InputReference::id)
253            .map(String::from)
254            .collect()
255    }
256
257    /// Assign stable numeric labels to references based on a document order.
258    ///
259    /// Also populates compound groups for numeric styles that enable compound
260    /// numbering.
261    fn initialize_numeric_citation_numbers_from_ordered_ids(
262        &self,
263        run: &mut RunState,
264        ordered_ids: Vec<String>,
265    ) {
266        let mut numbers = run
267            .citation_numbers
268            .write()
269            .unwrap_or_else(std::sync::PoisonError::into_inner);
270        if !numbers.is_empty() {
271            return;
272        }
273
274        let compound_config = self.get_bibliography_options().compound_numeric.clone();
275
276        if compound_config.is_some() {
277            let mut set_first_seen: IndexMap<String, usize> = IndexMap::new();
278            let mut current_number = 1usize;
279            run.compound_groups.clear();
280
281            for ref_id in &ordered_ids {
282                if let Some(set_id) = self.compound_set_by_ref.get(ref_id) {
283                    if let Some(&number) = set_first_seen.get(set_id) {
284                        numbers.insert(ref_id.clone(), number);
285                    } else {
286                        set_first_seen.insert(set_id.clone(), current_number);
287                        if let Some(members) = self.compound_sets.get(set_id) {
288                            let present_members: Vec<String> = members
289                                .iter()
290                                .filter(|id| self.bibliography.contains_key(*id))
291                                .cloned()
292                                .collect();
293                            for member in &present_members {
294                                numbers.insert(member.clone(), current_number);
295                            }
296                            if present_members.len() > 1 {
297                                run.compound_groups.insert(current_number, present_members);
298                            }
299                        } else {
300                            numbers.insert(ref_id.clone(), current_number);
301                        }
302                        current_number += 1;
303                    }
304                } else if !numbers.contains_key(ref_id) {
305                    numbers.insert(ref_id.clone(), current_number);
306                    current_number += 1;
307                }
308            }
309        } else {
310            for (index, ref_id) in ordered_ids.into_iter().enumerate() {
311                numbers.insert(ref_id, index + 1);
312            }
313        }
314    }
315
316    /// Begin a new render run.
317    ///
318    /// Allocates fresh per-run state (citation numbers, cite-order tracking,
319    /// dynamic compound groups, first-note tracking) and performs numeric
320    /// citation-number pre-initialization from the processor's immutable
321    /// bibliography/style data. Registration methods (`&self, &mut RunState`)
322    /// populate the returned `RunState` in citation-processing order; call
323    /// [`RunState::finalize`] before rendering. See
324    /// `docs/specs/EXPLICIT_RENDER_RUN_STATE.md`.
325    #[must_use]
326    pub fn begin_run(&self) -> RunState {
327        let mut run = RunState::default();
328        self.initialize_numeric_citation_numbers(&mut run);
329        self.initialize_numeric_bibliography_numbers(&mut run);
330        run
331    }
332
333    /// Create a new processor with default English locale (en-US).
334    #[must_use]
335    pub fn new(style: Style, bibliography: Bibliography) -> Self {
336        Self::with_compound_sets(style, bibliography, IndexMap::new())
337    }
338
339    /// Create a new processor with explicit compound sets, returning an error for invalid sets.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error when any compound set references unknown bibliography
344    /// entries or reuses the same member more than once.
345    pub fn try_with_compound_sets(
346        style: Style,
347        bibliography: Bibliography,
348        compound_sets: IndexMap<String, Vec<String>>,
349    ) -> Result<Self, ProcessorError> {
350        Self::try_with_locale_and_compound_sets(style, bibliography, Locale::en_us(), compound_sets)
351    }
352
353    /// Create a new processor with explicit compound sets.
354    ///
355    /// If `compound_sets` is invalid, this constructor ignores the supplied sets
356    /// and falls back to a processor without compound sets.
357    #[must_use]
358    pub fn with_compound_sets(
359        style: Style,
360        bibliography: Bibliography,
361        compound_sets: IndexMap<String, Vec<String>>,
362    ) -> Self {
363        let validated_sets = Self::validate_compound_sets_or_default(&bibliography, compound_sets);
364        Self::build_processor(style, bibliography, Locale::en_us(), validated_sets)
365    }
366
367    /// Create a new processor with a specified locale.
368    ///
369    /// The locale determines term translations and locale-specific formatting behavior.
370    #[must_use]
371    pub fn with_locale(style: Style, bibliography: Bibliography, locale: Locale) -> Self {
372        Self::with_locale_and_compound_sets(style, bibliography, locale, IndexMap::new())
373    }
374
375    /// Create a new processor with explicit locale and compound sets, returning
376    /// an error for invalid sets.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error when any compound set references unknown bibliography
381    /// entries or reuses the same member more than once.
382    pub fn try_with_locale_and_compound_sets(
383        style: Style,
384        bibliography: Bibliography,
385        locale: Locale,
386        compound_sets: IndexMap<String, Vec<String>>,
387    ) -> Result<Self, ProcessorError> {
388        let validated_sets = Self::try_validate_compound_sets(&bibliography, compound_sets)?;
389        Ok(Self::build_processor(
390            style,
391            bibliography,
392            locale,
393            validated_sets,
394        ))
395    }
396
397    /// Create a new processor with a specified locale and explicit compound sets.
398    ///
399    /// The locale determines term translations and locale-specific formatting behavior.
400    ///
401    /// If `compound_sets` is invalid, this constructor ignores the supplied sets
402    /// and falls back to a processor without compound sets.
403    #[must_use]
404    pub fn with_locale_and_compound_sets(
405        style: Style,
406        bibliography: Bibliography,
407        locale: Locale,
408        compound_sets: IndexMap<String, Vec<String>>,
409    ) -> Self {
410        let validated_sets = Self::validate_compound_sets_or_default(&bibliography, compound_sets);
411        Self::build_processor(style, bibliography, locale, validated_sets)
412    }
413
414    /// Create a new processor, loading the locale from disk.
415    ///
416    /// Loads the locale specified in the style's `default_locale` field from the given directory,
417    /// falling back to en-US if not found or not specified.
418    #[must_use]
419    pub fn with_style_locale(
420        style: Style,
421        bibliography: Bibliography,
422        locales_dir: &std::path::Path,
423    ) -> Self {
424        let style = style.into_resolved();
425        let locale = if let Some(ref locale_id) = style.info.default_locale {
426            Locale::load(locale_id, locales_dir)
427        } else {
428            Locale::en_us()
429        };
430        Self::with_locale_and_compound_sets(style, bibliography, locale, IndexMap::new())
431    }
432
433    /// Return a copy of the processor that injects source template indices into semantic HTML.
434    #[must_use]
435    pub fn with_inject_ast_indices(mut self, inject_ast_indices: bool) -> Self {
436        self.inject_ast_indices = inject_ast_indices;
437        self
438    }
439
440    /// Enable or disable source template index injection for semantic HTML output.
441    pub fn set_inject_ast_indices(&mut self, inject_ast_indices: bool) {
442        self.inject_ast_indices = inject_ast_indices;
443    }
444
445    /// Return the global style configuration.
446    pub fn get_config(&self) -> &Config {
447        self.style.options.as_ref().unwrap_or(&self.default_config)
448    }
449
450    /// Return merged config for citation rendering.
451    ///
452    /// Combines global style options with citation-specific overrides.
453    pub fn get_citation_config(&self) -> std::borrow::Cow<'_, Config> {
454        let base = self.get_config();
455        match self
456            .style
457            .citation
458            .as_ref()
459            .and_then(|citation| citation.options.as_ref())
460        {
461            Some(citation_options) => std::borrow::Cow::Owned(citation_options.merged_with(base)),
462            None => std::borrow::Cow::Borrowed(base),
463        }
464    }
465
466    /// Return merged shared config for bibliography rendering.
467    ///
468    /// Combines global shared style options with bibliography-local shared overrides.
469    pub fn get_bibliography_config(&self) -> std::borrow::Cow<'_, Config> {
470        let base = self.get_config();
471        match self
472            .style
473            .bibliography
474            .as_ref()
475            .and_then(|bibliography| bibliography.options.as_ref())
476        {
477            Some(bibliography_options) => {
478                std::borrow::Cow::Owned(bibliography_options.merged_with(base))
479            }
480            None => std::borrow::Cow::Borrowed(base),
481        }
482    }
483
484    /// Return effective bibliography-only configuration.
485    pub fn get_bibliography_options(&self) -> std::borrow::Cow<'_, BibliographyConfig> {
486        match self
487            .style
488            .bibliography
489            .as_ref()
490            .and_then(|bibliography| bibliography.options.as_ref())
491        {
492            Some(bibliography_options) => {
493                std::borrow::Cow::Owned(bibliography_options.to_bibliography_config())
494            }
495            None => std::borrow::Cow::Owned(BibliographyConfig::default()),
496        }
497    }
498
499    /// Sort references according to the style's bibliography sort specification.
500    ///
501    /// Uses style-specified sort keys (author, title, issued, etc.) and sort order.
502    pub fn sort_references<'a>(&self, references: Vec<&'a Reference>) -> Vec<&'a Reference> {
503        let bibliography_config = self.get_bibliography_config();
504        let mut sorted_refs = match self.resolved_bibliography_sort() {
505            Some((sort_spec, true)) => {
506                let sorter = crate::sorting::ReferenceSorter::with_bibliography_config(
507                    &self.locale,
508                    &bibliography_config,
509                );
510                sorter.sort_references_with_id_tiebreak(references, &sort_spec)
511            }
512            Some((sort_spec, false)) => {
513                let sorter = crate::sorting::ReferenceSorter::with_bibliography_config(
514                    &self.locale,
515                    &bibliography_config,
516                );
517                sorter.sort_references(references, &sort_spec)
518            }
519            None => references,
520        };
521
522        let bibliography_options = self.get_bibliography_options();
523        if let Some(partitioning) =
524            effective_sort_partitioning(&bibliography_options, &bibliography_config).as_ref()
525            && crate::sort_partitioning::should_sort_flat(partitioning)
526        {
527            crate::sort_partitioning::sort_by_partition(
528                sorted_refs.as_mut_slice(),
529                &self.locale,
530                partitioning,
531            );
532        }
533
534        sorted_refs
535    }
536
537    /// Sort citation items according to the style's citation sort specification.
538    pub fn sort_citation_items(
539        &self,
540        items: Vec<CitationItem>,
541        spec: &citum_schema::CitationSpec,
542    ) -> Vec<CitationItem> {
543        if let Some(sort_spec) = &spec.sort {
544            let mut items_with_refs: Vec<(CitationItem, Option<&Reference>)> = items
545                .into_iter()
546                .map(|item| {
547                    let reference = self.bibliography.get(&item.id);
548                    (item, reference)
549                })
550                .collect();
551
552            let resolved_sort = sort_spec.resolve();
553            let sorter = crate::sorting::ReferenceSorter::new(&self.locale);
554            items_with_refs.sort_by(|left, right| match (left.1, right.1) {
555                (Some(left_reference), Some(right_reference)) => {
556                    for sort_key in &resolved_sort.template {
557                        let cmp = sorter.compare_by_key(left_reference, right_reference, sort_key);
558                        if cmp != std::cmp::Ordering::Equal {
559                            return cmp;
560                        }
561                    }
562                    std::cmp::Ordering::Equal
563                }
564                (Some(_), None) => std::cmp::Ordering::Less,
565                (None, Some(_)) => std::cmp::Ordering::Greater,
566                (None, None) => std::cmp::Ordering::Equal,
567            });
568
569            return items_with_refs
570                .into_iter()
571                .map(|(item, _reference)| item)
572                .collect();
573        }
574
575        items
576    }
577
578    /// Calculate disambiguation hints needed for the style.
579    ///
580    /// Analyzes the bibliography to determine which items need disambiguation
581    /// (year suffixes, etc.) and calculates hints for efficient rendering.
582    pub fn calculate_hints(&self) -> HashMap<String, ProcHints> {
583        let citation_config = self.get_citation_config();
584        let config = citation_config.as_ref();
585        let bibliography_sort = self.resolved_bibliography_sort();
586
587        let disambiguator = if let Some((resolved_sort, _id_tiebreak)) = &bibliography_sort {
588            Disambiguator::with_group_sort(&self.bibliography, config, &self.locale, resolved_sort)
589        } else {
590            Disambiguator::new(&self.bibliography, config, &self.locale)
591        };
592
593        disambiguator.calculate_hints()
594    }
595}
596
597fn effective_sort_partitioning(
598    bibliography_options: &BibliographyConfig,
599    bibliography_config: &Config,
600) -> Option<BibliographySortPartitioning> {
601    if let Some(partitioning) = &bibliography_options.sort_partitioning {
602        return Some(partitioning.clone());
603    }
604
605    bibliography_config
606        .sorting
607        .as_ref()
608        .is_some_and(|sorting| {
609            sorting.effective_multilingual() == SortingMultilingualMode::PerScript
610        })
611        .then(|| BibliographySortPartitioning {
612            by: BibliographyPartitionKind::Script,
613            mode: BibliographyPartitionMode::SortOnly,
614            order: Vec::new(),
615            headings: HashMap::new(),
616            unknown_fields: Default::default(),
617        })
618}