Skip to main content

citum_engine/values/contributor/
names.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Name-formatting helpers for contributor rendering.
7
8use crate::values::{ProcHints, RenderOptions};
9use citum_schema::options::contributors::NameForm;
10use citum_schema::options::{
11    AndOptions, AndOtherOptions, DemoteNonDroppingParticle, DisplayAsSort, ShortenListOptions,
12};
13use citum_schema::template::{ContributorForm, NameOrder};
14use unicode_script::{Script, UnicodeScript};
15
16/// Configuration for formatting a single name.
17pub(crate) struct NameFormatContext<'a> {
18    pub(crate) display_as_sort: Option<DisplayAsSort>,
19    pub(crate) name_order: Option<&'a NameOrder>,
20    pub(crate) initialize_with: Option<&'a String>,
21    pub(crate) initialize_with_hyphen: Option<bool>,
22    pub(crate) name_form: Option<NameForm>,
23    pub(crate) demote_ndp: Option<&'a DemoteNonDroppingParticle>,
24    pub(crate) sort_separator: Option<&'a String>,
25    pub(crate) component_sort_separator: Option<&'a String>,
26    pub(crate) script_configs:
27        Option<&'a std::collections::HashMap<String, citum_schema::options::ScriptConfig>>,
28    pub(crate) integral_name_state: Option<citum_schema::citation::IntegralNameState>,
29    pub(crate) org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
30    pub(crate) use_integral_short_name: bool,
31    pub(crate) short_name_display: Option<citum_schema::options::ShortNameDisplay>,
32    pub(crate) subsequent_form: Option<citum_schema::options::SubsequentNameForm>,
33}
34
35/// Per-call template overrides passed to [`format_names`].
36///
37/// Bundles the optional override parameters that come from a
38/// `TemplateContributor` so that call sites do not need to spell out each
39/// one individually.
40pub struct NamesOverrides<'a> {
41    /// Override for name display order (given-first vs family-first).
42    pub name_order: Option<&'a NameOrder>,
43    /// Override for the sort separator (e.g. `","` or `" "`).
44    pub sort_separator: Option<&'a String>,
45    /// Override for et-al shortening options.
46    pub shorten: Option<&'a ShortenListOptions>,
47    /// Override for the "and" conjunction between names.
48    pub and: Option<&'a AndOptions>,
49    /// Override for the `initialize-with` string used to form initials.
50    pub initialize_with: Option<&'a String>,
51    /// Override for the name form (full, initials, family-only).
52    pub name_form: Option<NameForm>,
53}
54
55/// Partition names into (`first_names`, `use_et_al`, `last_names`) based on et-al options.
56fn partition_et_al<'a>(
57    names: &'a [crate::reference::FlatName],
58    shorten: Option<&'a ShortenListOptions>,
59    hints: &'a ProcHints,
60) -> (
61    Vec<&'a crate::reference::FlatName>,
62    bool,
63    Vec<&'a crate::reference::FlatName>,
64) {
65    if let Some(opts) = shorten {
66        // Determine effective min/use_first based on citation position.
67        let is_subsequent = matches!(
68            hints.position,
69            Some(
70                citum_schema::citation::Position::Subsequent
71                    | citum_schema::citation::Position::Ibid
72                    | citum_schema::citation::Position::IbidWithLocator
73            )
74        );
75        let effective_min_threshold = if is_subsequent {
76            opts.subsequent_min.unwrap_or(opts.min) as usize
77        } else {
78            opts.min as usize
79        };
80        let effective_use_first = if is_subsequent {
81            opts.subsequent_use_first.unwrap_or(opts.use_first) as usize
82        } else {
83            opts.use_first as usize
84        };
85
86        // When min_names_to_show is set (name expansion disambiguation),
87        // determine effective threshold for et-al application.
88        let effective_min = if let Some(expanded) = hints.min_names_to_show {
89            expanded.max(effective_use_first)
90        } else {
91            effective_use_first
92        };
93
94        // Apply et-al only if the list exceeds the minimum threshold
95        if names.len() >= effective_min_threshold {
96            if effective_min >= names.len() {
97                (names.iter().collect::<Vec<_>>(), false, Vec::new())
98            } else {
99                let first: Vec<&crate::reference::FlatName> =
100                    names.iter().take(effective_min).collect();
101                let last: Vec<&crate::reference::FlatName> = if let Some(ul) = opts.use_last {
102                    let take_last = ul as usize;
103                    let skip = std::cmp::max(effective_min, names.len().saturating_sub(take_last));
104                    names.iter().skip(skip).collect()
105                } else {
106                    Vec::new()
107                };
108                (first, true, last)
109            }
110        } else {
111            (names.iter().collect::<Vec<_>>(), false, Vec::new())
112        }
113    } else {
114        (names.iter().collect::<Vec<_>>(), false, Vec::new())
115    }
116}
117
118/// Join a list of formatted names with a conjunction and Oxford-comma rules.
119fn join_names_with_conjunction(
120    formatted_first: &[String],
121    and_str: Option<&str>,
122    delimiter: &str,
123    delimiter_precedes_last: Option<&citum_schema::options::DelimiterPrecedesLast>,
124    first_names_len: usize,
125    ctx: &NameFormatContext,
126    context: crate::values::RenderContext,
127) -> String {
128    use citum_schema::options::{DelimiterPrecedesLast, DisplayAsSort};
129
130    match and_str {
131        None => {
132            // No conjunction - just join all with delimiter
133            formatted_first.join(delimiter)
134        }
135        Some(conjunction) if formatted_first.len() == 2 => {
136            // For two names: citations don't use delimiter before conjunction,
137            // but bibliographies do (contextual Oxford comma).
138            let use_delimiter = if context == crate::values::RenderContext::Bibliography {
139                if matches!(ctx.name_order, Some(NameOrder::GivenFirst)) {
140                    false
141                } else {
142                    // In bibliography, check delimiter-precedes-last setting
143                    match delimiter_precedes_last {
144                        Some(DelimiterPrecedesLast::Always) => true,
145                        Some(DelimiterPrecedesLast::Never) => false,
146                        Some(DelimiterPrecedesLast::Contextual) | None => true, // Default: use comma in bibliography
147                        Some(DelimiterPrecedesLast::AfterInvertedName) => {
148                            ctx.display_as_sort.as_ref().is_some_and(|das| {
149                                matches!(das, DisplayAsSort::All | DisplayAsSort::First)
150                            })
151                        }
152                    }
153                }
154            } else {
155                // In citations, never use delimiter before conjunction for 2 names
156                false
157            };
158
159            #[allow(clippy::indexing_slicing, reason = "length checked")]
160            if use_delimiter {
161                format!(
162                    "{}{}{} {}",
163                    formatted_first[0], delimiter, conjunction, formatted_first[1]
164                )
165            } else {
166                format!(
167                    "{} {} {}",
168                    formatted_first[0], conjunction, formatted_first[1]
169                )
170            }
171        }
172        Some(conjunction) => {
173            if let Some((last, rest)) = formatted_first.split_last() {
174                // Check if delimiter should precede "and" (Oxford comma)
175                let use_delimiter = match delimiter_precedes_last {
176                    Some(DelimiterPrecedesLast::Always) => true,
177                    Some(DelimiterPrecedesLast::Never) => false,
178                    Some(DelimiterPrecedesLast::Contextual) | None => true, // Default: comma for 3+ names
179                    Some(DelimiterPrecedesLast::AfterInvertedName) => {
180                        ctx.display_as_sort.as_ref().is_some_and(|das| {
181                            matches!(das, DisplayAsSort::All)
182                                || (matches!(das, DisplayAsSort::First) && first_names_len == 1)
183                        })
184                    }
185                };
186                if use_delimiter {
187                    format!(
188                        "{}{}{} {}",
189                        rest.join(delimiter),
190                        delimiter,
191                        conjunction,
192                        last
193                    )
194                } else {
195                    format!("{} {} {}", rest.join(delimiter), conjunction, last)
196                }
197            } else {
198                String::new()
199            }
200        }
201    }
202}
203
204/// Parameters controlling et-al abbreviation formatting.
205struct EtAlContext<'a> {
206    and_others: AndOtherOptions,
207    delimiter: &'a str,
208    delimiter_precedes: Option<&'a citum_schema::options::DelimiterPrecedesLast>,
209    first_count: usize,
210}
211
212/// Apply et-al suffix or return result unchanged.
213fn apply_et_al(
214    result: String,
215    formatted_last: &[String],
216    et_al: EtAlContext<'_>,
217    ctx: &NameFormatContext,
218    locale: &citum_schema::locale::Locale,
219) -> String {
220    use citum_schema::options::DelimiterPrecedesLast;
221
222    if !formatted_last.is_empty() {
223        // et-al-use-last: result + ellipsis + last names
224        // CSL typically uses an ellipsis (...) for this.
225        return format!("{} … {}", result, formatted_last.join(et_al.delimiter));
226    }
227
228    // Determine delimiter before "et al." based on delimiter_precedes_et_al option
229    let use_delimiter = match et_al.delimiter_precedes {
230        Some(DelimiterPrecedesLast::Always) => true,
231        Some(DelimiterPrecedesLast::Never) => false,
232        Some(DelimiterPrecedesLast::AfterInvertedName) => {
233            // Use delimiter if last displayed name was inverted (family-first)
234            ctx.display_as_sort.as_ref().is_some_and(|das| {
235                matches!(das, DisplayAsSort::All)
236                    || (matches!(das, DisplayAsSort::First) && et_al.first_count == 1)
237            })
238        }
239        Some(DelimiterPrecedesLast::Contextual) | None => {
240            // Default: use delimiter only if more than one name displayed
241            et_al.first_count > 1
242        }
243    };
244
245    let and_others_term = match et_al.and_others {
246        AndOtherOptions::EtAl => locale.et_al(),
247        AndOtherOptions::Text => locale.et_al().trim_end_matches('.'),
248    };
249
250    if use_delimiter {
251        format!("{result}, {and_others_term}")
252    } else {
253        format!("{result} {and_others_term}")
254    }
255}
256
257/// Format a list of names according to style options.
258///
259/// # Panics
260///
261/// This function assumes the non-empty input check at the top remains in place;
262/// violating that invariant can trigger indexing or `unwrap()` panics in later
263/// formatting branches.
264#[must_use]
265#[allow(
266    clippy::too_many_lines,
267    reason = "linear context-building pipeline; no clean split point"
268)]
269pub fn format_names(
270    names: &[crate::reference::FlatName],
271    form: &ContributorForm,
272    options: &RenderOptions<'_>,
273    overrides: &NamesOverrides<'_>,
274    hints: &ProcHints,
275) -> String {
276    if names.is_empty() {
277        return String::new();
278    }
279
280    let config = options.config.contributors.as_ref();
281    let locale = options.locale;
282
283    // Determine shortening options:
284    // 1. Use explicit override from template (e.g. bibliography et-al)
285    // 2. Else use global config
286    let shorten = overrides
287        .shorten
288        .or_else(|| config.and_then(|c| c.shorten.as_ref()));
289
290    let and_others = shorten.map_or(AndOtherOptions::EtAl, |opts| opts.and_others);
291
292    let (first_names, use_et_al, last_names) = partition_et_al(names, shorten, hints);
293
294    // Build format context once
295    let ctx = NameFormatContext {
296        display_as_sort: config.and_then(|c| c.display_as_sort),
297        name_order: overrides.name_order,
298        initialize_with: overrides
299            .initialize_with
300            .or_else(|| config.and_then(|c| c.initialize_with.as_ref())),
301        initialize_with_hyphen: config.and_then(|c| c.initialize_with_hyphen),
302        name_form: overrides
303            .name_form
304            .or_else(|| config.and_then(|c| c.name_form)),
305        demote_ndp: config.and_then(|c| c.demote_non_dropping_particle.as_ref()),
306        sort_separator: overrides
307            .sort_separator
308            .or_else(|| config.and_then(|c| c.sort_separator.as_ref())),
309        component_sort_separator: overrides.sort_separator,
310        script_configs: options
311            .config
312            .multilingual
313            .as_ref()
314            .map(|multilingual| &multilingual.scripts),
315        integral_name_state: hints.integral_name_state,
316        org_abbreviation_state: hints.org_abbreviation_state,
317        use_integral_short_name: matches!(
318            options.mode,
319            citum_schema::citation::CitationMode::Integral
320        ),
321        short_name_display: options
322            .config
323            .org_abbreviation_memory
324            .as_ref()
325            .map(|c| c.resolve().short_name_display),
326        subsequent_form: options
327            .config
328            .integral_name_memory
329            .as_ref()
330            .map(|c| c.resolve().subsequent_form),
331    };
332
333    let delimiter = config.and_then(|c| c.delimiter.as_deref()).unwrap_or(", ");
334
335    let formatted_first: Vec<String> = first_names
336        .iter()
337        .enumerate()
338        .map(|(i, name)| {
339            let expand =
340                hints.expand_given_names && !(hints.expand_given_names_primary_only && i > 0);
341            format_single_name(name, form, i, &ctx, expand)
342        })
343        .collect();
344
345    let formatted_last: Vec<String> = last_names
346        .iter()
347        .enumerate()
348        .map(|(i, name)| {
349            let original_idx = names.len() - last_names.len() + i;
350            let expand = hints.expand_given_names
351                && !(hints.expand_given_names_primary_only && original_idx > 0);
352            format_single_name(name, form, original_idx, &ctx, expand)
353        })
354        .collect();
355
356    // Determine "and" setting: use override if provided, else global config
357    let and_option = overrides
358        .and
359        .or_else(|| config.and_then(|c| c.and.as_ref()));
360
361    // Determine conjunction between last two names
362    // Default (None or no config) means no conjunction, matching CSL behavior
363    let and_str = match and_option {
364        Some(AndOptions::Text) => Some(locale.and_term(false)),
365        Some(AndOptions::Symbol) => Some(locale.and_term(true)),
366        Some(AndOptions::None) | None => None, // No conjunction
367        _ => None,                             // Catch-all for future non_exhaustive variants
368    };
369    // When "et al." is applied, most styles expect comma-separated shown names
370    // before the abbreviation (e.g., "Smith, Jones, et al."), not a final
371    // conjunction ("Smith, Jones, and Brown, et al.").
372    let and_str = if use_et_al && formatted_last.is_empty() {
373        None
374    } else {
375        and_str
376    };
377
378    // Check if delimiter should precede last name (Oxford comma)
379    let delimiter_precedes_last = config.and_then(|c| c.delimiter_precedes_last.as_ref());
380
381    let result = if formatted_first.len() == 1 {
382        #[allow(clippy::unwrap_used, reason = "length checked")]
383        formatted_first.first().unwrap().clone()
384    } else {
385        join_names_with_conjunction(
386            &formatted_first,
387            and_str,
388            delimiter,
389            delimiter_precedes_last,
390            first_names.len(),
391            &ctx,
392            options.context,
393        )
394    };
395
396    if !use_et_al {
397        return result;
398    }
399
400    apply_et_al(
401        result,
402        &formatted_last,
403        EtAlContext {
404            and_others,
405            delimiter,
406            delimiter_precedes: config.and_then(|c| c.delimiter_precedes_et_al.as_ref()),
407            first_count: first_names.len(),
408        },
409        &ctx,
410        locale,
411    )
412}
413
414/// Initialize a given name by extracting initials.
415///
416/// Splits the given name on word separators (space, hyphen, non-breaking space),
417/// and converts each part to its first character followed by the initialize suffix.
418fn initialize_given_name(
419    given: &str,
420    initialize_with: Option<&String>,
421    initialize_with_hyphen: Option<bool>,
422) -> String {
423    let init = initialize_with.map_or(". ", std::string::String::as_str);
424    let separators = if initialize_with_hyphen == Some(false) {
425        vec![' ', '\u{00A0}'] // Non-breaking space too
426    } else {
427        vec![' ', '-', '\u{00A0}']
428    };
429
430    let mut result = String::new();
431    let mut current_part = String::new();
432
433    for c in given.chars() {
434        if separators.contains(&c) {
435            if !current_part.is_empty() {
436                if let Some(first) = current_part.chars().next() {
437                    result.push(first);
438                    result.push_str(init);
439                }
440                current_part.clear();
441            }
442            // Preserve only non-whitespace separators (e.g., hyphen for J.-P.).
443            // Strip any trailing separator space before the hyphen so we get
444            // "J.-P." rather than "J. -P." when init contains a trailing space.
445            if !c.is_whitespace() {
446                let trimmed_len = result.trim_end().len();
447                result.truncate(trimmed_len);
448                result.push(c);
449            }
450        } else {
451            current_part.push(c);
452        }
453    }
454
455    if !current_part.is_empty()
456        && let Some(first) = current_part.chars().next()
457    {
458        result.push(first);
459        result.push_str(init);
460    }
461    result.trim().to_string()
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465enum NameAssemblyOrder {
466    GivenFirst,
467    NativeFamilyFirst,
468    Inverted,
469}
470
471#[derive(Debug, Default)]
472struct NameScriptFlags {
473    has_han: bool,
474    has_hiragana: bool,
475    has_katakana: bool,
476    has_hangul: bool,
477}
478
479impl NameScriptFlags {
480    fn record(&mut self, value: &str) {
481        for ch in value.chars() {
482            match ch.script() {
483                Script::Han => self.has_han = true,
484                Script::Hiragana => self.has_hiragana = true,
485                Script::Katakana => self.has_katakana = true,
486                Script::Hangul => self.has_hangul = true,
487                _ => {}
488            }
489        }
490    }
491
492    fn cjk_script_count(&self) -> usize {
493        usize::from(self.has_han)
494            + usize::from(self.has_hiragana)
495            + usize::from(self.has_katakana)
496            + usize::from(self.has_hangul)
497    }
498
499    fn candidate_keys(&self) -> Vec<&'static str> {
500        let count = self.cjk_script_count();
501        if count == 0 {
502            return Vec::new();
503        }
504        // Mixed kana (Hiragana + Katakana, no Han/Hangul) matches "kana" before "cjk".
505        if count > 1
506            && !self.has_han
507            && !self.has_hangul
508            && (self.has_hiragana || self.has_katakana)
509        {
510            return vec!["kana", "Hrkt", "cjk"];
511        }
512        if count > 1 {
513            return vec!["cjk"];
514        }
515        if self.has_katakana {
516            return vec!["katakana", "Kana", "Hrkt", "kana", "cjk"];
517        }
518        if self.has_hiragana {
519            return vec!["hiragana", "Hira", "Hrkt", "kana", "cjk"];
520        }
521        if self.has_han {
522            return vec!["han", "Hani", "cjk"];
523        }
524        if self.has_hangul {
525            return vec!["hangul", "Hang", "cjk"];
526        }
527        Vec::new()
528    }
529}
530
531fn script_config_for_name<'a>(
532    name: &crate::reference::FlatName,
533    ctx: &'a NameFormatContext<'a>,
534) -> Option<&'a citum_schema::options::ScriptConfig> {
535    let configs = ctx.script_configs?;
536    if configs.is_empty() {
537        return None;
538    }
539
540    let mut flags = NameScriptFlags::default();
541    for part in [
542        name.family.as_deref(),
543        name.given.as_deref(),
544        name.dropping_particle.as_deref(),
545        name.non_dropping_particle.as_deref(),
546        name.suffix.as_deref(),
547    ]
548    .into_iter()
549    .flatten()
550    {
551        flags.record(part);
552    }
553
554    flags
555        .candidate_keys()
556        .into_iter()
557        .find_map(|key| configs.get(key))
558}
559
560/// Assemble a long-form name from its computed parts.
561///
562/// Inverted order uses "Family, Given"; native family-first uses family-first
563/// display order without sort punctuation.
564fn assemble_long_name(
565    family_part: String,
566    given_part: String,
567    particle_part: String,
568    suffix: &str,
569    order: NameAssemblyOrder,
570    name_part_delimiter: &str,
571    sort_separator: &str,
572) -> String {
573    match order {
574        NameAssemblyOrder::Inverted => assemble_inverted_long_name(
575            family_part,
576            given_part,
577            particle_part,
578            suffix,
579            sort_separator,
580        ),
581        NameAssemblyOrder::NativeFamilyFirst => assemble_native_family_first_long_name(
582            family_part,
583            given_part,
584            particle_part,
585            suffix,
586            name_part_delimiter,
587        ),
588        NameAssemblyOrder::GivenFirst => assemble_given_first_long_name(
589            family_part,
590            given_part,
591            particle_part,
592            suffix,
593            name_part_delimiter,
594        ),
595    }
596}
597
598fn assemble_inverted_long_name(
599    family_part: String,
600    given_part: String,
601    particle_part: String,
602    suffix: &str,
603    sort_separator: &str,
604) -> String {
605    let mut suffix_part = String::new();
606    if !given_part.is_empty() {
607        suffix_part.push_str(&given_part);
608    }
609    if !particle_part.is_empty() {
610        if !suffix_part.is_empty() {
611            suffix_part.push(' ');
612        }
613        suffix_part.push_str(&particle_part);
614    }
615    if !suffix.is_empty() {
616        if !suffix_part.is_empty() {
617            suffix_part.push(' ');
618        }
619        suffix_part.push_str(suffix);
620    }
621
622    if suffix_part.is_empty() {
623        family_part
624    } else {
625        format!("{family_part}{sort_separator}{suffix_part}")
626    }
627}
628
629fn assemble_native_family_first_long_name(
630    family_part: String,
631    given_part: String,
632    particle_part: String,
633    suffix: &str,
634    name_part_delimiter: &str,
635) -> String {
636    let mut parts = Vec::new();
637    if !family_part.is_empty() {
638        parts.push(family_part);
639    }
640    if !particle_part.is_empty() {
641        parts.push(particle_part);
642    }
643    if !given_part.is_empty() {
644        parts.push(given_part);
645    }
646    if !suffix.is_empty() {
647        parts.push(suffix.to_string());
648    }
649    parts.join(name_part_delimiter)
650}
651
652fn assemble_given_first_long_name(
653    family_part: String,
654    given_part: String,
655    particle_part: String,
656    suffix: &str,
657    name_part_delimiter: &str,
658) -> String {
659    let mut parts = Vec::new();
660    if !given_part.is_empty() {
661        parts.push(given_part);
662    }
663    if !particle_part.is_empty() {
664        parts.push(particle_part);
665    }
666    if !family_part.is_empty() {
667        if let Some(last) = parts.last_mut()
668            && last.ends_with('-')
669        {
670            last.push_str(&family_part);
671        } else {
672            parts.push(family_part);
673        }
674    }
675    if !suffix.is_empty() {
676        parts.push(suffix.to_string());
677    }
678    parts.join(name_part_delimiter)
679}
680
681fn format_literal_name(literal: &str, short: Option<&str>, ctx: &NameFormatContext) -> String {
682    if ctx.use_integral_short_name
683        && let Some(short) = short
684    {
685        match ctx.org_abbreviation_state {
686            Some(citum_schema::citation::IntegralNameState::First) => {
687                return match ctx.short_name_display {
688                    Some(citum_schema::options::ShortNameDisplay::ShortThenBracketed) => {
689                        format!("{short} [{literal}]")
690                    }
691                    Some(citum_schema::options::ShortNameDisplay::ShortThenParenthetical) => {
692                        format!("{short} ({literal})")
693                    }
694                    Some(citum_schema::options::ShortNameDisplay::FullThenBracketed) => {
695                        format!("{literal} [{short}]")
696                    }
697                    _ => format!("{literal} ({short})"),
698                };
699            }
700            Some(citum_schema::citation::IntegralNameState::Subsequent) => {
701                return short.to_string();
702            }
703            _ => {}
704        }
705    }
706    literal.to_string()
707}
708
709fn is_inverted_name_order(index: usize, ctx: &NameFormatContext) -> bool {
710    match ctx.name_order {
711        Some(NameOrder::GivenFirst) => false,
712        Some(NameOrder::FamilyFirst) => match ctx.display_as_sort {
713            Some(DisplayAsSort::First) => index == 0,
714            _ => true,
715        },
716        Some(NameOrder::FamilyFirstOnly) => index == 0,
717        None => match ctx.display_as_sort {
718            Some(DisplayAsSort::All) => true,
719            Some(DisplayAsSort::First) => index == 0,
720            _ => false,
721        },
722    }
723}
724
725fn name_assembly_order(
726    inverted: bool,
727    script_config: Option<&citum_schema::options::ScriptConfig>,
728    ctx: &NameFormatContext,
729) -> NameAssemblyOrder {
730    if inverted {
731        return NameAssemblyOrder::Inverted;
732    }
733    let native_family_first =
734        ctx.name_order.is_none() && script_config.is_some_and(|config| config.use_native_ordering);
735    if native_family_first {
736        NameAssemblyOrder::NativeFamilyFirst
737    } else {
738        NameAssemblyOrder::GivenFirst
739    }
740}
741
742fn sort_separator_for_name<'a>(
743    script_config: Option<&'a citum_schema::options::ScriptConfig>,
744    ctx: &'a NameFormatContext<'a>,
745) -> &'a str {
746    ctx.component_sort_separator
747        .map_or_else(
748            || {
749                script_config
750                    .and_then(|config| config.sort_separator.as_deref())
751                    .or_else(|| ctx.sort_separator.map(std::string::String::as_str))
752            },
753            |separator| Some(separator.as_str()),
754        )
755        .unwrap_or(", ")
756}
757
758/// Format a single name.
759pub(crate) fn format_single_name(
760    name: &crate::reference::FlatName,
761    form: &ContributorForm,
762    index: usize,
763    ctx: &NameFormatContext,
764    expand_given_names: bool,
765) -> String {
766    fn join_particle_family(particle: &str, family: &str) -> String {
767        if particle.ends_with('-') {
768            format!("{particle}{family}")
769        } else {
770            format!("{particle} {family}")
771        }
772    }
773
774    // Handle literal names (e.g., corporate authors)
775    if let Some(literal) = &name.literal {
776        return format_literal_name(literal, name.short_name.as_deref(), ctx);
777    }
778
779    let family = name.family.as_deref().unwrap_or("");
780    let given = name.given.as_deref().unwrap_or("");
781    let dp = name.dropping_particle.as_deref().unwrap_or("");
782    let ndp = name.non_dropping_particle.as_deref().unwrap_or("");
783    let suffix = name.suffix.as_deref().unwrap_or("");
784    let script_config = script_config_for_name(name, ctx);
785
786    // Determine if we should invert (Family, Given).
787    // `display-as-sort: first` in the config limits inversion to the first name
788    // even when the template requests `name-order: family-first` for all names.
789    let inverted = is_inverted_name_order(index, ctx);
790    let assembly_order = name_assembly_order(inverted, script_config, ctx);
791
792    // Determine effective form; integral name-memory overrides template form
793    // so first mentions render full name and subsequent mentions render short.
794    // Only applies when a memory config is active (subsequent_form is Some).
795    let effective_form = if ctx.use_integral_short_name && ctx.subsequent_form.is_some() {
796        match ctx.integral_name_state {
797            Some(citum_schema::citation::IntegralNameState::First) => &ContributorForm::Long,
798            Some(citum_schema::citation::IntegralNameState::Subsequent) => {
799                match ctx.subsequent_form {
800                    Some(citum_schema::options::SubsequentNameForm::FamilyOnly) => {
801                        &ContributorForm::FamilyOnly
802                    }
803                    _ => &ContributorForm::Short,
804                }
805            }
806            _ => {
807                if expand_given_names && matches!(form, ContributorForm::Short) {
808                    &ContributorForm::Long
809                } else {
810                    form
811                }
812            }
813        }
814    } else if expand_given_names && matches!(form, ContributorForm::Short) {
815        &ContributorForm::Long
816    } else {
817        form
818    };
819
820    match effective_form {
821        ContributorForm::FamilyOnly => {
822            // FamilyOnly form strictly outputs literally just the family name without non-dropping particles.
823            family.to_string()
824        }
825        ContributorForm::Short => {
826            // Short form usually just family name, but includes non-dropping particle
827            // e.g. "van Beethoven" (unless demoted? CSL spec says demote only affects sorting/display of full names mostly?)
828            // Spec: "demote-non-dropping-particle ... This attribute does not affect ... the short form"
829            // So for short form, we keep ndp with family.
830
831            if ndp.is_empty() {
832                family.to_string()
833            } else {
834                format!("{ndp} {family}")
835            }
836        }
837        ContributorForm::Long | ContributorForm::Verb | ContributorForm::VerbShort => {
838            // Determine parts based on demotion
839            let demote = matches!(
840                ctx.demote_ndp,
841                Some(DemoteNonDroppingParticle::DisplayAndSort)
842            );
843
844            let family_part = if !ndp.is_empty() && !demote {
845                join_particle_family(ndp, family)
846            } else {
847                family.to_string()
848            };
849
850            // Determine how to render the given name based on NameForm.
851            // initialize-with only controls the separator between initials, not whether
852            // to use initials at all. name-form controls the form.
853            let effective_name_form = match ctx.name_form {
854                Some(f) => f,
855                None => NameForm::Full,
856            };
857
858            let given_part = match effective_name_form {
859                NameForm::FamilyOnly => String::new(),
860                NameForm::Initials => {
861                    initialize_given_name(given, ctx.initialize_with, ctx.initialize_with_hyphen)
862                }
863                NameForm::Full => given.to_string(),
864            };
865
866            // Construct particle part (dropping + demoted non-dropping)
867            let mut particle_part = String::new();
868            if !dp.is_empty() {
869                particle_part.push_str(dp);
870            }
871            if demote && !ndp.is_empty() {
872                if !particle_part.is_empty() {
873                    particle_part.push(' ');
874                }
875                particle_part.push_str(ndp);
876            }
877
878            let name_part_delimiter = script_config
879                .and_then(|config| config.delimiter.as_deref())
880                .unwrap_or(" ");
881            let sep = sort_separator_for_name(script_config, ctx);
882            assemble_long_name(
883                family_part,
884                given_part,
885                particle_part,
886                suffix,
887                assembly_order,
888                name_part_delimiter,
889                sep,
890            )
891        }
892    }
893}
894
895/// Format contributors in short form for citation grouping.
896#[must_use]
897pub fn format_contributors_short(
898    names: &[crate::reference::FlatName],
899    options: &RenderOptions<'_>,
900) -> String {
901    format_names(
902        names,
903        &ContributorForm::Short,
904        options,
905        &NamesOverrides {
906            name_order: None,
907            sort_separator: None,
908            shorten: None,
909            and: None,
910            initialize_with: None,
911            name_form: None,
912        },
913        &ProcHints::default(),
914    )
915}