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        let mut components = Vec::with_capacity(template.len());
852        let mut component_options = options.clone();
853        for (template_index, component) in template.iter().enumerate() {
854            component_options.current_template_index =
855                self.inject_ast_indices.then_some(template_index);
856            let ctx = TemplateRenderContext {
857                reference,
858                ref_type,
859                options: &component_options,
860                hint,
861                template_index,
862            };
863            if let Some(component) =
864                self.render_template_component_with_format::<F>(&ctx, component, &mut tracker)
865            {
866                components.push(component);
867            }
868        }
869        components
870    }
871
872    fn build_template_render_hint(
873        &self,
874        reference: &Reference,
875        context: RenderContext,
876        citation_number: usize,
877        position: Option<citum_schema::citation::Position>,
878        integral_name_state: Option<citum_schema::citation::IntegralNameState>,
879    ) -> ProcHints {
880        let default_hint = ProcHints::default();
881        let base_hint = self
882            .hints
883            .get(reference.id().as_deref().unwrap_or_default())
884            .unwrap_or(&default_hint);
885        ProcHints {
886            citation_number: (citation_number > 0).then_some(citation_number),
887            citation_sub_label: if context == RenderContext::Citation {
888                reference
889                    .id()
890                    .as_deref()
891                    .and_then(|id| self.citation_sub_label_for_ref(id))
892            } else {
893                None
894            },
895            position,
896            integral_name_state,
897            ..base_hint.clone()
898        }
899    }
900
901    fn render_template_component_with_format<F>(
902        &self,
903        ctx: &TemplateRenderContext<'_>,
904        component: &TemplateComponent,
905        tracker: &mut TemplateComponentTracker,
906    ) -> Option<ProcTemplateComponent>
907    where
908        F: crate::render::format::OutputFormat<Output = String>,
909    {
910        if let TemplateComponent::Group(group) = component {
911            return self.render_group_component_with_format::<F>(ctx, group, tracker);
912        }
913
914        let resolved_component = component;
915        let var_key = get_variable_key(resolved_component);
916        if tracker.should_skip(var_key.as_deref()) {
917            return None;
918        }
919
920        let mut values = resolved_component.values::<F>(ctx.reference, ctx.hint, ctx.options)?;
921        // Suppress affixes when a component resolves to no meaningful content.
922        // A whitespace-only value carries no data, so its prefix/suffix must
923        // not leak into output (e.g. a ". In " prefix on an empty editor list).
924        if values.value.trim().is_empty() {
925            return None;
926        }
927        self.apply_issued_no_date_fallback(
928            ctx.reference,
929            ctx.options,
930            resolved_component,
931            &mut values,
932        );
933        self.apply_entry_link_fallback(ctx.reference, ctx.options, &mut values);
934
935        let item_language =
936            crate::values::effective_component_language(ctx.reference, resolved_component);
937        tracker.mark_rendered(var_key, values.substituted_key.as_deref());
938
939        Some(ProcTemplateComponent {
940            template_component: resolved_component.clone(),
941            template_index: self.inject_ast_indices.then_some(ctx.template_index),
942            value: values.value,
943            prefix: values.prefix,
944            suffix: values.suffix,
945            url: values.url,
946            ref_type: Some(ctx.ref_type.to_string()),
947            config: Some(ctx.options.config.clone()),
948            bibliography_config: ctx.options.bibliography_config.clone(),
949            item_language,
950            sentence_initial: false,
951            pre_formatted: values.pre_formatted,
952        })
953    }
954
955    fn render_group_component_with_format<F>(
956        &self,
957        ctx: &TemplateRenderContext<'_>,
958        group: &citum_schema::template::TemplateGroup,
959        tracker: &mut TemplateComponentTracker,
960    ) -> Option<ProcTemplateComponent>
961    where
962        F: crate::render::format::OutputFormat<Output = String>,
963    {
964        let fmt = F::default();
965        let values = self.render_group_child_values(&fmt, ctx, group, tracker)?;
966        let delimiter = group
967            .delimiter
968            .as_ref()
969            .unwrap_or(&citum_schema::template::DelimiterPunctuation::Comma)
970            .to_string_with_space();
971        let group_component = TemplateComponent::Group(group.clone());
972        Some(ProcTemplateComponent {
973            template_component: group_component.clone(),
974            template_index: self.inject_ast_indices.then_some(ctx.template_index),
975            value: fmt.join(values, &delimiter),
976            prefix: None,
977            suffix: None,
978            url: None,
979            ref_type: Some(ctx.ref_type.to_string()),
980            config: Some(ctx.options.config.clone()),
981            bibliography_config: ctx.options.bibliography_config.clone(),
982            item_language: crate::values::effective_component_language(
983                ctx.reference,
984                &group_component,
985            ),
986            sentence_initial: false,
987            pre_formatted: true,
988        })
989    }
990
991    /// Render the children of a template group into rendered strings, dropping
992    /// empty values. Returns `None` when no child carries meaningful content
993    /// (i.e. only term-only siblings produced output). Borrows the parent
994    /// `fmt` so a stateful `OutputFormat` sees a single instance for both
995    /// child rendering and the final `join` in the caller.
996    fn render_group_child_values<F>(
997        &self,
998        fmt: &F,
999        ctx: &TemplateRenderContext<'_>,
1000        group: &citum_schema::template::TemplateGroup,
1001        tracker: &mut TemplateComponentTracker,
1002    ) -> Option<Vec<String>>
1003    where
1004        F: crate::render::format::OutputFormat<Output = String>,
1005    {
1006        let mut has_meaningful_content = false;
1007        let mut values = Vec::with_capacity(group.group.len());
1008
1009        for item in &group.group {
1010            let Some(rendered) =
1011                self.render_template_component_with_format::<F>(ctx, item, tracker)
1012            else {
1013                continue;
1014            };
1015            let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1016                &rendered,
1017                fmt,
1018                ctx.options.show_semantics,
1019            );
1020            if rendered_str.trim().is_empty() {
1021                continue;
1022            }
1023            if !is_term_only_component(item) {
1024                has_meaningful_content = true;
1025            }
1026            values.push(rendered_str);
1027        }
1028
1029        (has_meaningful_content && !values.is_empty()).then_some(values)
1030    }
1031
1032    fn apply_issued_no_date_fallback(
1033        &self,
1034        reference: &Reference,
1035        options: &RenderOptions<'_>,
1036        component: &TemplateComponent,
1037        values: &mut crate::values::ProcValues<String>,
1038    ) {
1039        if !matches!(
1040            component,
1041            TemplateComponent::Date(citum_schema::template::TemplateDate {
1042                date: citum_schema::template::DateVariable::Issued,
1043                ..
1044            })
1045        ) || reference.csl_issued_date().is_some()
1046            || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1047        {
1048            return;
1049        }
1050
1051        if let Some(long) = options.locale.resolved_general_term(
1052            &citum_schema::locale::GeneralTerm::NoDate,
1053            &citum_schema::locale::TermForm::Long,
1054            None,
1055        ) {
1056            values.value = long;
1057        }
1058    }
1059
1060    fn apply_entry_link_fallback(
1061        &self,
1062        reference: &Reference,
1063        options: &RenderOptions<'_>,
1064        values: &mut crate::values::ProcValues<String>,
1065    ) {
1066        if values.url.is_some() {
1067            return;
1068        }
1069
1070        let Some(links) = &options.config.links else {
1071            return;
1072        };
1073        use citum_schema::options::LinkAnchor;
1074        if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1075            values.url = crate::values::resolve_url(links, reference);
1076        }
1077    }
1078
1079    /// Apply the substitution string to the primary contributor component.
1080    pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1081        self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1082            proc, substitute,
1083        );
1084    }
1085
1086    /// Apply the substitution string to the primary contributor component with specific format.
1087    pub fn apply_author_substitution_with_format<F>(
1088        &self,
1089        proc: &mut ProcTemplate,
1090        substitute: &str,
1091    ) where
1092        F: crate::render::format::OutputFormat<Output = String>,
1093    {
1094        if let Some(component) = proc
1095            .iter_mut()
1096            .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1097        {
1098            let fmt = F::default();
1099            component.value = fmt.text(substitute);
1100        }
1101    }
1102
1103    fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1104        match self
1105            .style
1106            .info
1107            .source
1108            .as_ref()
1109            .map(|source| source.csl_id.as_str())
1110        {
1111            Some("http://www.zotero.org/styles/harvard-cite-them-right") => {
1112                citum_schema::locale::TermForm::Long
1113            }
1114            _ => citum_schema::locale::TermForm::Short,
1115        }
1116    }
1117
1118    fn render_group_item_from_template_with_format<F>(
1119        &self,
1120        reference: &Reference,
1121        item_request: GroupItemRenderRequest<'_>,
1122    ) -> Option<String>
1123    where
1124        F: crate::render::format::OutputFormat<Output = String>,
1125    {
1126        let request = self.citation_render_request(
1127            item_request.item,
1128            item_request.template,
1129            item_request.mode,
1130            item_request.suppress_author,
1131            item_request.position,
1132            item_request.note_start_text_case,
1133        );
1134        self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1135    }
1136}
1137
1138pub(super) fn filter_author_from_template(
1139    template: &[TemplateComponent],
1140) -> (Vec<TemplateComponent>, Option<String>) {
1141    let mut filtered: Vec<TemplateComponent> =
1142        template.iter().filter_map(strip_author_component).collect();
1143    let leading_affix = filtered.first().and_then(leading_group_affix);
1144    if let Some(first) = filtered.first_mut() {
1145        strip_leading_group_affixes(first);
1146    }
1147    (filtered, leading_affix)
1148}