1use super::super::{
7 GroupRenderParams, Renderer, TemplateComponentTracker, TemplateRenderParams,
8 TemplateRenderRequest, find_grouping_component, get_variable_key, has_contributor_component,
9 leading_group_affix, remove_first_contributor_with_role, strip_author_component,
10 strip_leading_group_affixes,
11};
12use super::component_predicates::{is_term_only_component, resolve_type_variant};
13use super::group_citation_items_by_author;
14use crate::error::ProcessorError;
15use crate::reference::Reference;
16use crate::render::{ProcTemplate, ProcTemplateComponent};
17use crate::values::{ComponentValues, ProcHints, RenderContext, RenderOptions};
18use citum_schema::template::{
19 TemplateComponent, TemplateConditionField, TemplateGroupCondition, WrapConfig, WrapPunctuation,
20};
21use std::borrow::Cow;
22
23struct GroupRenderState<'a> {
24 first_item: &'a crate::reference::CitationItem,
25 first_ref: &'a Reference,
26 template: Cow<'a, [TemplateComponent]>,
27}
28
29struct ItemRenderState<'a> {
30 item: &'a crate::reference::CitationItem,
31 reference: &'a Reference,
32 template: Cow<'a, [TemplateComponent]>,
33}
34
35struct GroupItemRenderRequest<'a> {
36 item: &'a crate::reference::CitationItem,
37 template: &'a [TemplateComponent],
38 mode: &'a citum_schema::citation::CitationMode,
39 suppress_author: bool,
40 position: Option<&'a citum_schema::citation::Position>,
41 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
42 delimiter: &'a str,
43}
44
45struct TemplateRenderContext<'a> {
51 reference: &'a Reference,
52 ref_type: &'a str,
53 options: &'a RenderOptions<'a>,
54 hint: &'a ProcHints,
55 template_index: usize,
56}
57
58struct HintInputs<'a> {
62 reference: &'a Reference,
63 context: RenderContext,
64 citation_number: usize,
65 position: Option<citum_schema::citation::Position>,
66 integral_name_state: Option<citum_schema::citation::IntegralNameState>,
67 org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
68 first_reference_note_number: Option<u32>,
69}
70
71fn group_condition_matches(reference: &Reference, condition: &TemplateGroupCondition) -> bool {
72 condition
73 .field_present
74 .as_ref()
75 .is_none_or(|field| condition_field_present(reference, field))
76 && condition
77 .field_absent
78 .as_ref()
79 .is_none_or(|field| !condition_field_present(reference, field))
80}
81
82fn condition_field_present(reference: &Reference, field: &TemplateConditionField) -> bool {
83 match field {
84 TemplateConditionField::Author => reference.author().is_some(),
85 TemplateConditionField::Editor => reference.editor().is_some(),
86 TemplateConditionField::Recipient => reference
87 .contributor(citum_schema::reference::ContributorRole::Recipient)
88 .is_some(),
89 TemplateConditionField::Translator => reference.translator().is_some(),
90 TemplateConditionField::Title => reference.title().is_some(),
91 TemplateConditionField::CollectionTitle => reference.collection_title().is_some(),
92 TemplateConditionField::Issued => reference.effective_issued_date().is_some(),
93 TemplateConditionField::OriginalPublished => reference.original_date().is_some(),
94 TemplateConditionField::Publisher => reference.publisher_str().is_some(),
95 TemplateConditionField::OriginalPublisher => reference.original_publisher_str().is_some(),
96 TemplateConditionField::OriginalPublisherPlace => {
97 reference.original_publisher_place().is_some()
98 }
99 TemplateConditionField::OriginalTitle => reference.original_title().is_some(),
100 TemplateConditionField::Doi => reference.doi().is_some(),
101 TemplateConditionField::Genre => reference.genre().is_some(),
102 TemplateConditionField::Archive => reference.archive().is_some(),
103 TemplateConditionField::ArchiveLocation => reference.archive_location().is_some(),
104 }
105}
106
107impl Renderer<'_> {
108 fn strip_redundant_leading_group_punctuation<'a>(
109 &self,
110 value: &'a str,
111 delimiter: &str,
112 ) -> &'a str {
113 let Some(delimiter_char) = delimiter.chars().find(|ch| !ch.is_whitespace()) else {
114 return value;
115 };
116
117 let trimmed = value.trim_start();
118 if !trimmed.starts_with(delimiter_char) {
119 return value;
120 }
121
122 #[allow(clippy::string_slice, reason = "delimiter found at start")]
123 trimmed[delimiter_char.len_utf8()..].trim_start()
124 }
125
126 fn join_integral_group_item_parts(&self, item_parts: &[String], delimiter: &str) -> String {
127 let repeated_item_delimiter = if delimiter.trim().is_empty() {
128 ", "
129 } else {
130 delimiter
131 };
132
133 let mut joined = String::new();
134 for (index, part) in item_parts.iter().enumerate() {
135 if index > 0 {
136 joined.push_str(repeated_item_delimiter);
137 }
138
139 let normalized = if index == 0 {
140 part.as_str()
141 } else {
142 self.strip_redundant_leading_group_punctuation(part, repeated_item_delimiter)
143 };
144 joined.push_str(normalized);
145 }
146
147 joined
148 }
149
150 pub fn render_grouped_citation(
156 &self,
157 items: &[crate::reference::CitationItem],
158 spec: &citum_schema::CitationSpec,
159 mode: &citum_schema::citation::CitationMode,
160 intra_delimiter: &str,
161 suppress_author: bool,
162 position: Option<&citum_schema::citation::Position>,
163 ) -> Result<Vec<String>, ProcessorError> {
164 self.render_grouped_citation_with_format::<crate::render::plain::PlainText>(
165 items,
166 &GroupRenderParams {
167 spec,
168 mode,
169 intra_delimiter,
170 suppress_author,
171 position,
172 note_start_text_case: spec.note_start_text_case,
173 },
174 )
175 }
176
177 fn render_special_type_items<F>(
180 &self,
181 group: &[&crate::reference::CitationItem],
182 params: &GroupRenderParams<'_>,
183 ) -> Result<Vec<String>, ProcessorError>
184 where
185 F: crate::render::format::OutputFormat<Output = String>,
186 {
187 let fmt = F::default();
188 let mut rendered_items = Vec::new();
189 for item in group {
190 let state = self.resolve_item_render_state(item, params.spec)?;
191 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
192 state.reference,
193 GroupItemRenderRequest {
194 item: state.item,
195 template: &state.template,
196 mode: params.mode,
197 suppress_author: params.suppress_author,
198 position: params.position,
199 note_start_text_case: params.note_start_text_case,
200 delimiter: params.intra_delimiter,
201 },
202 ) && let Some((ids, content)) = self.build_citation_chunk(
203 &fmt,
204 vec![item.id.clone()],
205 item_str,
206 item.prefix.as_deref(),
207 item.suffix.as_deref(),
208 ) {
209 rendered_items.push(fmt.citation(ids, content));
210 }
211 }
212 Ok(rendered_items)
213 }
214
215 fn render_integral_explicit_group<F>(
220 &self,
221 group: &[&crate::reference::CitationItem],
222 spec: &citum_schema::CitationSpec,
223 mode: &citum_schema::citation::CitationMode,
224 suppress_author: bool,
225 position: Option<&citum_schema::citation::Position>,
226 ) -> Result<Option<String>, ProcessorError>
227 where
228 F: crate::render::format::OutputFormat<Output = String>,
229 {
230 let fmt = F::default();
231 let component_delimiter = spec.delimiter.as_deref().unwrap_or(" ");
232 let item_join_delim = spec.multi_cite_delimiter.as_deref().unwrap_or(", ");
233 let mut group_items_str = Vec::new();
234 let mut all_ids = Vec::new();
235
236 for item in group {
237 let state = self.resolve_item_render_state(item, spec)?;
238 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
239 state.reference,
240 GroupItemRenderRequest {
241 item: state.item,
242 template: &state.template,
243 mode,
244 suppress_author,
245 position,
246 note_start_text_case: spec.note_start_text_case,
247 delimiter: component_delimiter,
248 },
249 ) && !item_str.is_empty()
250 {
251 group_items_str.push(self.affix_content(
252 &fmt,
253 item_str,
254 item.prefix.as_deref(),
255 item.suffix.as_deref(),
256 ));
257 all_ids.push(item.id.clone());
258 }
259 }
260
261 if group_items_str.is_empty() {
262 return Ok(None);
263 }
264
265 let combined_str = group_items_str.join(item_join_delim);
266 Ok(Some(fmt.citation(all_ids, combined_str)))
267 }
268
269 pub fn render_grouped_citation_with_format<F>(
278 &self,
279 items: &[crate::reference::CitationItem],
280 params: &GroupRenderParams<'_>,
281 ) -> Result<Vec<String>, ProcessorError>
282 where
283 F: crate::render::format::OutputFormat<Output = String>,
284 {
285 let groups = group_citation_items_by_author(self, items);
286 let mut rendered_groups = Vec::new();
287 for (_author_key, group) in groups {
288 rendered_groups
289 .extend(self.render_grouped_citation_group_with_format::<F>(&group, params)?);
290 }
291
292 Ok(rendered_groups)
293 }
294
295 fn render_grouped_citation_group_with_format<F>(
296 &self,
297 group: &[&crate::reference::CitationItem],
298 params: &GroupRenderParams<'_>,
299 ) -> Result<Vec<String>, ProcessorError>
300 where
301 F: crate::render::format::OutputFormat<Output = String>,
302 {
303 let state = self.resolve_group_render_state(group, params.spec)?;
304
305 if group.len() == 1
310 && let Some(citation) = self.try_render_integral_group_with_format::<F>(
311 group,
312 params.spec,
313 params.mode,
314 params.suppress_author,
315 params.position,
316 )?
317 {
318 return Ok(vec![citation]);
319 }
320
321 if self.requires_full_group_item_rendering(params.mode, state.first_ref) {
322 return self.render_special_type_items::<F>(group, params);
323 }
324
325 Ok(self
326 .render_fallback_grouped_citation_with_format::<F>(
327 group,
328 state.first_ref,
329 state.first_item,
330 &state.template,
331 params,
332 )?
333 .into_iter()
334 .collect())
335 }
336
337 fn render_fallback_grouped_citation_with_format<F>(
338 &self,
339 group: &[&crate::reference::CitationItem],
340 first_ref: &Reference,
341 first_item: &crate::reference::CitationItem,
342 template: &[TemplateComponent],
343 params: &GroupRenderParams<'_>,
344 ) -> Result<Option<String>, ProcessorError>
345 where
346 F: crate::render::format::OutputFormat<Output = String>,
347 {
348 let fmt = F::default();
349 let author_part = self.render_author_for_grouping_with_format::<F>(
350 first_ref,
351 first_item,
352 template,
353 params.mode,
354 params.suppress_author,
355 params.position,
356 );
357 let (item_parts, group_delimiter, captured_year_wrap) =
358 self.render_group_item_parts_with_format::<F>(&fmt, group, params)?;
359 let pre_wrapped_years =
365 if matches!(params.mode, citum_schema::citation::CitationMode::Integral)
366 && !item_parts.is_empty()
367 {
368 let delimiter = group_delimiter.as_deref().unwrap_or(params.intra_delimiter);
369 let joined = self.join_integral_group_item_parts(&item_parts, delimiter);
370 let wrap_punct = captured_year_wrap
371 .as_ref()
372 .map(|w| &w.punctuation)
373 .unwrap_or(&WrapPunctuation::Parentheses);
374 let inner_prefix = captured_year_wrap
375 .as_ref()
376 .and_then(|w| w.inner_prefix.as_deref())
377 .unwrap_or("");
378 let inner_suffix = captured_year_wrap
379 .as_ref()
380 .and_then(|w| w.inner_suffix.as_deref())
381 .unwrap_or("");
382 let inner = fmt.inner_affix(inner_prefix, joined, inner_suffix);
383 let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
384 Some(fmt.wrap_punctuation(wrap_punct, inner, &marks))
385 } else {
386 None
387 };
388 let Some(content) = self.build_grouped_citation_content(
389 &author_part,
390 &item_parts,
391 params,
392 group_delimiter.as_deref(),
393 pre_wrapped_years.as_deref(),
394 ) else {
395 return Ok(None);
396 };
397 let group_ids = group.iter().map(|item| item.id.clone()).collect();
398 let prefix = first_item.prefix.as_deref().unwrap_or("");
399 let suffix = if item_parts.is_empty() {
402 first_item.suffix.as_deref()
403 } else {
404 None
405 };
406
407 Ok(Some(fmt.citation(
408 group_ids,
409 self.affix_content(&fmt, content, Some(prefix), suffix),
410 )))
411 }
412
413 fn build_grouped_citation_content(
414 &self,
415 author_part: &str,
416 item_parts: &[String],
417 params: &GroupRenderParams<'_>,
418 group_delimiter: Option<&str>,
419 pre_wrapped_years: Option<&str>,
420 ) -> Option<String> {
421 if !author_part.is_empty() && !item_parts.is_empty() {
422 let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
423 return Some(match params.mode {
424 citum_schema::citation::CitationMode::Integral => {
425 let wrapped = pre_wrapped_years.map(str::to_string).unwrap_or_else(|| {
429 self.join_integral_group_item_parts(item_parts, author_item_delimiter)
430 });
431 self.format_integral_grouped_items(
432 author_part,
433 &wrapped,
434 params.suppress_author,
435 )
436 }
437 citum_schema::citation::CitationMode::NonIntegral => {
438 let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
439 ", "
440 } else {
441 author_item_delimiter
442 };
443 let joined_items = item_parts.join(repeated_item_delimiter);
444 self.format_non_integral_grouped_items(
445 author_part,
446 author_item_delimiter,
447 &joined_items,
448 params.suppress_author,
449 )
450 }
451 });
452 }
453
454 if !author_part.is_empty() {
455 return Some(author_part.to_string());
456 }
457
458 if !item_parts.is_empty() {
459 return Some(item_parts.join(params.intra_delimiter));
460 }
461
462 None
463 }
464
465 fn format_integral_grouped_items(
466 &self,
467 author_part: &str,
468 wrapped_content: &str,
469 suppress_author: bool,
470 ) -> String {
471 if suppress_author {
472 wrapped_content.to_string()
473 } else {
474 format!("{author_part} {wrapped_content}")
475 }
476 }
477
478 fn format_non_integral_grouped_items(
479 &self,
480 author_part: &str,
481 author_item_delimiter: &str,
482 joined_items: &str,
483 suppress_author: bool,
484 ) -> String {
485 if suppress_author {
486 return joined_items.to_string();
487 }
488
489 if let Some(adjusted) =
490 self.adjust_grouped_author_quote_punctuation(author_part, author_item_delimiter)
491 {
492 return format!("{adjusted}{joined_items}");
493 }
494
495 format!("{author_part}{author_item_delimiter}{joined_items}")
496 }
497
498 fn adjust_grouped_author_quote_punctuation(
499 &self,
500 author_part: &str,
501 author_item_delimiter: &str,
502 ) -> Option<String> {
503 if !self.config.punctuation_in_quote
504 || !author_item_delimiter.starts_with(',')
505 || !(author_part.ends_with('"') || author_part.ends_with('\u{201D}'))
506 {
507 return None;
508 }
509
510 let is_curly = author_part.ends_with('\u{201D}');
511 let quote_char = if is_curly { '\u{201D}' } else { '"' };
512 #[allow(clippy::string_slice, reason = "quote found at end")]
513 let trimmed = &author_part[..author_part.len() - quote_char.len_utf8()];
514 #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
515 Some(format!(
516 "{trimmed},{quote_char}{}",
517 &author_item_delimiter[1..]
518 ))
519 }
520
521 fn render_group_item_parts_with_format<F>(
522 &self,
523 fmt: &F,
524 group: &[&crate::reference::CitationItem],
525 params: &GroupRenderParams<'_>,
526 ) -> Result<(Vec<String>, Option<String>, Option<WrapConfig>), ProcessorError>
527 where
528 F: crate::render::format::OutputFormat<Output = String>,
529 {
530 let mut item_parts = Vec::new();
531 let mut group_delimiter: Option<String> = None;
532 let mut captured_year_wrap: Option<WrapConfig> = None;
539 let collapse_group = group.len() > 1
540 && matches!(params.mode, citum_schema::citation::CitationMode::Integral);
541 for (index, item) in group.iter().enumerate() {
542 let state = self.resolve_item_render_state(item, params.spec)?;
543 let (mut filtered_template, leading_affix, strip_item_delimiter) =
544 filter_author_from_template(&state.template);
545 if collapse_group {
546 if index == 0 {
547 captured_year_wrap = filtered_template
552 .first_mut()
553 .and_then(|c| c.rendering_mut().wrap.take());
554 } else {
555 if let Some(first) = filtered_template.first_mut() {
557 first.rendering_mut().wrap = None;
558 }
559 }
560 }
561 if group_delimiter.is_none() {
562 group_delimiter = leading_affix
563 .as_ref()
564 .filter(|value| !value.is_empty())
565 .cloned();
566 }
567 let item_delimiter = if strip_item_delimiter {
568 ""
569 } else {
570 params.intra_delimiter
571 };
572 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
573 state.reference,
574 GroupItemRenderRequest {
575 item: state.item,
576 template: &filtered_template,
577 mode: params.mode,
578 suppress_author: params.suppress_author,
579 position: params.position,
580 note_start_text_case: params.note_start_text_case,
581 delimiter: item_delimiter,
582 },
583 ) && !item_str.is_empty()
584 {
585 let prefix = (index > 0).then_some(item.prefix.as_deref()).flatten();
586 item_parts.push(self.affix_content(fmt, item_str, prefix, item.suffix.as_deref()));
587 }
588 }
589 Ok((item_parts, group_delimiter, captured_year_wrap))
590 }
591
592 fn resolve_group_render_state<'b>(
593 &'b self,
594 group: &'b [&'b crate::reference::CitationItem],
595 spec: &'b citum_schema::CitationSpec,
596 ) -> Result<GroupRenderState<'b>, ProcessorError> {
597 #[allow(clippy::indexing_slicing, reason = "groups are non-empty")]
598 let first_item = group[0];
599 let first_ref = self
600 .bibliography
601 .get(&first_item.id)
602 .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
603 let first_language = crate::values::effective_item_language(first_ref);
604 let default_template = spec
605 .resolve_template_for_language(first_language.as_deref())
606 .map(Cow::Owned);
607
608 let ref_type = first_ref.ref_type();
609 let first_template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
610 .map(Cow::Borrowed)
611 .or(default_template);
612
613 Ok(GroupRenderState {
614 first_item,
615 first_ref,
616 template: first_template.unwrap_or(Cow::Borrowed(&[])),
617 })
618 }
619
620 fn resolve_item_render_state<'b>(
621 &'b self,
622 item: &'b crate::reference::CitationItem,
623 spec: &'b citum_schema::CitationSpec,
624 ) -> Result<ItemRenderState<'b>, ProcessorError> {
625 let reference = self
626 .bibliography
627 .get(&item.id)
628 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
629 let item_language = crate::values::effective_item_language(reference);
630 let default_template = spec
631 .resolve_template_for_language(item_language.as_deref())
632 .map(Cow::Owned);
633
634 let ref_type = reference.ref_type();
635 let item_template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
636 .map(Cow::Borrowed)
637 .or(default_template);
638
639 Ok(ItemRenderState {
640 item,
641 reference,
642 template: item_template.unwrap_or(Cow::Borrowed(&[])),
643 })
644 }
645
646 fn try_render_integral_group_with_format<F>(
647 &self,
648 group: &[&crate::reference::CitationItem],
649 spec: &citum_schema::CitationSpec,
650 mode: &citum_schema::citation::CitationMode,
651 suppress_author: bool,
652 position: Option<&citum_schema::citation::Position>,
653 ) -> Result<Option<String>, ProcessorError>
654 where
655 F: crate::render::format::OutputFormat<Output = String>,
656 {
657 if !matches!(mode, citum_schema::citation::CitationMode::Integral)
658 || !self.has_explicit_integral_template()
659 {
660 return Ok(None);
661 }
662
663 self.render_integral_explicit_group::<F>(group, spec, mode, suppress_author, position)
664 }
665
666 fn requires_full_group_item_rendering(
677 &self,
678 mode: &citum_schema::citation::CitationMode,
679 reference: &Reference,
680 ) -> bool {
681 matches!(mode, citum_schema::citation::CitationMode::NonIntegral)
682 && matches!(
683 reference.ref_type().as_str(),
684 "legal-case" | "treaty" | "hearing" | "personal-communication"
685 )
686 }
687
688 pub(crate) fn render_author_for_grouping_with_format<F>(
690 &self,
691 reference: &Reference,
692 item: &crate::reference::CitationItem,
693 template: &[TemplateComponent],
694 mode: &citum_schema::citation::CitationMode,
695 suppress_author: bool,
696 position: Option<&citum_schema::citation::Position>,
697 ) -> String
698 where
699 F: crate::render::format::OutputFormat<Output = String>,
700 {
701 let is_note_processing = self.config.processing.as_ref().is_some_and(|processing| {
702 matches!(processing, citum_schema::options::Processing::Note)
703 });
704 if is_note_processing
705 && matches!(
706 position,
707 Some(
708 citum_schema::citation::Position::Ibid
709 | citum_schema::citation::Position::IbidWithLocator
710 )
711 )
712 && !template.iter().any(has_contributor_component)
713 {
714 return String::new();
715 }
716
717 let options = self.citation_render_options(mode.clone(), suppress_author, None, None);
718
719 if let Some(comp) = template.first().and_then(find_grouping_component) {
723 let base_hints = self
724 .hints
725 .get(reference.id().as_deref().unwrap_or_default())
726 .cloned()
727 .unwrap_or_default();
728 let hints = ProcHints {
730 position: position.cloned(),
731 integral_name_state: item.integral_name_state,
732 ..base_hints
733 };
734 if let Some(vals) = comp.values::<F>(reference, &hints, &options)
735 && !vals.value.is_empty()
736 {
737 return vals.value;
738 }
739 }
740
741 if let Some(authors) = reference.author() {
743 let names_vec = self.resolve_contributor_names(&authors);
744 F::default().text(&crate::values::format_contributors_short(
745 &names_vec, &options,
746 ))
747 } else {
748 String::new()
749 }
750 }
751
752 pub(crate) fn render_integral_anchor_with_format<F>(
754 &self,
755 items: &[crate::reference::CitationItem],
756 spec: &citum_schema::CitationSpec,
757 inter_delimiter: &str,
758 suppress_author: bool,
759 position: Option<&citum_schema::citation::Position>,
760 ) -> Result<String, ProcessorError>
761 where
762 F: crate::render::format::OutputFormat<Output = String>,
763 {
764 let groups = group_citation_items_by_author(self, items);
765
766 let mut rendered_groups = Vec::new();
767 let fmt = F::default();
768 for (_author_key, group) in groups {
769 #[allow(
770 clippy::indexing_slicing,
771 reason = "group is non-empty by construction"
772 )]
773 let first_item = group[0];
774 let reference = self
775 .bibliography
776 .get(&first_item.id)
777 .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
778 let item_language = crate::values::effective_item_language(reference);
779 let template = spec.resolve_template_for_language(item_language.as_deref());
780 let effective_template = template.as_deref().unwrap_or(&[]);
781 let author_part = self.render_author_for_grouping_with_format::<F>(
782 reference,
783 first_item,
784 effective_template,
785 &citum_schema::citation::CitationMode::Integral,
786 suppress_author,
787 position,
788 );
789 if !author_part.is_empty() {
790 rendered_groups.push(author_part);
791 }
792 }
793
794 Ok(fmt.join(rendered_groups, inter_delimiter))
795 }
796
797 #[must_use]
799 pub fn get_or_assign_citation_number(&self, ref_id: &str) -> usize {
800 let mut numbers = self
801 .citation_numbers
802 .write()
803 .unwrap_or_else(std::sync::PoisonError::into_inner);
804 let next_num = numbers.len() + 1;
805 *numbers.entry(ref_id.to_string()).or_insert(next_num)
806 }
807
808 #[must_use]
810 pub fn process_bibliography_entry(
811 &self,
812 reference: &Reference,
813 entry_number: usize,
814 ) -> Option<ProcTemplate> {
815 self.process_bibliography_entry_with_format::<crate::render::plain::PlainText>(
816 reference,
817 entry_number,
818 )
819 }
820
821 #[must_use]
823 pub fn process_bibliography_entry_with_format<F>(
824 &self,
825 reference: &Reference,
826 entry_number: usize,
827 ) -> Option<ProcTemplate>
828 where
829 F: crate::render::format::OutputFormat<Output = String>,
830 {
831 let bib_spec = self.style.bibliography.as_ref()?;
832
833 let item_language = crate::values::effective_item_language(reference);
834 let default_template = bib_spec
835 .resolve_template_for_language(item_language.as_deref())
836 .map(Cow::Owned);
837
838 let ref_type = reference.ref_type();
839 let template = resolve_type_variant(bib_spec.type_variants.as_ref(), &ref_type)
840 .map(Cow::Borrowed)
841 .or(default_template)?;
842
843 let template = self.apply_anonymous_entry_bibliography_policy(reference, template)?;
844 let template = self.apply_article_journal_bibliography_policy(reference, template);
845
846 self.process_template_request_with_format::<F>(
847 reference,
848 TemplateRenderRequest {
849 template: template.as_ref(),
850 context: RenderContext::Bibliography,
851 mode: citum_schema::citation::CitationMode::NonIntegral,
852 suppress_author: false,
853 locator_raw: None,
854 citation_number: entry_number,
855 position: None,
856 note_start_text_case: None,
857 integral_name_state: None,
858 org_abbreviation_state: None,
859 first_reference_note_number: None,
860 },
861 )
862 }
863
864 #[must_use]
869 pub fn process_template_with_number(
870 &self,
871 reference: &Reference,
872 params: TemplateRenderParams<'_>,
873 ) -> Option<ProcTemplate> {
874 self.process_template_with_number_with_format::<crate::render::plain::PlainText>(
875 reference, params,
876 )
877 }
878
879 pub fn process_template_with_number_with_format<F>(
884 &self,
885 reference: &Reference,
886 params: TemplateRenderParams<'_>,
887 ) -> Option<ProcTemplate>
888 where
889 F: crate::render::format::OutputFormat<Output = String>,
890 {
891 self.process_template_request_with_format::<F>(
892 reference,
893 TemplateRenderRequest {
894 template: params.template,
895 context: params.context,
896 mode: params.mode,
897 suppress_author: params.suppress_author,
898 locator_raw: params.locator_raw,
899 citation_number: params.citation_number,
900 position: params.position.cloned(),
901 note_start_text_case: params.note_start_text_case,
902 integral_name_state: params.integral_name_state,
903 org_abbreviation_state: params.org_abbreviation_state,
904 first_reference_note_number: None,
905 },
906 )
907 }
908
909 #[must_use]
911 pub fn process_template_request_with_format<F>(
912 &self,
913 reference: &Reference,
914 request: TemplateRenderRequest<'_>,
915 ) -> Option<ProcTemplate>
916 where
917 F: crate::render::format::OutputFormat<Output = String>,
918 {
919 let TemplateRenderRequest {
920 template,
921 context,
922 mode,
923 suppress_author,
924 locator_raw,
925 citation_number,
926 position,
927 note_start_text_case,
928 integral_name_state,
929 org_abbreviation_state,
930 first_reference_note_number,
931 } = request;
932 let ref_type = reference.ref_type();
933 let options = RenderOptions {
934 config: self.config.clone(),
935 bibliography_config: self.bibliography_config.clone(),
936 locale: self.locale,
937 context,
938 mode,
939 suppress_author,
940 locator_raw,
941 ref_type: Some(ref_type.clone()),
942 show_semantics: self.show_semantics,
943 current_template_index: None,
944 abbreviation_map: self.abbreviation_map,
945 };
946 let effective_first_ref_note = if template_uses_first_ref_note_number(template) {
951 first_reference_note_number
952 } else {
953 None
954 };
955 let hint = self.build_template_render_hint(HintInputs {
956 reference,
957 context: options.context,
958 citation_number,
959 position,
960 integral_name_state,
961 org_abbreviation_state,
962 first_reference_note_number: effective_first_ref_note,
963 });
964 let mut components =
965 self.render_template_components::<F>(reference, &ref_type, &options, &hint, template);
966
967 self.apply_sentence_initial_context::<F>(&mut components, context, note_start_text_case);
968
969 (!components.is_empty()).then_some(components)
970 }
971
972 fn render_template_components<F>(
976 &self,
977 reference: &Reference,
978 ref_type: &str,
979 options: &RenderOptions<'_>,
980 hint: &ProcHints,
981 template: &[TemplateComponent],
982 ) -> Vec<ProcTemplateComponent>
983 where
984 F: crate::render::format::OutputFormat<Output = String>,
985 {
986 let mut tracker = TemplateComponentTracker::default();
987 let mut components = Vec::with_capacity(template.len());
988 let mut component_options = options.clone();
989 for (template_index, component) in template.iter().enumerate() {
990 component_options.current_template_index =
991 self.inject_ast_indices.then_some(template_index);
992 let ctx = TemplateRenderContext {
993 reference,
994 ref_type,
995 options: &component_options,
996 hint,
997 template_index,
998 };
999 if let Some(component) =
1000 self.render_template_component_with_format::<F>(&ctx, component, &mut tracker)
1001 {
1002 components.push(component);
1003 }
1004 }
1005 components
1006 }
1007
1008 fn build_template_render_hint(&self, inputs: HintInputs<'_>) -> ProcHints {
1009 let HintInputs {
1010 reference,
1011 context,
1012 citation_number,
1013 position,
1014 integral_name_state,
1015 org_abbreviation_state,
1016 first_reference_note_number,
1017 } = inputs;
1018 let default_hint = ProcHints::default();
1019 let base_hint = self
1020 .hints
1021 .get(reference.id().as_deref().unwrap_or_default())
1022 .unwrap_or(&default_hint);
1023 let is_subsequent = matches!(position, Some(citum_schema::citation::Position::Subsequent));
1024 ProcHints {
1025 citation_number: (citation_number > 0).then_some(citation_number),
1026 citation_sub_label: if context == RenderContext::Citation {
1027 reference
1028 .id()
1029 .as_deref()
1030 .and_then(|id| self.citation_sub_label_for_ref(id))
1031 } else {
1032 None
1033 },
1034 position,
1035 integral_name_state,
1036 org_abbreviation_state,
1037 first_reference_note_number: if is_subsequent {
1038 first_reference_note_number
1039 } else {
1040 None
1041 },
1042 suppress_disambiguation_title: is_subsequent && first_reference_note_number.is_some(),
1043 ..base_hint.clone()
1044 }
1045 }
1046
1047 fn render_template_component_with_format<F>(
1048 &self,
1049 ctx: &TemplateRenderContext<'_>,
1050 component: &TemplateComponent,
1051 tracker: &mut TemplateComponentTracker,
1052 ) -> Option<ProcTemplateComponent>
1053 where
1054 F: crate::render::format::OutputFormat<Output = String>,
1055 {
1056 if let TemplateComponent::Group(group) = component {
1057 return self.render_group_component_with_format::<F>(ctx, group, tracker);
1058 }
1059
1060 let resolved_component = component;
1061 if resolved_component.rendering().suppress == Some(true) {
1062 return None;
1063 }
1064
1065 let var_key = get_variable_key(resolved_component);
1066 if tracker.should_skip(var_key.as_deref()) {
1067 return None;
1068 }
1069
1070 let mut values = resolved_component.values::<F>(ctx.reference, ctx.hint, ctx.options)?;
1071 if values.value.trim().is_empty() {
1075 return None;
1076 }
1077 self.apply_issued_no_date_fallback(
1078 ctx.reference,
1079 ctx.options,
1080 resolved_component,
1081 &mut values,
1082 );
1083 self.apply_entry_link_fallback(ctx.reference, ctx.options, &mut values);
1084
1085 let item_language =
1086 crate::values::effective_component_language(ctx.reference, resolved_component);
1087 tracker.mark_rendered(var_key, values.substituted_key.as_deref());
1088
1089 Some(ProcTemplateComponent {
1090 template_component: resolved_component.clone(),
1091 template_index: self.inject_ast_indices.then_some(ctx.template_index),
1092 value: values.value,
1093 prefix: values.prefix,
1094 suffix: values.suffix,
1095 url: values.url,
1096 ref_type: Some(ctx.ref_type.to_string()),
1097 config: Some(ctx.options.config.clone()),
1098 bibliography_config: ctx.options.bibliography_config.clone(),
1099 item_language,
1100 quote_marks: crate::render::format::QuoteMarks::from(
1101 &ctx.options.locale.grammar_options,
1102 ),
1103 sentence_initial: false,
1104 pre_formatted: values.pre_formatted,
1105 })
1106 }
1107
1108 fn render_group_component_with_format<F>(
1109 &self,
1110 ctx: &TemplateRenderContext<'_>,
1111 group: &citum_schema::template::TemplateGroup,
1112 tracker: &mut TemplateComponentTracker,
1113 ) -> Option<ProcTemplateComponent>
1114 where
1115 F: crate::render::format::OutputFormat<Output = String>,
1116 {
1117 if group.rendering.suppress == Some(true) {
1118 return None;
1119 }
1120 if group
1121 .render_when
1122 .as_ref()
1123 .is_some_and(|condition| !group_condition_matches(ctx.reference, condition))
1124 {
1125 return None;
1126 }
1127
1128 let fmt = F::default();
1129 let mut group_tracker = tracker.clone();
1130 let values = self.render_group_child_values(&fmt, ctx, group, &mut group_tracker)?;
1131 let delimiter = group
1132 .delimiter
1133 .as_ref()
1134 .unwrap_or(&citum_schema::template::DelimiterPunctuation::Comma)
1135 .to_string_with_space();
1136 tracker.merge_from(group_tracker);
1137 let group_component = TemplateComponent::Group(group.clone());
1138 Some(ProcTemplateComponent {
1139 template_component: group_component.clone(),
1140 template_index: self.inject_ast_indices.then_some(ctx.template_index),
1141 value: fmt.join(values, &delimiter),
1142 prefix: None,
1143 suffix: None,
1144 url: None,
1145 ref_type: Some(ctx.ref_type.to_string()),
1146 config: Some(ctx.options.config.clone()),
1147 bibliography_config: ctx.options.bibliography_config.clone(),
1148 item_language: crate::values::effective_component_language(
1149 ctx.reference,
1150 &group_component,
1151 ),
1152 quote_marks: crate::render::format::QuoteMarks::from(
1153 &ctx.options.locale.grammar_options,
1154 ),
1155 sentence_initial: false,
1156 pre_formatted: true,
1157 })
1158 }
1159
1160 fn render_group_child_values<F>(
1166 &self,
1167 fmt: &F,
1168 ctx: &TemplateRenderContext<'_>,
1169 group: &citum_schema::template::TemplateGroup,
1170 tracker: &mut TemplateComponentTracker,
1171 ) -> Option<Vec<String>>
1172 where
1173 F: crate::render::format::OutputFormat<Output = String>,
1174 {
1175 let mut has_meaningful_content = false;
1176 let mut values = Vec::with_capacity(group.group.len());
1177
1178 for item in &group.group {
1179 let Some(rendered) =
1180 self.render_template_component_with_format::<F>(ctx, item, tracker)
1181 else {
1182 continue;
1183 };
1184 let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1185 &rendered,
1186 fmt,
1187 ctx.options.show_semantics,
1188 );
1189 if rendered_str.trim().is_empty() {
1190 continue;
1191 }
1192 if !is_term_only_component(item) {
1193 has_meaningful_content = true;
1194 }
1195 values.push(rendered_str);
1196 }
1197
1198 (has_meaningful_content && !values.is_empty()).then_some(values)
1199 }
1200
1201 fn apply_issued_no_date_fallback(
1202 &self,
1203 reference: &Reference,
1204 options: &RenderOptions<'_>,
1205 component: &TemplateComponent,
1206 values: &mut crate::values::ProcValues<String>,
1207 ) {
1208 if !matches!(
1209 component,
1210 TemplateComponent::Date(citum_schema::template::TemplateDate {
1211 date: citum_schema::template::DateVariable::Issued,
1212 ..
1213 })
1214 ) || reference.effective_issued_date().is_some()
1215 || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1216 {
1217 return;
1218 }
1219
1220 if let Some(long) = options.locale.resolved_general_term(
1221 &citum_schema::locale::GeneralTerm::NoDate,
1222 &citum_schema::locale::TermForm::Long,
1223 None,
1224 ) {
1225 values.value = long;
1226 }
1227 }
1228
1229 fn apply_entry_link_fallback(
1230 &self,
1231 reference: &Reference,
1232 options: &RenderOptions<'_>,
1233 values: &mut crate::values::ProcValues<String>,
1234 ) {
1235 if values.url.is_some() {
1236 return;
1237 }
1238
1239 let Some(links) = &options.config.links else {
1240 return;
1241 };
1242 use citum_schema::options::LinkAnchor;
1243 if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1244 values.url = crate::values::resolve_url(links, reference);
1245 }
1246 }
1247
1248 pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1250 self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1251 proc, substitute,
1252 );
1253 }
1254
1255 pub fn apply_author_substitution_with_format<F>(
1257 &self,
1258 proc: &mut ProcTemplate,
1259 substitute: &str,
1260 ) where
1261 F: crate::render::format::OutputFormat<Output = String>,
1262 {
1263 if let Some(component) = proc
1264 .iter_mut()
1265 .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1266 {
1267 let fmt = F::default();
1268 component.value = fmt.text(substitute);
1269 }
1270 }
1271
1272 fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1275 match self
1276 .config
1277 .dates
1278 .as_ref()
1279 .and_then(|dates| dates.no_date_form)
1280 {
1281 Some(citum_schema::options::NoDateForm::Long) => citum_schema::locale::TermForm::Long,
1282 Some(citum_schema::options::NoDateForm::Short) | None => {
1283 citum_schema::locale::TermForm::Short
1284 }
1285 }
1286 }
1287
1288 fn render_group_item_from_template_with_format<F>(
1289 &self,
1290 reference: &Reference,
1291 item_request: GroupItemRenderRequest<'_>,
1292 ) -> Option<String>
1293 where
1294 F: crate::render::format::OutputFormat<Output = String>,
1295 {
1296 let request = self.citation_render_request(
1297 item_request.item,
1298 item_request.template,
1299 item_request.mode,
1300 item_request.suppress_author,
1301 item_request.position,
1302 item_request.note_start_text_case,
1303 );
1304 self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1305 }
1306}
1307
1308pub(super) fn template_uses_first_ref_note_number(template: &[TemplateComponent]) -> bool {
1315 template.iter().any(|c| match c {
1316 TemplateComponent::Number(n) => {
1317 n.number == citum_schema::template::NumberVariable::FirstReferenceNoteNumber
1318 }
1319 TemplateComponent::Group(g) => template_uses_first_ref_note_number(&g.group),
1320 _ => false,
1321 })
1322}
1323
1324pub(super) fn filter_author_from_template(
1325 template: &[TemplateComponent],
1326) -> (Vec<TemplateComponent>, Option<String>, bool) {
1327 let grouping_role = template
1333 .first()
1334 .and_then(find_grouping_component)
1335 .and_then(|component| match component {
1336 TemplateComponent::Contributor(contributor)
1337 if contributor.contributor != citum_schema::template::ContributorRole::Author =>
1338 {
1339 Some(contributor.contributor.clone())
1340 }
1341 _ => None,
1342 });
1343 let mut filtered: Vec<TemplateComponent> =
1344 template.iter().filter_map(strip_author_component).collect();
1345 if let Some(role) = grouping_role
1346 && !filtered.is_empty()
1347 {
1348 let first = filtered.remove(0);
1349 if let (Some(remaining), _) = remove_first_contributor_with_role(first, &role) {
1350 filtered.insert(0, remaining);
1351 }
1352 }
1353 let stripped_leading_affix = filtered.first().and_then(leading_group_affix);
1354 let leading_affix = stripped_leading_affix.clone().or_else(|| {
1355 filtered
1356 .first()
1357 .and_then(|_| template.first().and_then(author_group_delimiter_affix))
1358 });
1359 if let Some(first) = filtered.first_mut() {
1360 strip_leading_group_affixes(first);
1361 }
1362 (filtered, leading_affix, stripped_leading_affix.is_some())
1363}
1364
1365fn author_group_delimiter_affix(component: &TemplateComponent) -> Option<String> {
1366 let TemplateComponent::Group(group) = component else {
1367 return None;
1368 };
1369 group
1370 .group
1371 .first()
1372 .is_some_and(component_starts_with_author)
1373 .then_some(group.delimiter.as_ref())
1374 .flatten()
1375 .map(citum_schema::template::DelimiterPunctuation::to_string_with_space)
1376 .filter(|delimiter| !delimiter.is_empty())
1377}
1378
1379fn component_starts_with_author(component: &TemplateComponent) -> bool {
1380 match component {
1381 TemplateComponent::Contributor(contributor) => contributor
1382 .contributor
1383 .contains(&citum_schema::template::ContributorRole::Author),
1384 TemplateComponent::Group(group) => group
1385 .group
1386 .first()
1387 .is_some_and(component_starts_with_author),
1388 _ => false,
1389 }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::*;
1395 use citum_schema::template::{
1396 ContributorRole, DelimiterPunctuation, TemplateContributor, TemplateGroup,
1397 };
1398
1399 #[test]
1400 fn author_group_delimiter_affix_recognizes_merged_leading_author_component() {
1401 let group = TemplateComponent::Group(TemplateGroup {
1404 group: vec![TemplateComponent::Contributor(TemplateContributor {
1405 contributor: vec![ContributorRole::Author, ContributorRole::Editor].into(),
1406 ..Default::default()
1407 })],
1408 delimiter: Some(DelimiterPunctuation::Comma),
1409 ..Default::default()
1410 });
1411
1412 let affix = author_group_delimiter_affix(&group);
1414
1415 assert_eq!(affix, Some(", ".to_string()));
1417 }
1418
1419 #[test]
1420 fn author_group_delimiter_affix_ignores_merged_component_without_author() {
1421 let group = TemplateComponent::Group(TemplateGroup {
1424 group: vec![TemplateComponent::Contributor(TemplateContributor {
1425 contributor: vec![ContributorRole::Editor, ContributorRole::Translator].into(),
1426 ..Default::default()
1427 })],
1428 delimiter: Some(DelimiterPunctuation::Comma),
1429 ..Default::default()
1430 });
1431
1432 let affix = author_group_delimiter_affix(&group);
1434
1435 assert_eq!(affix, None);
1437 }
1438}