1use crate::error::ProcessorError;
13use crate::reference::{Bibliography, Reference};
14use crate::values::{ProcHints, RenderContext, RenderOptions};
15use citum_schema::citation::CitationLocator;
16use citum_schema::locale::Locale;
17use citum_schema::options::{Config, bibliography::BibliographyConfig};
18use citum_schema::template::TemplateComponent;
19use grouped::component_predicates::resolve_type_variant;
20use indexmap::IndexMap;
21use std::borrow::Cow;
22use std::cell::RefCell;
23use std::collections::{HashMap, HashSet};
24use std::rc::Rc;
25
26pub struct Renderer<'a> {
31 pub style: &'a citum_schema::Style,
33 pub bibliography: &'a Bibliography,
35 pub locale: &'a Locale,
37 pub config: Rc<Config>,
39 pub bibliography_config: Option<Rc<BibliographyConfig>>,
41 pub hints: &'a HashMap<String, ProcHints>,
43 pub citation_numbers: &'a RefCell<HashMap<String, usize>>,
45 pub compound_set_by_ref: &'a HashMap<String, String>,
47 pub compound_member_index: &'a HashMap<String, usize>,
49 pub compound_sets: &'a IndexMap<String, Vec<String>>,
51 pub show_semantics: bool,
53 pub inject_ast_indices: bool,
55 pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
57 pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
59 pub first_note_by_id: Option<&'a RefCell<HashMap<String, u32>>>,
61}
62
63pub struct CompoundRenderData<'a> {
65 pub set_by_ref: &'a HashMap<String, String>,
67 pub member_index: &'a HashMap<String, usize>,
69 pub sets: &'a IndexMap<String, Vec<String>>,
71}
72
73mod collapse;
74mod grouped;
75mod grouped_fallback;
76mod helpers;
77
78#[cfg(test)]
79#[allow(
80 clippy::unwrap_used,
81 clippy::expect_used,
82 clippy::panic,
83 clippy::indexing_slicing,
84 clippy::todo,
85 clippy::unimplemented,
86 clippy::unreachable,
87 clippy::get_unwrap,
88 reason = "Panicking is acceptable and often desired in tests."
89)]
90mod tests;
91
92pub use grouped_fallback::GroupRenderParams;
93pub use grouped_fallback::TemplateRenderParams;
94pub(super) use helpers::{
95 find_grouping_component, has_contributor_component, leading_group_affix,
96 strip_author_component, strip_leading_group_affixes,
97};
98
99pub struct TemplateRenderRequest<'a> {
101 pub template: &'a [TemplateComponent],
103 pub context: RenderContext,
105 pub mode: citum_schema::citation::CitationMode,
107 pub suppress_author: bool,
109 pub locator_raw: Option<&'a CitationLocator>,
111 pub citation_number: usize,
113 pub position: Option<citum_schema::citation::Position>,
115 pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
117 pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
119 pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
121 pub first_reference_note_number: Option<u32>,
123}
124
125struct UngroupedItemRenderState<'a> {
127 reference: &'a Reference,
128 template: Cow<'a, [TemplateComponent]>,
129 delimiter: &'a str,
130}
131
132#[derive(Clone, Copy)]
134struct UngroupedItemRenderParams<'a> {
135 mode: &'a citum_schema::citation::CitationMode,
136 suppress_author: bool,
137 position: Option<&'a citum_schema::citation::Position>,
138 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
139}
140
141#[derive(Clone, Default)]
142struct TemplateComponentTracker {
143 rendered_vars: HashSet<String>,
144 substituted_bases: HashSet<String>,
145}
146
147impl TemplateComponentTracker {
148 fn should_skip(&self, var_key: Option<&str>) -> bool {
149 let Some(var_key) = var_key else {
150 return false;
151 };
152 let base = key_base(var_key);
153 self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
154 }
155
156 fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
157 if let Some(var_key) = var_key {
158 self.rendered_vars.insert(var_key);
159 }
160 if let Some(substituted_key) = substituted_key {
161 self.rendered_vars.insert(substituted_key.to_string());
162 self.substituted_bases
163 .insert(key_base(substituted_key).into_owned());
164 }
165 }
166
167 fn merge_from(&mut self, other: Self) {
168 self.rendered_vars.extend(other.rendered_vars);
169 self.substituted_bases.extend(other.substituted_bases);
170 }
171}
172
173pub struct RendererResources<'a> {
178 pub style: &'a citum_schema::Style,
180 pub bibliography: &'a Bibliography,
182 pub locale: &'a Locale,
184 pub config: Rc<Config>,
186 pub bibliography_config: Option<Rc<BibliographyConfig>>,
188 pub first_note_by_id: Option<&'a RefCell<HashMap<String, u32>>>,
190}
191
192impl<'a> Renderer<'a> {
193 pub fn new(
195 resources: RendererResources<'a>,
196 hints: &'a HashMap<String, ProcHints>,
197 citation_numbers: &'a RefCell<HashMap<String, usize>>,
198 compound: CompoundRenderData<'a>,
199 show_semantics: bool,
200 inject_ast_indices: bool,
201 abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
202 ) -> Self {
203 Self {
204 style: resources.style,
205 bibliography: resources.bibliography,
206 locale: resources.locale,
207 config: resources.config,
208 bibliography_config: resources.bibliography_config,
209 hints,
210 citation_numbers,
211 compound_set_by_ref: compound.set_by_ref,
212 compound_member_index: compound.member_index,
213 compound_sets: compound.sets,
214 show_semantics,
215 inject_ast_indices,
216 filtered_to_original_index: RefCell::new(None),
217 abbreviation_map,
218 first_note_by_id: resources.first_note_by_id,
219 }
220 }
221
222 fn resolve_contributor_names(
224 &self,
225 contributor: &citum_schema::reference::contributor::Contributor,
226 ) -> Vec<crate::reference::FlatName> {
227 let ml = self.config.multilingual.as_ref();
228 crate::values::resolve_multilingual_name(
229 contributor,
230 ml.and_then(|m| m.name_mode.as_ref()),
231 ml.and_then(|m| m.preferred_transliteration.as_deref()),
232 ml.and_then(|m| m.preferred_script.as_ref()),
233 &self.locale.locale,
234 )
235 }
236
237 fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
240 let compound = self
241 .bibliography_config
242 .as_ref()
243 .and_then(|b| b.compound_numeric.as_ref())?;
244 let set_id = self.compound_set_by_ref.get(ref_id)?;
245 let members = self.compound_sets.get(set_id)?;
246 if members.len() <= 1 {
247 return None;
248 }
249 if !compound.subentry {
250 return None;
251 }
252 let idx = *self.compound_member_index.get(ref_id)?;
253 match compound.sub_label {
254 citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
255 crate::values::int_to_letter((idx + 1) as u32)
256 }
257 citum_schema::options::bibliography::SubLabelStyle::Numeric => {
258 Some((idx + 1).to_string())
259 }
260 }
261 }
262
263 fn should_render_author_number_for_numeric_integral(
269 &self,
270 mode: &citum_schema::citation::CitationMode,
271 ) -> bool {
272 matches!(mode, citum_schema::citation::CitationMode::Integral)
273 && self.config.processing.as_ref().is_some_and(|processing| {
274 matches!(processing, citum_schema::options::Processing::Numeric)
275 })
276 && !self.has_explicit_integral_template()
277 }
278
279 fn has_explicit_integral_template(&self) -> bool {
281 self.style.citation.as_ref().is_some_and(|c| {
282 c.integral.as_ref().is_some_and(|i| {
283 i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
284 })
285 })
286 }
287
288 fn should_collapse_compound_subentries(
290 &self,
291 mode: &citum_schema::citation::CitationMode,
292 ) -> bool {
293 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
294 return false;
295 }
296
297 self.bibliography_config
298 .as_ref()
299 .and_then(|b| b.compound_numeric.as_ref())
300 .is_some_and(|c| c.subentry && c.collapse_subentries)
301 }
302
303 fn should_collapse_citation_numbers(
305 &self,
306 spec: &citum_schema::CitationSpec,
307 mode: &citum_schema::citation::CitationMode,
308 ) -> bool {
309 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
310 return false;
311 }
312
313 let is_numeric = self
314 .config
315 .processing
316 .as_ref()
317 .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
318
319 is_numeric
320 && matches!(
321 spec.collapse,
322 Some(citum_schema::CitationCollapse::CitationNumber)
323 )
324 }
325
326 fn normalize_prefix_spacing(prefix: &str) -> String {
328 if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
329 format!("{prefix} ")
330 } else {
331 prefix.to_string()
332 }
333 }
334
335 fn ensure_suffix_spacing(suffix: &str) -> String {
338 if suffix.is_empty() {
339 String::new()
340 } else if suffix.starts_with(char::is_whitespace)
341 || suffix.starts_with(',')
342 || suffix.starts_with(';')
343 || suffix.starts_with('.')
344 {
345 suffix.to_string()
347 } else {
348 format!(" {suffix}")
350 }
351 }
352
353 fn affix_content<F>(
355 &self,
356 fmt: &F,
357 content: String,
358 prefix: Option<&str>,
359 suffix: Option<&str>,
360 ) -> String
361 where
362 F: crate::render::format::OutputFormat<Output = String>,
363 {
364 let prefix = prefix.unwrap_or("");
365 let suffix = suffix.unwrap_or("");
366 if prefix.is_empty() && suffix.is_empty() {
367 content
368 } else {
369 fmt.affix(
370 &Self::normalize_prefix_spacing(prefix),
371 content,
372 &Self::ensure_suffix_spacing(suffix),
373 )
374 }
375 }
376
377 fn build_citation_chunk<F>(
379 &self,
380 fmt: &F,
381 ids: Vec<String>,
382 content: String,
383 prefix: Option<&str>,
384 suffix: Option<&str>,
385 ) -> Option<(Vec<String>, String)>
386 where
387 F: crate::render::format::OutputFormat<Output = String>,
388 {
389 if content.is_empty() {
390 None
391 } else {
392 Some((ids, self.affix_content(fmt, content, prefix, suffix)))
393 }
394 }
395
396 fn build_item_chunk<F>(
398 &self,
399 fmt: &F,
400 item: &crate::reference::CitationItem,
401 content: String,
402 ) -> Option<(Vec<String>, String)>
403 where
404 F: crate::render::format::OutputFormat<Output = String>,
405 {
406 self.build_citation_chunk(
407 fmt,
408 vec![item.id.clone()],
409 content,
410 item.prefix.as_deref(),
411 item.suffix.as_deref(),
412 )
413 }
414
415 fn citation_render_request<'b>(
417 &self,
418 item: &'b crate::reference::CitationItem,
419 template: &'b [TemplateComponent],
420 mode: &citum_schema::citation::CitationMode,
421 suppress_author: bool,
422 position: Option<&citum_schema::citation::Position>,
423 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
424 ) -> TemplateRenderRequest<'b> {
425 TemplateRenderRequest {
426 template,
427 context: RenderContext::Citation,
428 mode: mode.clone(),
429 suppress_author,
430 locator_raw: item.locator.as_ref(),
431 citation_number: self.get_or_assign_citation_number(&item.id),
432 position: position.cloned(),
433 note_start_text_case,
434 integral_name_state: item.integral_name_state,
435 org_abbreviation_state: item.org_abbreviation_state,
436 first_reference_note_number: self
437 .first_note_by_id
438 .as_ref()
439 .and_then(|m| m.borrow().get(&item.id).copied()),
440 }
441 }
442
443 fn render_item_from_template_with_format<F>(
445 &self,
446 reference: &Reference,
447 request: TemplateRenderRequest<'_>,
448 delimiter: &str,
449 ) -> Option<String>
450 where
451 F: crate::render::format::OutputFormat<Output = String>,
452 {
453 self.process_template_request_with_format::<F>(reference, request)
454 .map(|proc| {
455 crate::render::citation::citation_to_string_with_format::<F>(
456 &proc,
457 None,
458 None,
459 None,
460 Some(delimiter),
461 )
462 })
463 }
464
465 fn resolve_ungrouped_item_render_state<'b>(
468 &'b self,
469 item: &'b crate::reference::CitationItem,
470 spec: &'b citum_schema::CitationSpec,
471 intra_delimiter: &'b str,
472 ) -> Result<UngroupedItemRenderState<'b>, ProcessorError> {
473 let reference = self
474 .bibliography
475 .get(&item.id)
476 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
477 let ref_type = reference.ref_type();
478 let item_language = crate::values::effective_item_language(reference);
479 let template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
480 .map(Cow::Borrowed)
481 .or_else(|| {
482 spec.resolve_template_for_language(item_language.as_deref())
483 .map(Cow::Owned)
484 })
485 .unwrap_or(Cow::Borrowed(&[]));
486
487 Ok(UngroupedItemRenderState {
488 reference,
489 template,
490 delimiter: spec.delimiter.as_deref().unwrap_or(intra_delimiter),
491 })
492 }
493
494 fn citation_render_options<'b>(
496 &'b self,
497 mode: citum_schema::citation::CitationMode,
498 suppress_author: bool,
499 locator_raw: Option<&'b CitationLocator>,
500 ref_type: Option<String>,
501 ) -> RenderOptions<'b> {
502 RenderOptions {
503 config: self.config.clone(),
504 bibliography_config: self.bibliography_config.clone(),
505 locale: self.locale,
506 context: RenderContext::Citation,
507 mode,
508 suppress_author,
509 locator_raw,
510 ref_type,
511 show_semantics: self.show_semantics,
512 current_template_index: None,
513 abbreviation_map: self.abbreviation_map,
514 }
515 }
516
517 fn render_author_number_for_numeric_integral_with_format<F>(
521 &self,
522 reference: &Reference,
523 item: &crate::reference::CitationItem,
524 citation_number: usize,
525 ) -> String
526 where
527 F: crate::render::format::OutputFormat<Output = String>,
528 {
529 let fmt = F::default();
530 let options = self.citation_render_options(
531 citum_schema::citation::CitationMode::Integral,
532 false,
533 item.locator.as_ref(),
534 Some(reference.ref_type()),
535 );
536
537 let author_part = if let Some(authors) = reference.author() {
539 let names_vec = self.resolve_contributor_names(&authors);
540 fmt.text(&crate::values::format_contributors_short(
541 &names_vec, &options,
542 ))
543 } else {
544 String::new()
545 };
546
547 let ref_id = reference.id().unwrap_or_default().to_string();
549 let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
550
551 if author_part.is_empty() {
553 format!("[{citation_number}{sub_label}]")
555 } else {
556 format!("{author_part} [{citation_number}{sub_label}]")
557 }
558 }
559
560 fn render_numeric_integral_item_chunk_with_format<F>(
562 &self,
563 fmt: &F,
564 item: &crate::reference::CitationItem,
565 ) -> Result<Option<(Vec<String>, String)>, ProcessorError>
566 where
567 F: crate::render::format::OutputFormat<Output = String>,
568 {
569 let reference = self
570 .bibliography
571 .get(&item.id)
572 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
573 let citation_number = self.get_or_assign_citation_number(&item.id);
574 let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
575 reference,
576 item,
577 citation_number,
578 );
579 Ok(self.build_item_chunk(fmt, item, item_str))
580 }
581
582 fn render_template_item_chunk_with_format<F>(
584 &self,
585 fmt: &F,
586 item: &crate::reference::CitationItem,
587 state: UngroupedItemRenderState<'_>,
588 params: UngroupedItemRenderParams<'_>,
589 ) -> Option<(Vec<String>, String)>
590 where
591 F: crate::render::format::OutputFormat<Output = String>,
592 {
593 let request = self.citation_render_request(
594 item,
595 &state.template,
596 params.mode,
597 params.suppress_author,
598 params.position,
599 params.note_start_text_case,
600 );
601 self.render_item_from_template_with_format::<F>(state.reference, request, state.delimiter)
602 .and_then(|item_str| self.build_item_chunk(fmt, item, item_str))
603 }
604
605 pub fn render_ungrouped_citation(
612 &self,
613 items: &[crate::reference::CitationItem],
614 spec: &citum_schema::CitationSpec,
615 mode: &citum_schema::citation::CitationMode,
616 intra_delimiter: &str,
617 suppress_author: bool,
618 position: Option<&citum_schema::citation::Position>,
619 ) -> Result<Vec<String>, ProcessorError> {
620 self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
621 items,
622 spec,
623 mode,
624 intra_delimiter,
625 suppress_author,
626 position,
627 spec.note_start_text_case,
628 )
629 }
630
631 #[allow(
641 clippy::too_many_arguments,
642 reason = "Ungrouped citation rendering now needs explicit note-start context."
643 )]
644 pub fn render_ungrouped_citation_with_format<F>(
645 &self,
646 items: &[crate::reference::CitationItem],
647 spec: &citum_schema::CitationSpec,
648 mode: &citum_schema::citation::CitationMode,
649 intra_delimiter: &str,
650 suppress_author: bool,
651 position: Option<&citum_schema::citation::Position>,
652 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
653 ) -> Result<Vec<String>, ProcessorError>
654 where
655 F: crate::render::format::OutputFormat<Output = String>,
656 {
657 let fmt = F::default();
658 let mut chunks: Vec<(Vec<String>, String)> = Vec::new();
659
660 let use_author_number = self.should_render_author_number_for_numeric_integral(mode);
662 let params = UngroupedItemRenderParams {
663 mode,
664 suppress_author,
665 position,
666 note_start_text_case,
667 };
668
669 for item in items {
670 let chunk = if use_author_number {
671 self.render_numeric_integral_item_chunk_with_format::<F>(&fmt, item)?
672 } else {
673 let state =
674 self.resolve_ungrouped_item_render_state(item, spec, intra_delimiter)?;
675 self.render_template_item_chunk_with_format::<F>(&fmt, item, state, params)
676 };
677
678 if let Some(chunk) = chunk {
679 chunks.push(chunk);
680 }
681 }
682
683 if self.should_collapse_compound_subentries(mode) {
684 chunks = self.collapse_compound_citation_chunks(chunks);
685 }
686 if self.should_collapse_citation_numbers(spec, mode) {
687 chunks = self.collapse_numeric_citation_chunks(chunks);
688 }
689
690 Ok(chunks
691 .into_iter()
692 .map(|(ids, content)| fmt.citation(ids, content))
693 .collect())
694 }
695}
696
697fn key_base(key: &str) -> Cow<'_, str> {
698 let mut parts = key.splitn(3, ':');
699 match (parts.next(), parts.next()) {
700 (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
701 _ => Cow::Borrowed(key),
702 }
703}
704
705#[must_use]
711pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
712 use citum_schema::template::Rendering;
713 use std::fmt::Write;
714
715 fn push_context_suffix(key: &mut String, rendering: &Rendering) {
716 match (&rendering.prefix, &rendering.suffix) {
717 (Some(prefix), Some(suffix)) => {
718 key.push(':');
719 key.push_str(prefix);
720 key.push('_');
721 key.push_str(suffix);
722 }
723 (Some(prefix), None) => {
724 key.push(':');
725 key.push_str(prefix);
726 }
727 (None, Some(suffix)) => {
728 key.push(':');
729 key.push_str(suffix);
730 }
731 (None, None) => {}
732 }
733 }
734
735 fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
736 let mut key = String::new();
737 write!(&mut key, "{kind}:{value:?}").ok()?;
738 push_context_suffix(&mut key, rendering);
739 Some(key)
740 }
741
742 match component {
743 TemplateComponent::Contributor(c) => make_key("contributor", &c.contributor, &c.rendering),
744 TemplateComponent::Date(d) => make_key("date", &d.date, &d.rendering),
745 TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
746 TemplateComponent::Title(t) => {
747 let mut key = format!("title:{:?}", t.title);
748 if let Some(form) = &t.form {
749 write!(&mut key, ":{form:?}").ok()?;
750 }
751 push_context_suffix(&mut key, &t.rendering);
752 Some(key)
753 }
754 TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
755 TemplateComponent::Group(_) => None,
756 _ => None,
757 }
758}