Skip to main content

citum_engine/processor/rendering/grouped/
core.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6use super::super::{
7    GroupRenderParams, Renderer, TemplateComponentTracker, TemplateRenderParams,
8    TemplateRenderRequest, find_grouping_component, get_variable_key, has_contributor_component,
9    leading_group_affix, strip_author_component, strip_leading_group_affixes,
10};
11use super::component_predicates::{is_term_only_component, resolve_type_variant};
12use super::group_citation_items_by_author;
13use crate::error::ProcessorError;
14use crate::reference::Reference;
15use crate::render::{ProcTemplate, ProcTemplateComponent};
16use crate::values::{ComponentValues, ProcHints, RenderContext, RenderOptions};
17use citum_schema::template::TemplateComponent;
18use std::borrow::Cow;
19
20struct GroupRenderState<'a> {
21    first_item: &'a crate::reference::CitationItem,
22    first_ref: &'a Reference,
23    template: Cow<'a, [TemplateComponent]>,
24}
25
26struct ItemRenderState<'a> {
27    item: &'a crate::reference::CitationItem,
28    reference: &'a Reference,
29    template: Cow<'a, [TemplateComponent]>,
30}
31
32struct GroupItemRenderRequest<'a> {
33    item: &'a crate::reference::CitationItem,
34    template: &'a [TemplateComponent],
35    mode: &'a citum_schema::citation::CitationMode,
36    suppress_author: bool,
37    position: Option<&'a citum_schema::citation::Position>,
38    note_start_text_case: Option<citum_schema::NoteStartTextCase>,
39    delimiter: &'a str,
40}
41
42/// Resolved context for rendering a single template (or nested group)
43/// component. Bundles parameters that would otherwise inflate
44/// [`Renderer::render_template_component_with_format`] and
45/// [`Renderer::render_group_component_with_format`] past the clippy
46/// argument-count limit.
47struct TemplateRenderContext<'a> {
48    reference: &'a Reference,
49    ref_type: &'a str,
50    options: &'a RenderOptions<'a>,
51    hint: &'a ProcHints,
52    template_index: usize,
53}
54
55impl Renderer<'_> {
56    fn strip_redundant_leading_group_punctuation<'a>(
57        &self,
58        value: &'a str,
59        delimiter: &str,
60    ) -> &'a str {
61        let Some(delimiter_char) = delimiter.chars().find(|ch| !ch.is_whitespace()) else {
62            return value;
63        };
64
65        let trimmed = value.trim_start();
66        if !trimmed.starts_with(delimiter_char) {
67            return value;
68        }
69
70        #[allow(clippy::string_slice, reason = "delimiter found at start")]
71        trimmed[delimiter_char.len_utf8()..].trim_start()
72    }
73
74    fn join_integral_group_item_parts(&self, item_parts: &[String], delimiter: &str) -> String {
75        let repeated_item_delimiter = if delimiter.trim().is_empty() {
76            ", "
77        } else {
78            delimiter
79        };
80
81        let mut joined = String::new();
82        for (index, part) in item_parts.iter().enumerate() {
83            if index > 0 {
84                joined.push_str(repeated_item_delimiter);
85            }
86
87            let normalized = if index == 0 {
88                part.as_str()
89            } else {
90                self.strip_redundant_leading_group_punctuation(part, repeated_item_delimiter)
91            };
92            joined.push_str(normalized);
93        }
94
95        joined
96    }
97
98    /// Render citation items with author grouping, using plain text format.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error when a referenced item is missing or grouped rendering fails.
103    pub fn render_grouped_citation(
104        &self,
105        items: &[crate::reference::CitationItem],
106        spec: &citum_schema::CitationSpec,
107        mode: &citum_schema::citation::CitationMode,
108        intra_delimiter: &str,
109        suppress_author: bool,
110        position: Option<&citum_schema::citation::Position>,
111    ) -> Result<Vec<String>, ProcessorError> {
112        self.render_grouped_citation_with_format::<crate::render::plain::PlainText>(
113            items,
114            &GroupRenderParams {
115                spec,
116                mode,
117                intra_delimiter,
118                suppress_author,
119                position,
120                note_start_text_case: spec.note_start_text_case,
121            },
122        )
123    }
124
125    /// Render a group of items that must not be author-collapsed (legal cases,
126    /// personal communications). Returns the rendered citation strings.
127    fn render_special_type_items<F>(
128        &self,
129        group: &[&crate::reference::CitationItem],
130        params: &GroupRenderParams<'_>,
131    ) -> Result<Vec<String>, ProcessorError>
132    where
133        F: crate::render::format::OutputFormat<Output = String>,
134    {
135        let fmt = F::default();
136        let mut rendered_items = Vec::new();
137        for item in group {
138            let state = self.resolve_item_render_state(item, params.spec)?;
139            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
140                state.reference,
141                GroupItemRenderRequest {
142                    item: state.item,
143                    template: &state.template,
144                    mode: params.mode,
145                    suppress_author: params.suppress_author,
146                    position: params.position,
147                    note_start_text_case: params.note_start_text_case,
148                    delimiter: params.intra_delimiter,
149                },
150            ) && let Some((ids, content)) = self.build_citation_chunk(
151                &fmt,
152                vec![item.id.clone()],
153                item_str,
154                item.prefix.as_deref(),
155                item.suffix.as_deref(),
156            ) {
157                rendered_items.push(fmt.citation(ids, content));
158            }
159        }
160        Ok(rendered_items)
161    }
162
163    /// Render one citation group using the explicit integral template.
164    ///
165    /// Returns `Ok(Some(citation))` if the group rendered (caller should push and `continue`),
166    /// or `Ok(None)` if no items produced output (caller should fall through to other branches).
167    fn render_integral_explicit_group<F>(
168        &self,
169        group: &[&crate::reference::CitationItem],
170        spec: &citum_schema::CitationSpec,
171        mode: &citum_schema::citation::CitationMode,
172        suppress_author: bool,
173        position: Option<&citum_schema::citation::Position>,
174    ) -> Result<Option<String>, ProcessorError>
175    where
176        F: crate::render::format::OutputFormat<Output = String>,
177    {
178        let fmt = F::default();
179        let component_delimiter = spec.delimiter.as_deref().unwrap_or(" ");
180        let item_join_delim = spec.multi_cite_delimiter.as_deref().unwrap_or(", ");
181        let mut group_items_str = Vec::new();
182        let mut all_ids = Vec::new();
183
184        for item in group {
185            let state = self.resolve_item_render_state(item, spec)?;
186            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
187                state.reference,
188                GroupItemRenderRequest {
189                    item: state.item,
190                    template: &state.template,
191                    mode,
192                    suppress_author,
193                    position,
194                    note_start_text_case: spec.note_start_text_case,
195                    delimiter: component_delimiter,
196                },
197            ) && !item_str.is_empty()
198            {
199                group_items_str.push(self.affix_content(
200                    &fmt,
201                    item_str,
202                    item.prefix.as_deref(),
203                    item.suffix.as_deref(),
204                ));
205                all_ids.push(item.id.clone());
206            }
207        }
208
209        if group_items_str.is_empty() {
210            return Ok(None);
211        }
212
213        let combined_str = group_items_str.join(item_join_delim);
214        Ok(Some(fmt.citation(all_ids, combined_str)))
215    }
216
217    /// This preserves per-item output when grouping rules require items to stay
218    /// separate, and otherwise applies the requested renderer format to the
219    /// grouped citation output.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error when a referenced item is missing or grouped rendering
224    /// fails.
225    pub fn render_grouped_citation_with_format<F>(
226        &self,
227        items: &[crate::reference::CitationItem],
228        params: &GroupRenderParams<'_>,
229    ) -> Result<Vec<String>, ProcessorError>
230    where
231        F: crate::render::format::OutputFormat<Output = String>,
232    {
233        let groups = group_citation_items_by_author(self, items);
234        let mut rendered_groups = Vec::new();
235        for (_author_key, group) in groups {
236            rendered_groups
237                .extend(self.render_grouped_citation_group_with_format::<F>(&group, params)?);
238        }
239
240        Ok(rendered_groups)
241    }
242
243    fn render_grouped_citation_group_with_format<F>(
244        &self,
245        group: &[&crate::reference::CitationItem],
246        params: &GroupRenderParams<'_>,
247    ) -> Result<Vec<String>, ProcessorError>
248    where
249        F: crate::render::format::OutputFormat<Output = String>,
250    {
251        let state = self.resolve_group_render_state(group, params.spec)?;
252
253        if let Some(citation) = self.try_render_integral_group_with_format::<F>(
254            group,
255            params.spec,
256            params.mode,
257            params.suppress_author,
258            params.position,
259        )? {
260            return Ok(vec![citation]);
261        }
262
263        if self.requires_full_group_item_rendering(params.mode, state.first_ref) {
264            return self.render_special_type_items::<F>(group, params);
265        }
266
267        Ok(self
268            .render_fallback_grouped_citation_with_format::<F>(
269                group,
270                state.first_ref,
271                state.first_item,
272                &state.template,
273                params,
274            )?
275            .into_iter()
276            .collect())
277    }
278
279    fn render_fallback_grouped_citation_with_format<F>(
280        &self,
281        group: &[&crate::reference::CitationItem],
282        first_ref: &Reference,
283        first_item: &crate::reference::CitationItem,
284        template: &[TemplateComponent],
285        params: &GroupRenderParams<'_>,
286    ) -> Result<Option<String>, ProcessorError>
287    where
288        F: crate::render::format::OutputFormat<Output = String>,
289    {
290        let fmt = F::default();
291        let author_part = self.render_author_for_grouping_with_format::<F>(
292            first_ref,
293            first_item,
294            template,
295            params.mode,
296            params.suppress_author,
297            params.position,
298        );
299        let (item_parts, group_delimiter) =
300            self.render_group_item_parts_with_format::<F>(&fmt, group, params)?;
301        let Some(content) = self.build_grouped_citation_content(
302            &author_part,
303            &item_parts,
304            params,
305            group_delimiter.as_deref(),
306        ) else {
307            return Ok(None);
308        };
309        let group_ids = group.iter().map(|item| item.id.clone()).collect();
310        let prefix = first_item.prefix.as_deref().unwrap_or("");
311        // Suffix is embedded in item_parts by render_group_item_parts_with_format when
312        // item_parts is non-empty. Apply it here only when item_parts was empty (author-only output).
313        let suffix = if item_parts.is_empty() {
314            first_item.suffix.as_deref()
315        } else {
316            None
317        };
318
319        Ok(Some(fmt.citation(
320            group_ids,
321            self.affix_content(&fmt, content, Some(prefix), suffix),
322        )))
323    }
324
325    fn build_grouped_citation_content(
326        &self,
327        author_part: &str,
328        item_parts: &[String],
329        params: &GroupRenderParams<'_>,
330        group_delimiter: Option<&str>,
331    ) -> Option<String> {
332        if !author_part.is_empty() && !item_parts.is_empty() {
333            let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
334            let joined_items = match params.mode {
335                citum_schema::citation::CitationMode::Integral => {
336                    self.join_integral_group_item_parts(item_parts, author_item_delimiter)
337                }
338                citum_schema::citation::CitationMode::NonIntegral => {
339                    let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
340                        ", "
341                    } else {
342                        author_item_delimiter
343                    };
344                    item_parts.join(repeated_item_delimiter)
345                }
346            };
347            return Some(match params.mode {
348                citum_schema::citation::CitationMode::Integral => self
349                    .format_integral_grouped_items(
350                        author_part,
351                        &joined_items,
352                        params.suppress_author,
353                    ),
354                citum_schema::citation::CitationMode::NonIntegral => self
355                    .format_non_integral_grouped_items(
356                        author_part,
357                        author_item_delimiter,
358                        &joined_items,
359                        params.suppress_author,
360                    ),
361            });
362        }
363
364        if !author_part.is_empty() {
365            return Some(author_part.to_string());
366        }
367
368        if !item_parts.is_empty() {
369            return Some(item_parts.join(params.intra_delimiter));
370        }
371
372        None
373    }
374
375    fn format_integral_grouped_items(
376        &self,
377        author_part: &str,
378        joined_items: &str,
379        suppress_author: bool,
380    ) -> String {
381        if suppress_author {
382            format!("({joined_items})")
383        } else {
384            format!("{author_part} ({joined_items})")
385        }
386    }
387
388    fn format_non_integral_grouped_items(
389        &self,
390        author_part: &str,
391        author_item_delimiter: &str,
392        joined_items: &str,
393        suppress_author: bool,
394    ) -> String {
395        if suppress_author {
396            return joined_items.to_string();
397        }
398
399        if let Some(adjusted) =
400            self.adjust_grouped_author_quote_punctuation(author_part, author_item_delimiter)
401        {
402            return format!("{adjusted}{joined_items}");
403        }
404
405        format!("{author_part}{author_item_delimiter}{joined_items}")
406    }
407
408    fn adjust_grouped_author_quote_punctuation(
409        &self,
410        author_part: &str,
411        author_item_delimiter: &str,
412    ) -> Option<String> {
413        if !self.config.punctuation_in_quote
414            || !author_item_delimiter.starts_with(',')
415            || !(author_part.ends_with('"') || author_part.ends_with('\u{201D}'))
416        {
417            return None;
418        }
419
420        let is_curly = author_part.ends_with('\u{201D}');
421        let quote_char = if is_curly { '\u{201D}' } else { '"' };
422        #[allow(clippy::string_slice, reason = "quote found at end")]
423        let trimmed = &author_part[..author_part.len() - quote_char.len_utf8()];
424        #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
425        Some(format!(
426            "{trimmed},{quote_char}{}",
427            &author_item_delimiter[1..]
428        ))
429    }
430
431    fn render_group_item_parts_with_format<F>(
432        &self,
433        fmt: &F,
434        group: &[&crate::reference::CitationItem],
435        params: &GroupRenderParams<'_>,
436    ) -> Result<(Vec<String>, Option<String>), ProcessorError>
437    where
438        F: crate::render::format::OutputFormat<Output = String>,
439    {
440        let mut item_parts = Vec::new();
441        let mut group_delimiter: Option<String> = None;
442        for (index, item) in group.iter().enumerate() {
443            let state = self.resolve_item_render_state(item, params.spec)?;
444            let (filtered_template, leading_affix) = filter_author_from_template(&state.template);
445            if group_delimiter.is_none() {
446                group_delimiter = leading_affix
447                    .as_ref()
448                    .filter(|value| !value.is_empty())
449                    .cloned();
450            }
451            let item_delimiter = if leading_affix.is_some() {
452                ""
453            } else {
454                params.intra_delimiter
455            };
456            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
457                state.reference,
458                GroupItemRenderRequest {
459                    item: state.item,
460                    template: &filtered_template,
461                    mode: params.mode,
462                    suppress_author: params.suppress_author,
463                    position: params.position,
464                    note_start_text_case: params.note_start_text_case,
465                    delimiter: item_delimiter,
466                },
467            ) && !item_str.is_empty()
468            {
469                let prefix = (index > 0).then_some(item.prefix.as_deref()).flatten();
470                item_parts.push(self.affix_content(fmt, item_str, prefix, item.suffix.as_deref()));
471            }
472        }
473        Ok((item_parts, group_delimiter))
474    }
475
476    fn resolve_group_render_state<'b>(
477        &'b self,
478        group: &'b [&'b crate::reference::CitationItem],
479        spec: &'b citum_schema::CitationSpec,
480    ) -> Result<GroupRenderState<'b>, ProcessorError> {
481        #[allow(clippy::indexing_slicing, reason = "groups are non-empty")]
482        let first_item = group[0];
483        let first_ref = self
484            .bibliography
485            .get(&first_item.id)
486            .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
487        let first_language = crate::values::effective_item_language(first_ref);
488        let default_template = spec
489            .resolve_template_for_language(first_language.as_deref())
490            .map(Cow::Owned);
491
492        let ref_type = first_ref.ref_type();
493        let first_template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
494            .map(Cow::Borrowed)
495            .or(default_template);
496
497        Ok(GroupRenderState {
498            first_item,
499            first_ref,
500            template: first_template.unwrap_or(Cow::Borrowed(&[])),
501        })
502    }
503
504    fn resolve_item_render_state<'b>(
505        &'b self,
506        item: &'b crate::reference::CitationItem,
507        spec: &'b citum_schema::CitationSpec,
508    ) -> Result<ItemRenderState<'b>, ProcessorError> {
509        let reference = self
510            .bibliography
511            .get(&item.id)
512            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
513        let item_language = crate::values::effective_item_language(reference);
514        let default_template = spec
515            .resolve_template_for_language(item_language.as_deref())
516            .map(Cow::Owned);
517
518        let ref_type = reference.ref_type();
519        let item_template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
520            .map(Cow::Borrowed)
521            .or(default_template);
522
523        Ok(ItemRenderState {
524            item,
525            reference,
526            template: item_template.unwrap_or(Cow::Borrowed(&[])),
527        })
528    }
529
530    fn try_render_integral_group_with_format<F>(
531        &self,
532        group: &[&crate::reference::CitationItem],
533        spec: &citum_schema::CitationSpec,
534        mode: &citum_schema::citation::CitationMode,
535        suppress_author: bool,
536        position: Option<&citum_schema::citation::Position>,
537    ) -> Result<Option<String>, ProcessorError>
538    where
539        F: crate::render::format::OutputFormat<Output = String>,
540    {
541        if !matches!(mode, citum_schema::citation::CitationMode::Integral)
542            || !self.has_explicit_integral_template()
543        {
544            return Ok(None);
545        }
546
547        self.render_integral_explicit_group::<F>(group, spec, mode, suppress_author, position)
548    }
549
550    /// Returns true for non-integral citation types that must render as a single
551    /// unit via [`render_special_type_items`] rather than the split author+items
552    /// path used for standard author-date groups.
553    ///
554    /// Title-first types (`legal-case`, `treaty`, `hearing`) need this because
555    /// their type-variant template leads with a title component, not a
556    /// contributor. The grouped path strips only `Contributor::Author`, so the
557    /// title would render twice (plain in the author slot, emph in the item
558    /// slot). `personal-communication` is included because its per-item date
559    /// and term must stay together and not be collapsed across items.
560    fn requires_full_group_item_rendering(
561        &self,
562        mode: &citum_schema::citation::CitationMode,
563        reference: &Reference,
564    ) -> bool {
565        matches!(mode, citum_schema::citation::CitationMode::NonIntegral)
566            && matches!(
567                reference.ref_type().as_str(),
568                "legal-case" | "treaty" | "hearing" | "personal-communication"
569            )
570    }
571
572    /// Render just the author part for citation grouping.
573    pub(crate) fn render_author_for_grouping_with_format<F>(
574        &self,
575        reference: &Reference,
576        item: &crate::reference::CitationItem,
577        template: &[TemplateComponent],
578        mode: &citum_schema::citation::CitationMode,
579        suppress_author: bool,
580        position: Option<&citum_schema::citation::Position>,
581    ) -> String
582    where
583        F: crate::render::format::OutputFormat<Output = String>,
584    {
585        let is_note_processing = self.config.processing.as_ref().is_some_and(|processing| {
586            matches!(processing, citum_schema::options::Processing::Note)
587        });
588        if is_note_processing
589            && matches!(
590                position,
591                Some(
592                    citum_schema::citation::Position::Ibid
593                        | citum_schema::citation::Position::IbidWithLocator
594                )
595            )
596            && !template.iter().any(has_contributor_component)
597        {
598            return String::new();
599        }
600
601        let options = self.citation_render_options(mode.clone(), suppress_author, None, None);
602
603        // Try to use the first semantically relevant component (including nested lists)
604        // so disambiguation hints and component-specific formatting are preserved.
605        // This ensures substitution, shortening, and mode-dependent conjunctions are respected.
606        if let Some(comp) = template.first().and_then(find_grouping_component) {
607            let base_hints = self
608                .hints
609                .get(reference.id().as_deref().unwrap_or_default())
610                .cloned()
611                .unwrap_or_default();
612            // Inject citation position so subsequent et-al thresholds are applied.
613            let hints = ProcHints {
614                position: position.cloned(),
615                integral_name_state: item.integral_name_state,
616                ..base_hints
617            };
618            if let Some(vals) = comp.values::<F>(reference, &hints, &options)
619                && !vals.value.is_empty()
620            {
621                return vals.value;
622            }
623        }
624
625        // Fallback for cases where first component isn't suitable or returned empty
626        if let Some(authors) = reference.author() {
627            let names_vec = self.resolve_contributor_names(&authors);
628            F::default().text(&crate::values::format_contributors_short(
629                &names_vec, &options,
630            ))
631        } else {
632            String::new()
633        }
634    }
635
636    /// Render the prose anchor for an integral citation without any trailing note text.
637    pub(crate) fn render_integral_anchor_with_format<F>(
638        &self,
639        items: &[crate::reference::CitationItem],
640        spec: &citum_schema::CitationSpec,
641        inter_delimiter: &str,
642        suppress_author: bool,
643        position: Option<&citum_schema::citation::Position>,
644    ) -> Result<String, ProcessorError>
645    where
646        F: crate::render::format::OutputFormat<Output = String>,
647    {
648        let groups = group_citation_items_by_author(self, items);
649
650        let mut rendered_groups = Vec::new();
651        let fmt = F::default();
652        for (_author_key, group) in groups {
653            #[allow(
654                clippy::indexing_slicing,
655                reason = "group is non-empty by construction"
656            )]
657            let first_item = group[0];
658            let reference = self
659                .bibliography
660                .get(&first_item.id)
661                .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
662            let item_language = crate::values::effective_item_language(reference);
663            let template = spec.resolve_template_for_language(item_language.as_deref());
664            let effective_template = template.as_deref().unwrap_or(&[]);
665            let author_part = self.render_author_for_grouping_with_format::<F>(
666                reference,
667                first_item,
668                effective_template,
669                &citum_schema::citation::CitationMode::Integral,
670                suppress_author,
671                position,
672            );
673            if !author_part.is_empty() {
674                rendered_groups.push(author_part);
675            }
676        }
677
678        Ok(fmt.join(rendered_groups, inter_delimiter))
679    }
680
681    /// Get the citation number for a reference, assigning one if not yet cited.
682    #[must_use]
683    pub fn get_or_assign_citation_number(&self, ref_id: &str) -> usize {
684        let mut numbers = self.citation_numbers.borrow_mut();
685        let next_num = numbers.len() + 1;
686        *numbers.entry(ref_id.to_string()).or_insert(next_num)
687    }
688
689    /// Process a bibliography entry.
690    #[must_use]
691    pub fn process_bibliography_entry(
692        &self,
693        reference: &Reference,
694        entry_number: usize,
695    ) -> Option<ProcTemplate> {
696        self.process_bibliography_entry_with_format::<crate::render::plain::PlainText>(
697            reference,
698            entry_number,
699        )
700    }
701
702    /// Process a bibliography entry with specific format.
703    #[must_use]
704    pub fn process_bibliography_entry_with_format<F>(
705        &self,
706        reference: &Reference,
707        entry_number: usize,
708    ) -> Option<ProcTemplate>
709    where
710        F: crate::render::format::OutputFormat<Output = String>,
711    {
712        let bib_spec = self.style.bibliography.as_ref()?;
713
714        let item_language = crate::values::effective_item_language(reference);
715        let default_template = bib_spec
716            .resolve_template_for_language(item_language.as_deref())
717            .map(Cow::Owned);
718
719        let ref_type = reference.ref_type();
720        let template = resolve_type_variant(bib_spec.type_variants.as_ref(), &ref_type)
721            .map(Cow::Borrowed)
722            .or(default_template)?;
723
724        let template = self.apply_anonymous_entry_bibliography_policy(reference, template)?;
725        let template = self.apply_article_journal_bibliography_policy(reference, template);
726
727        self.process_template_request_with_format::<F>(
728            reference,
729            TemplateRenderRequest {
730                template: template.as_ref(),
731                context: RenderContext::Bibliography,
732                mode: citum_schema::citation::CitationMode::NonIntegral,
733                suppress_author: false,
734                locator_raw: None,
735                citation_number: entry_number,
736                position: None,
737                note_start_text_case: None,
738                integral_name_state: None,
739            },
740        )
741    }
742
743    /// Process a template for a reference using plain text format.
744    ///
745    /// Accepts a [`TemplateRenderParams`] bundle rather than individual arguments
746    /// to keep the call site readable and avoid argument-count lint issues.
747    #[must_use]
748    pub fn process_template_with_number(
749        &self,
750        reference: &Reference,
751        params: TemplateRenderParams<'_>,
752    ) -> Option<ProcTemplate> {
753        self.process_template_with_number_with_format::<crate::render::plain::PlainText>(
754            reference, params,
755        )
756    }
757
758    /// Process a template for a reference with a specific output format.
759    ///
760    /// Accepts a [`TemplateRenderParams`] bundle rather than individual arguments
761    /// to keep the call site readable and avoid argument-count lint issues.
762    pub fn process_template_with_number_with_format<F>(
763        &self,
764        reference: &Reference,
765        params: TemplateRenderParams<'_>,
766    ) -> Option<ProcTemplate>
767    where
768        F: crate::render::format::OutputFormat<Output = String>,
769    {
770        self.process_template_request_with_format::<F>(
771            reference,
772            TemplateRenderRequest {
773                template: params.template,
774                context: params.context,
775                mode: params.mode,
776                suppress_author: params.suppress_author,
777                locator_raw: params.locator_raw,
778                citation_number: params.citation_number,
779                position: params.position.cloned(),
780                note_start_text_case: params.note_start_text_case,
781                integral_name_state: params.integral_name_state,
782            },
783        )
784    }
785
786    /// Process a template request with a specific output format.
787    #[must_use]
788    pub fn process_template_request_with_format<F>(
789        &self,
790        reference: &Reference,
791        request: TemplateRenderRequest<'_>,
792    ) -> Option<ProcTemplate>
793    where
794        F: crate::render::format::OutputFormat<Output = String>,
795    {
796        let TemplateRenderRequest {
797            template,
798            context,
799            mode,
800            suppress_author,
801            locator_raw,
802            citation_number,
803            position,
804            note_start_text_case,
805            integral_name_state,
806        } = request;
807        let ref_type = reference.ref_type();
808        let options = RenderOptions {
809            config: self.config,
810            bibliography_config: self.bibliography_config.clone(),
811            locale: self.locale,
812            context,
813            mode,
814            suppress_author,
815            locator_raw,
816            ref_type: Some(ref_type.clone()),
817            show_semantics: self.show_semantics,
818            current_template_index: None,
819            abbreviation_map: self.abbreviation_map,
820        };
821        let hint = self.build_template_render_hint(
822            reference,
823            options.context,
824            citation_number,
825            position,
826            integral_name_state,
827        );
828        let mut components =
829            self.render_template_components::<F>(reference, &ref_type, &options, &hint, template);
830
831        self.apply_sentence_initial_context::<F>(&mut components, context, note_start_text_case);
832
833        (!components.is_empty()).then_some(components)
834    }
835
836    /// Render each top-level template component for `reference`, threading a
837    /// fresh `TemplateRenderContext` per index so the source position is
838    /// preserved in AST-injection mode.
839    fn render_template_components<F>(
840        &self,
841        reference: &Reference,
842        ref_type: &str,
843        options: &RenderOptions<'_>,
844        hint: &ProcHints,
845        template: &[TemplateComponent],
846    ) -> Vec<ProcTemplateComponent>
847    where
848        F: crate::render::format::OutputFormat<Output = String>,
849    {
850        let mut tracker = TemplateComponentTracker::default();
851        template
852            .iter()
853            .enumerate()
854            .filter_map(|(template_index, component)| {
855                let mut component_options = options.clone();
856                component_options.current_template_index =
857                    self.inject_ast_indices.then_some(template_index);
858                let ctx = TemplateRenderContext {
859                    reference,
860                    ref_type,
861                    options: &component_options,
862                    hint,
863                    template_index,
864                };
865                self.render_template_component_with_format::<F>(&ctx, component, &mut tracker)
866            })
867            .collect()
868    }
869
870    fn build_template_render_hint(
871        &self,
872        reference: &Reference,
873        context: RenderContext,
874        citation_number: usize,
875        position: Option<citum_schema::citation::Position>,
876        integral_name_state: Option<citum_schema::citation::IntegralNameState>,
877    ) -> ProcHints {
878        let default_hint = ProcHints::default();
879        let base_hint = self
880            .hints
881            .get(reference.id().as_deref().unwrap_or_default())
882            .unwrap_or(&default_hint);
883        ProcHints {
884            citation_number: (citation_number > 0).then_some(citation_number),
885            citation_sub_label: if context == RenderContext::Citation {
886                reference
887                    .id()
888                    .as_deref()
889                    .and_then(|id| self.citation_sub_label_for_ref(id))
890            } else {
891                None
892            },
893            position,
894            integral_name_state,
895            ..base_hint.clone()
896        }
897    }
898
899    fn render_template_component_with_format<F>(
900        &self,
901        ctx: &TemplateRenderContext<'_>,
902        component: &TemplateComponent,
903        tracker: &mut TemplateComponentTracker,
904    ) -> Option<ProcTemplateComponent>
905    where
906        F: crate::render::format::OutputFormat<Output = String>,
907    {
908        if let TemplateComponent::Group(group) = component {
909            return self.render_group_component_with_format::<F>(ctx, group, tracker);
910        }
911
912        let resolved_component = component;
913        let var_key = get_variable_key(resolved_component);
914        if tracker.should_skip(var_key.as_deref()) {
915            return None;
916        }
917
918        let mut values = resolved_component.values::<F>(ctx.reference, ctx.hint, ctx.options)?;
919        // Suppress affixes when a component resolves to no meaningful content.
920        // A whitespace-only value carries no data, so its prefix/suffix must
921        // not leak into output (e.g. a ". In " prefix on an empty editor list).
922        if values.value.trim().is_empty() {
923            return None;
924        }
925        self.apply_issued_no_date_fallback(
926            ctx.reference,
927            ctx.options,
928            resolved_component,
929            &mut values,
930        );
931        self.apply_entry_link_fallback(ctx.reference, ctx.options, &mut values);
932
933        let item_language =
934            crate::values::effective_component_language(ctx.reference, resolved_component);
935        tracker.mark_rendered(var_key, values.substituted_key.as_deref());
936
937        Some(ProcTemplateComponent {
938            template_component: resolved_component.clone(),
939            template_index: self.inject_ast_indices.then_some(ctx.template_index),
940            value: values.value,
941            prefix: values.prefix,
942            suffix: values.suffix,
943            url: values.url,
944            ref_type: Some(ctx.ref_type.to_string()),
945            config: Some(ctx.options.config.clone()),
946            bibliography_config: ctx.options.bibliography_config.clone(),
947            item_language,
948            sentence_initial: false,
949            pre_formatted: values.pre_formatted,
950        })
951    }
952
953    fn render_group_component_with_format<F>(
954        &self,
955        ctx: &TemplateRenderContext<'_>,
956        group: &citum_schema::template::TemplateGroup,
957        tracker: &mut TemplateComponentTracker,
958    ) -> Option<ProcTemplateComponent>
959    where
960        F: crate::render::format::OutputFormat<Output = String>,
961    {
962        let fmt = F::default();
963        let values = self.render_group_child_values(&fmt, ctx, group, tracker)?;
964        let delimiter = group
965            .delimiter
966            .as_ref()
967            .unwrap_or(&citum_schema::template::DelimiterPunctuation::Comma)
968            .to_string_with_space();
969        let group_component = TemplateComponent::Group(group.clone());
970        Some(ProcTemplateComponent {
971            template_component: group_component.clone(),
972            template_index: self.inject_ast_indices.then_some(ctx.template_index),
973            value: fmt.join(values, &delimiter),
974            prefix: None,
975            suffix: None,
976            url: None,
977            ref_type: Some(ctx.ref_type.to_string()),
978            config: Some(ctx.options.config.clone()),
979            bibliography_config: ctx.options.bibliography_config.clone(),
980            item_language: crate::values::effective_component_language(
981                ctx.reference,
982                &group_component,
983            ),
984            sentence_initial: false,
985            pre_formatted: true,
986        })
987    }
988
989    /// Render the children of a template group into rendered strings, dropping
990    /// empty values. Returns `None` when no child carries meaningful content
991    /// (i.e. only term-only siblings produced output). Borrows the parent
992    /// `fmt` so a stateful `OutputFormat` sees a single instance for both
993    /// child rendering and the final `join` in the caller.
994    fn render_group_child_values<F>(
995        &self,
996        fmt: &F,
997        ctx: &TemplateRenderContext<'_>,
998        group: &citum_schema::template::TemplateGroup,
999        tracker: &mut TemplateComponentTracker,
1000    ) -> Option<Vec<String>>
1001    where
1002        F: crate::render::format::OutputFormat<Output = String>,
1003    {
1004        let mut has_meaningful_content = false;
1005        let mut values = Vec::new();
1006
1007        for item in &group.group {
1008            let Some(rendered) =
1009                self.render_template_component_with_format::<F>(ctx, item, tracker)
1010            else {
1011                continue;
1012            };
1013            let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1014                &rendered,
1015                fmt,
1016                ctx.options.show_semantics,
1017            );
1018            if rendered_str.trim().is_empty() {
1019                continue;
1020            }
1021            if !is_term_only_component(item) {
1022                has_meaningful_content = true;
1023            }
1024            values.push(rendered_str);
1025        }
1026
1027        (has_meaningful_content && !values.is_empty()).then_some(values)
1028    }
1029
1030    fn apply_issued_no_date_fallback(
1031        &self,
1032        reference: &Reference,
1033        options: &RenderOptions<'_>,
1034        component: &TemplateComponent,
1035        values: &mut crate::values::ProcValues<String>,
1036    ) {
1037        if !matches!(
1038            component,
1039            TemplateComponent::Date(citum_schema::template::TemplateDate {
1040                date: citum_schema::template::DateVariable::Issued,
1041                ..
1042            })
1043        ) || reference.csl_issued_date().is_some()
1044            || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1045        {
1046            return;
1047        }
1048
1049        if let Some(long) = options.locale.resolved_general_term(
1050            &citum_schema::locale::GeneralTerm::NoDate,
1051            &citum_schema::locale::TermForm::Long,
1052            None,
1053        ) {
1054            values.value = long;
1055        }
1056    }
1057
1058    fn apply_entry_link_fallback(
1059        &self,
1060        reference: &Reference,
1061        options: &RenderOptions<'_>,
1062        values: &mut crate::values::ProcValues<String>,
1063    ) {
1064        if values.url.is_some() {
1065            return;
1066        }
1067
1068        let Some(links) = &options.config.links else {
1069            return;
1070        };
1071        use citum_schema::options::LinkAnchor;
1072        if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1073            values.url = crate::values::resolve_url(links, reference);
1074        }
1075    }
1076
1077    /// Apply the substitution string to the primary contributor component.
1078    pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1079        self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1080            proc, substitute,
1081        );
1082    }
1083
1084    /// Apply the substitution string to the primary contributor component with specific format.
1085    pub fn apply_author_substitution_with_format<F>(
1086        &self,
1087        proc: &mut ProcTemplate,
1088        substitute: &str,
1089    ) where
1090        F: crate::render::format::OutputFormat<Output = String>,
1091    {
1092        if let Some(component) = proc
1093            .iter_mut()
1094            .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1095        {
1096            let fmt = F::default();
1097            component.value = fmt.text(substitute);
1098        }
1099    }
1100
1101    fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1102        match self
1103            .style
1104            .info
1105            .source
1106            .as_ref()
1107            .map(|source| source.csl_id.as_str())
1108        {
1109            Some("http://www.zotero.org/styles/harvard-cite-them-right") => {
1110                citum_schema::locale::TermForm::Long
1111            }
1112            _ => citum_schema::locale::TermForm::Short,
1113        }
1114    }
1115
1116    fn render_group_item_from_template_with_format<F>(
1117        &self,
1118        reference: &Reference,
1119        item_request: GroupItemRenderRequest<'_>,
1120    ) -> Option<String>
1121    where
1122        F: crate::render::format::OutputFormat<Output = String>,
1123    {
1124        let request = self.citation_render_request(
1125            item_request.item,
1126            item_request.template,
1127            item_request.mode,
1128            item_request.suppress_author,
1129            item_request.position,
1130            item_request.note_start_text_case,
1131        );
1132        self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1133    }
1134}
1135
1136pub(super) fn filter_author_from_template(
1137    template: &[TemplateComponent],
1138) -> (Vec<TemplateComponent>, Option<String>) {
1139    let mut filtered: Vec<TemplateComponent> =
1140        template.iter().filter_map(strip_author_component).collect();
1141    let leading_affix = filtered.first().and_then(leading_group_affix);
1142    if let Some(first) = filtered.first_mut() {
1143        strip_leading_group_affixes(first);
1144    }
1145    (filtered, leading_affix)
1146}