Skip to main content

citum_engine/values/
number.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Rendering logic for numeric variables (volume, issue, pages, citation numbers, etc.).
7//!
8//! This module handles number component rendering with support for page range formatting,
9//! edition labels, and numeric citation identifiers.
10
11use crate::reference::Reference;
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_schema::locale::{GrammaticalGender, TermForm};
14use citum_schema::reference::ClassExtension;
15use citum_schema::template::{NumberVariable, TemplateNumber};
16
17/// Resolve the raw value string for a number variable from a reference.
18fn resolve_number_value(
19    number: &NumberVariable,
20    reference: &Reference,
21    hints: &ProcHints,
22    options: &RenderOptions<'_>,
23    show_with_locator: bool,
24) -> Option<String> {
25    match number {
26        NumberVariable::Volume => reference.volume().map(|v| v.to_string()),
27        NumberVariable::Issue => reference.issue().map(|v| v.to_string()),
28        NumberVariable::Pages => {
29            let suppress = !show_with_locator
30                && options.context == crate::values::RenderContext::Citation
31                && options.locator_raw.is_some()
32                && matches!(
33                    options.config.processing,
34                    Some(citum_schema::options::Processing::Note)
35                );
36            if suppress {
37                None
38            } else {
39                reference.pages().map(|p| {
40                    let delimiter =
41                        options.config.page_range_delimiter.as_deref().unwrap_or(
42                            options.locale.grammar_options.page_range_delimiter.as_str(),
43                        );
44                    format_page_range(
45                        &p.to_string(),
46                        options.config.page_range_format.as_ref(),
47                        delimiter,
48                    )
49                })
50            }
51        }
52        NumberVariable::ChapterNumber => match reference.extension() {
53            ClassExtension::Statute(r) => r.chapter_number.clone(),
54            _ => reference.numbering_value(&citum_schema::reference::NumberingType::Chapter),
55        },
56        NumberVariable::Edition => reference.edition(),
57        NumberVariable::CollectionNumber => reference.collection_number(),
58        NumberVariable::Number => reference.number(),
59        NumberVariable::Custom(kind) => reference.numbering_value(
60            &citum_schema::reference::NumberingType::Custom(kind.clone()),
61        ),
62        NumberVariable::DocketNumber => match reference.extension() {
63            ClassExtension::Brief(r) => r.docket_number.clone(),
64            _ => None,
65        },
66        NumberVariable::PatentNumber => match reference.extension() {
67            ClassExtension::Patent(r) => Some(r.patent_number.clone()),
68            _ => None,
69        },
70        NumberVariable::StandardNumber => match reference.extension() {
71            ClassExtension::Standard(r) => Some(r.standard_number.clone()),
72            _ => None,
73        },
74        NumberVariable::ReportNumber => reference.report_number(),
75        NumberVariable::PartNumber => {
76            reference.numbering_value(&citum_schema::reference::NumberingType::Part)
77        }
78        NumberVariable::SupplementNumber => {
79            reference.numbering_value(&citum_schema::reference::NumberingType::Supplement)
80        }
81        NumberVariable::PrintingNumber => {
82            reference.numbering_value(&citum_schema::reference::NumberingType::Printing)
83        }
84        NumberVariable::FirstReferenceNoteNumber => {
85            hints.first_reference_note_number.map(|n| n.to_string())
86        }
87        NumberVariable::CitationNumber => hints.citation_number.map(|n| {
88            if options.context == crate::values::RenderContext::Citation
89                && let Some(sub_label) = &hints.citation_sub_label
90            {
91                return format!("{n}{sub_label}");
92            }
93            n.to_string()
94        }),
95        NumberVariable::CitationLabel => {
96            let Some(citum_schema::options::Processing::Label(config)) =
97                options.config.processing.as_ref()
98            else {
99                return None;
100            };
101            let params = config.effective_params();
102            let base = crate::processor::labels::generate_base_label(reference, &params);
103            if base.is_empty() {
104                return None;
105            }
106            let suffix = if hints.disamb_condition && hints.group_index > 0 {
107                crate::values::int_to_letter(hints.group_index as u32).unwrap_or_default()
108            } else {
109                String::new()
110            };
111            Some(format!("{base}{suffix}"))
112        }
113        _ => None,
114    }
115}
116
117/// Resolve a label prefix for a number variable if `label_form` is configured.
118fn resolve_number_label<F: crate::render::format::OutputFormat<Output = String>>(
119    number: &NumberVariable,
120    label_form: &citum_schema::template::LabelForm,
121    value: &str,
122    requested_gender: Option<GrammaticalGender>,
123    effective_rendering: &citum_schema::template::Rendering,
124    options: &RenderOptions<'_>,
125    fmt: &F,
126) -> Option<String> {
127    if let Some(locator_type) = number_var_to_locator_type(number) {
128        // Check pluralization
129        let plural = check_plural(value, &locator_type);
130
131        let term_form = match label_form {
132            citum_schema::template::LabelForm::Long => TermForm::Long,
133            citum_schema::template::LabelForm::Short => TermForm::Short,
134            citum_schema::template::LabelForm::Symbol => TermForm::Symbol,
135        };
136
137        options
138            .locale
139            .resolved_locator_term(&locator_type, plural, &term_form, requested_gender)
140            .map(|t| {
141                let term_str = if crate::values::should_strip_periods(effective_rendering, options)
142                {
143                    crate::values::strip_trailing_periods(&t)
144                } else {
145                    t
146                };
147                fmt.text(&format!("{term_str} "))
148            })
149    } else {
150        None
151    }
152}
153
154impl ComponentValues for TemplateNumber {
155    fn values<F: crate::render::format::OutputFormat<Output = String>>(
156        &self,
157        reference: &Reference,
158        hints: &ProcHints,
159        options: &RenderOptions<'_>,
160    ) -> Option<ProcValues<F::Output>> {
161        let fmt = F::default();
162
163        let value = resolve_number_value(
164            &self.number,
165            reference,
166            hints,
167            options,
168            self.show_with_locator.unwrap_or(false),
169        );
170
171        value.filter(|s| !s.is_empty()).map(|value| {
172            // Resolve effective rendering options
173            let effective_rendering = &self.rendering;
174
175            // Handle label if label_form is specified
176            let prefix = if let Some(label_form) = &self.label_form {
177                resolve_number_label(
178                    &self.number,
179                    label_form,
180                    &value,
181                    self.gender.clone(),
182                    effective_rendering,
183                    options,
184                    &fmt,
185                )
186            } else {
187                None
188            };
189
190            ProcValues {
191                value,
192                prefix,
193                suffix: None,
194                url: crate::values::resolve_effective_url(
195                    self.links.as_ref(),
196                    options.config.links.as_ref(),
197                    reference,
198                    citum_schema::options::LinkAnchor::Component,
199                ),
200                substituted_key: None,
201                pre_formatted: false,
202            }
203        })
204    }
205}
206
207/// Maps a number variable to its corresponding locator type.
208///
209/// Determines which `LocatorType` corresponds to a given numeric variable,
210/// allowing proper label selection when rendering page, volume, or issue information.
211/// Returns `None` for variables with no locator equivalent (e.g. edition, version).
212#[must_use]
213pub fn number_var_to_locator_type(
214    var: &NumberVariable,
215) -> Option<citum_schema::citation::LocatorType> {
216    use citum_schema::citation::LocatorType;
217    match var {
218        NumberVariable::Volume => Some(LocatorType::Volume),
219        NumberVariable::Pages => Some(LocatorType::Page),
220        NumberVariable::ChapterNumber => Some(LocatorType::Chapter),
221        NumberVariable::NumberOfPages => Some(LocatorType::Page),
222        NumberVariable::NumberOfVolumes => Some(LocatorType::Volume),
223        NumberVariable::Number
224        | NumberVariable::DocketNumber
225        | NumberVariable::PatentNumber
226        | NumberVariable::StandardNumber
227        | NumberVariable::ReportNumber
228        | NumberVariable::PrintingNumber => Some(LocatorType::Number),
229        NumberVariable::PartNumber => Some(LocatorType::Part),
230        NumberVariable::SupplementNumber => Some(LocatorType::Supplement),
231        NumberVariable::Issue => Some(LocatorType::Issue),
232        NumberVariable::Custom(kind) => Some(LocatorType::Custom(kind.clone())),
233        _ => None,
234    }
235}
236
237/// Heuristically detect whether a locator string should use plural labeling.
238///
239/// Returns `true` if the value contains range or list separators — hyphens (`-`),
240/// en-dashes (`–`), commas (`,`), or ampersands (`&`) — indicating multiple items
241/// such as `"1-10"`, `"1, 3"`, or `"1 & 3"`.
242#[must_use]
243pub fn check_plural(value: &str, _locator_type: &citum_schema::citation::LocatorType) -> bool {
244    // Simple heuristic: if contains ranges or separators, it's plural.
245    // "1-10", "1, 3", "1 & 3"
246    value.contains('–') || value.contains('-') || value.contains(',') || value.contains('&')
247}
248
249/// Format a page range according to the specified format.
250///
251/// Formats: expanded (default), minimal, minimal-two, chicago, chicago-16.
252/// `delimiter` is the range separator (usually the locale's
253/// `page-range-delimiter`, en-dash by default; AMA and similar use a hyphen).
254#[must_use]
255pub fn format_page_range(
256    pages: &str,
257    format: Option<&citum_schema::options::PageRangeFormat>,
258    delimiter: &str,
259) -> String {
260    use citum_schema::options::PageRangeFormat;
261
262    // Normalize any en-dash separator to a plain hyphen so splitting below is
263    // delimiter-agnostic; ranges are re-joined with the configured `delimiter`.
264    let normalized = pages.replace('\u{2013}', "-");
265    let with_delimiter = || normalized.replace('-', delimiter);
266
267    // If no format specified, just apply the delimiter to the range.
268    let Some(format) = format else {
269        return with_delimiter();
270    };
271
272    let parts: Vec<&str> = normalized.split('-').collect();
273    let [start, end] = parts.as_slice() else {
274        return with_delimiter(); // Not a simple range
275    };
276    let start = start.trim();
277    let end = end.trim();
278
279    // Parse as numbers
280    let start_num: Option<u32> = start.parse().ok();
281    let end_num: Option<u32> = end.parse().ok();
282
283    match (start_num, end_num) {
284        (Some(s), Some(e)) if e > s => {
285            let formatted_end = match format {
286                PageRangeFormat::Expanded => end.to_string(),
287                PageRangeFormat::Minimal => format_minimal(start, end, 1),
288                PageRangeFormat::MinimalTwo => format_minimal(start, end, 2),
289                PageRangeFormat::Chicago | PageRangeFormat::Chicago16 => format_chicago(s, e),
290                _ => end.to_string(), // Future variants: default to expanded
291            };
292            format!("{start}{delimiter}{formatted_end}")
293        }
294        _ => with_delimiter(), // Can't parse or invalid range
295    }
296}
297
298/// Minimal format: keep only differing digits, with minimum `min_digits`
299#[must_use]
300pub fn format_minimal(start: &str, end: &str, min_digits: usize) -> String {
301    let start_chars: Vec<char> = start.chars().collect();
302    let end_chars: Vec<char> = end.chars().collect();
303
304    if start_chars.len() != end_chars.len() {
305        return end.to_string();
306    }
307
308    // Find first differing position
309    let mut first_diff = 0;
310    for (i, (s, e)) in start_chars.iter().zip(end_chars.iter()).enumerate() {
311        if s != e {
312            first_diff = i;
313            break;
314        }
315    }
316
317    // Keep at least min_digits from the end
318    let keep_from = first_diff.min(end_chars.len().saturating_sub(min_digits));
319    end_chars
320        .get(keep_from..)
321        .unwrap_or_default()
322        .iter()
323        .collect()
324}
325
326/// Chicago Manual of Style page range format
327#[must_use]
328pub fn format_chicago(start: u32, end: u32) -> String {
329    // Chicago rules (simplified from CMOS 17th):
330    // - Under 100: use all digits (3–10, 71–72, 96–117)
331    // - 100+, same hundreds: use changed part only for 2+ digits (107–8, 321–28, 1536–38)
332    // - Different hundreds: use all digits (107–108, 321–328 if change of hundreds)
333
334    if start < 100 || end < 100 {
335        return end.to_string();
336    }
337
338    let start_str = start.to_string();
339    let end_str = end.to_string();
340
341    if start_str.len() != end_str.len() {
342        return end_str;
343    }
344
345    // Check if same hundreds
346    let start_prefix = start / 100;
347    let end_prefix = end / 100;
348
349    if start_prefix != end_prefix {
350        return end_str; // Different hundreds, use full number
351    }
352
353    // Same hundreds: use minimal-two style
354    format_minimal(&start_str, &end_str, 2)
355}
356
357#[cfg(test)]
358#[allow(
359    clippy::unwrap_used,
360    clippy::expect_used,
361    clippy::panic,
362    clippy::indexing_slicing,
363    clippy::todo,
364    clippy::unimplemented,
365    clippy::unreachable,
366    clippy::get_unwrap,
367    reason = "Panicking is acceptable and often desired in tests."
368)]
369mod tests {
370    use super::*;
371    use citum_schema::options::PageRangeFormat;
372
373    #[test]
374    fn test_format_chicago() {
375        for (start, end, expected) in [
376            (3, 10, "10"),
377            (71, 72, "72"),
378            (96, 117, "117"),
379            (107, 108, "08"),
380            (321, 328, "28"),
381            (1536, 1538, "38"),
382            (107, 208, "208"),
383            (321, 428, "428"),
384        ] {
385            assert_eq!(format_chicago(start, end), expected);
386        }
387    }
388
389    #[test]
390    fn test_format_minimal() {
391        for (start, end, min_digits, expected) in [
392            ("100", "105", 1, "5"),
393            ("100", "105", 2, "05"),
394            ("1536", "1538", 1, "8"),
395            ("1536", "1538", 2, "38"),
396            ("1536", "1538", 4, "1538"),
397            ("12", "15", 1, "5"),
398            ("12", "15", 2, "15"),
399            ("10", "150", 1, "150"),
400        ] {
401            assert_eq!(format_minimal(start, end, min_digits), expected);
402        }
403    }
404
405    #[test]
406    fn test_format_page_range() {
407        // Default en-dash delimiter.
408        let en = "\u{2013}";
409        for (input, format, expected) in [
410            ("10-15", None, "10–15"),
411            ("10–15", None, "10–15"),
412            ("321-328", None, "321–328"),
413            ("10-15", Some(PageRangeFormat::Expanded), "10–15"),
414            ("42-45", Some(PageRangeFormat::Expanded), "42–45"),
415            ("107-108", Some(PageRangeFormat::Chicago), "107–08"),
416            ("71-72", Some(PageRangeFormat::Chicago), "71–72"),
417            ("321-328", Some(PageRangeFormat::Chicago), "321–28"),
418            ("321-428", Some(PageRangeFormat::Chicago), "321–428"),
419            ("1536-1538", Some(PageRangeFormat::Chicago), "1536–38"),
420            ("100-105", Some(PageRangeFormat::Minimal), "100–5"),
421            ("321-328", Some(PageRangeFormat::Minimal), "321–8"),
422            ("42-45", Some(PageRangeFormat::Minimal), "42–5"),
423            ("12-17", Some(PageRangeFormat::Minimal), "12–7"),
424            ("100-105", Some(PageRangeFormat::MinimalTwo), "100–05"),
425            ("42-45", Some(PageRangeFormat::MinimalTwo), "42–45"),
426            ("10", Some(PageRangeFormat::Chicago), "10"),
427            ("10-5", Some(PageRangeFormat::Chicago), "10–5"),
428            ("X-Y", Some(PageRangeFormat::Chicago), "X–Y"),
429            ("10-15-20", Some(PageRangeFormat::Chicago), "10–15–20"),
430        ] {
431            assert_eq!(format_page_range(input, format.as_ref(), en), expected);
432        }
433    }
434
435    #[test]
436    fn test_format_page_range_hyphen_delimiter() {
437        // AMA-style hyphen delimiter: en-dash input is normalized to a hyphen,
438        // and range formats still apply.
439        for (input, format, expected) in [
440            ("436-444", None, "436-444"),
441            ("436–444", None, "436-444"),
442            ("321-328", Some(PageRangeFormat::Expanded), "321-328"),
443            ("321-328", Some(PageRangeFormat::Chicago), "321-28"),
444        ] {
445            assert_eq!(format_page_range(input, format.as_ref(), "-"), expected);
446        }
447    }
448
449    #[test]
450    fn test_check_plural() {
451        for (value, expected) in [
452            ("1-10", true),
453            ("1–10", true),
454            ("1, 3", true),
455            ("1 & 3", true),
456            ("1", false),
457            ("IV", false),
458        ] {
459            assert_eq!(
460                check_plural(value, &citum_schema::citation::LocatorType::Page),
461                expected
462            );
463        }
464    }
465
466    #[test]
467    fn number_var_to_locator_type_maps_printing_number() {
468        assert_eq!(
469            number_var_to_locator_type(&NumberVariable::PrintingNumber),
470            Some(citum_schema::citation::LocatorType::Number)
471        );
472    }
473
474    #[test]
475    fn number_var_to_locator_type_maps_part_number() {
476        assert_eq!(
477            number_var_to_locator_type(&NumberVariable::PartNumber),
478            Some(citum_schema::citation::LocatorType::Part)
479        );
480    }
481
482    #[test]
483    fn number_var_to_locator_type_maps_supplement_number() {
484        assert_eq!(
485            number_var_to_locator_type(&NumberVariable::SupplementNumber),
486            Some(citum_schema::citation::LocatorType::Supplement)
487        );
488    }
489}