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