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