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            // Free-text number values (e.g. `edition`) honor an explicit
176            // text-case override the same way string variables do.
177            let value = if let Some(tc) = effective_rendering.text_case {
178                crate::values::text_case::apply_text_case(&value, tc)
179            } else {
180                value
181            };
182
183            // Handle label if label_form is specified
184            let prefix = if let Some(label_form) = &self.label_form {
185                resolve_number_label(
186                    &self.number,
187                    label_form,
188                    &value,
189                    self.gender.clone(),
190                    effective_rendering,
191                    options,
192                    &fmt,
193                )
194            } else {
195                None
196            };
197
198            ProcValues {
199                value,
200                prefix,
201                suffix: None,
202                url: crate::values::resolve_effective_url(
203                    self.links.as_ref(),
204                    options.config.links.as_ref(),
205                    reference,
206                    citum_schema::options::LinkAnchor::Component,
207                ),
208                substituted_key: None,
209                pre_formatted: false,
210            }
211        })
212    }
213}
214
215/// Maps a number variable to its corresponding locator type.
216///
217/// Determines which `LocatorType` corresponds to a given numeric variable,
218/// allowing proper label selection when rendering page, volume, or issue information.
219/// Returns `None` for variables with no locator equivalent (e.g. edition, version).
220#[must_use]
221pub fn number_var_to_locator_type(
222    var: &NumberVariable,
223) -> Option<citum_schema::citation::LocatorType> {
224    use citum_schema::citation::LocatorType;
225    match var {
226        NumberVariable::Volume => Some(LocatorType::Volume),
227        NumberVariable::Pages => Some(LocatorType::Page),
228        NumberVariable::ChapterNumber => Some(LocatorType::Chapter),
229        NumberVariable::NumberOfPages => Some(LocatorType::Page),
230        NumberVariable::NumberOfVolumes => Some(LocatorType::Volume),
231        NumberVariable::Number
232        | NumberVariable::DocketNumber
233        | NumberVariable::PatentNumber
234        | NumberVariable::StandardNumber
235        | NumberVariable::ReportNumber
236        | NumberVariable::PrintingNumber => Some(LocatorType::Number),
237        NumberVariable::PartNumber => Some(LocatorType::Part),
238        NumberVariable::SupplementNumber => Some(LocatorType::Supplement),
239        NumberVariable::Issue => Some(LocatorType::Issue),
240        NumberVariable::Custom(kind) => Some(LocatorType::Custom(kind.clone())),
241        _ => None,
242    }
243}
244
245/// Heuristically detect whether a locator string should use plural labeling.
246///
247/// Returns `true` if the value contains range or list separators — hyphens (`-`),
248/// en-dashes (`–`), commas (`,`), or ampersands (`&`) — indicating multiple items
249/// such as `"1-10"`, `"1, 3"`, or `"1 & 3"`.
250#[must_use]
251pub fn check_plural(value: &str, _locator_type: &citum_schema::citation::LocatorType) -> bool {
252    // Simple heuristic: if contains ranges or separators, it's plural.
253    // "1-10", "1, 3", "1 & 3"
254    value.contains('–') || value.contains('-') || value.contains(',') || value.contains('&')
255}
256
257/// Format a page range according to the specified format.
258///
259/// Formats: expanded (default), minimal, minimal-two, chicago, chicago-16.
260/// `delimiter` is the range separator (usually the locale's
261/// `page-range-delimiter`, en-dash by default; AMA and similar use a hyphen).
262#[must_use]
263pub fn format_page_range(
264    pages: &str,
265    format: Option<&citum_schema::options::PageRangeFormat>,
266    delimiter: &str,
267) -> String {
268    use citum_schema::options::PageRangeFormat;
269
270    // Normalize any en-dash separator to a plain hyphen so splitting below is
271    // delimiter-agnostic; ranges are re-joined with the configured `delimiter`.
272    let normalized = pages.replace('\u{2013}', "-");
273    let with_delimiter = || normalized.replace('-', delimiter);
274
275    // If no format specified, just apply the delimiter to the range.
276    let Some(format) = format else {
277        return with_delimiter();
278    };
279
280    let parts: Vec<&str> = normalized.split('-').collect();
281    let [start, end] = parts.as_slice() else {
282        return with_delimiter(); // Not a simple range
283    };
284    let start = start.trim();
285    let end = end.trim();
286
287    // Parse as numbers
288    let start_num: Option<u32> = start.parse().ok();
289    let end_num: Option<u32> = end.parse().ok();
290
291    match (start_num, end_num) {
292        (Some(s), Some(e)) if e > s => {
293            let formatted_end = match format {
294                PageRangeFormat::Expanded => end.to_string(),
295                PageRangeFormat::Minimal => format_minimal(start, end, 1),
296                PageRangeFormat::MinimalTwo => format_minimal(start, end, 2),
297                PageRangeFormat::Chicago | PageRangeFormat::Chicago16 => {
298                    format_chicago_page_range_end(s, e)
299                }
300                _ => end.to_string(), // Future variants: default to expanded
301            };
302            format!("{start}{delimiter}{formatted_end}")
303        }
304        _ => with_delimiter(), // Can't parse or invalid range
305    }
306}
307
308/// Minimal format: keep only differing digits, with minimum `min_digits`
309#[must_use]
310pub fn format_minimal(start: &str, end: &str, min_digits: usize) -> String {
311    let start_chars: Vec<char> = start.chars().collect();
312    let end_chars: Vec<char> = end.chars().collect();
313
314    if start_chars.len() != end_chars.len() {
315        return end.to_string();
316    }
317
318    // Find first differing position
319    let mut first_diff = 0;
320    for (i, (s, e)) in start_chars.iter().zip(end_chars.iter()).enumerate() {
321        if s != e {
322            first_diff = i;
323            break;
324        }
325    }
326
327    // Keep at least min_digits from the end
328    let keep_from = first_diff.min(end_chars.len().saturating_sub(min_digits));
329    end_chars
330        .get(keep_from..)
331        .unwrap_or_default()
332        .iter()
333        .collect()
334}
335
336/// Format the second number in a Chicago Manual of Style page range.
337#[must_use]
338fn format_chicago_page_range_end(start: u32, end: u32) -> String {
339    // Chicago rules:
340    // - start < 100: use all digits
341    // - start exact multiple of 100: use all digits
342    // - start % 100 is 1..=9: use the changed part only and trim any leading
343    //   zero from that suffix (1002–6, 505–17, 107–8)
344    // - otherwise: keep at least two digits unless more are needed to show the
345    //   changed part (321–25, 1087–89, 1496–500, 13792–803)
346
347    if start < 100 || start.is_multiple_of(100) {
348        return end.to_string();
349    }
350
351    let start_str = start.to_string();
352    let end_str = end.to_string();
353    let changed_end_part = if start % 100 <= 9 {
354        format_minimal(&start_str, &end_str, 1)
355    } else {
356        format_minimal(&start_str, &end_str, 2)
357    };
358
359    if changed_end_part.len() > 1 && changed_end_part.starts_with('0') {
360        let trimmed = changed_end_part.trim_start_matches('0');
361        if trimmed.is_empty() {
362            "0".to_string()
363        } else {
364            trimmed.to_string()
365        }
366    } else {
367        changed_end_part
368    }
369}
370
371/// Format a Chicago Manual of Style page range end.
372#[must_use]
373pub fn format_chicago(start: u32, end: u32) -> String {
374    format_chicago_page_range_end(start, end)
375}
376
377#[cfg(test)]
378#[allow(
379    clippy::unwrap_used,
380    clippy::expect_used,
381    clippy::panic,
382    clippy::indexing_slicing,
383    clippy::todo,
384    clippy::unimplemented,
385    clippy::unreachable,
386    clippy::get_unwrap,
387    reason = "Panicking is acceptable and often desired in tests."
388)]
389mod tests {
390    use super::*;
391    use citum_schema::options::PageRangeFormat;
392
393    #[test]
394    fn test_format_chicago_page_range_end() {
395        for (start, end, expected) in [
396            (3, 10, "10"),
397            (71, 72, "72"),
398            (92, 113, "113"),
399            (100, 104, "104"),
400            (600, 613, "613"),
401            (107, 108, "8"),
402            (505, 517, "17"),
403            (1002, 1006, "6"),
404            (321, 325, "25"),
405            (415, 532, "532"),
406            (1087, 1089, "89"),
407            (1496, 1500, "500"),
408            (13792, 13803, "803"),
409            (12991, 13001, "3001"),
410        ] {
411            assert_eq!(format_chicago_page_range_end(start, end), expected);
412        }
413    }
414
415    #[test]
416    fn test_format_minimal() {
417        for (start, end, min_digits, expected) in [
418            ("100", "105", 1, "5"),
419            ("100", "105", 2, "05"),
420            ("1536", "1538", 1, "8"),
421            ("1536", "1538", 2, "38"),
422            ("1536", "1538", 4, "1538"),
423            ("12", "15", 1, "5"),
424            ("12", "15", 2, "15"),
425            ("10", "150", 1, "150"),
426        ] {
427            assert_eq!(format_minimal(start, end, min_digits), expected);
428        }
429    }
430
431    #[test]
432    fn test_format_page_range() {
433        // Default en-dash delimiter.
434        let en = "\u{2013}";
435        for (input, format, expected) in [
436            ("10-15", None, "10–15"),
437            ("10–15", None, "10–15"),
438            ("321-328", None, "321–328"),
439            ("10-15", Some(PageRangeFormat::Expanded), "10–15"),
440            ("42-45", Some(PageRangeFormat::Expanded), "42–45"),
441            ("3-10", Some(PageRangeFormat::Chicago), "3–10"),
442            ("71-72", Some(PageRangeFormat::Chicago), "71–72"),
443            ("92-113", Some(PageRangeFormat::Chicago), "92–113"),
444            ("100-104", Some(PageRangeFormat::Chicago), "100–104"),
445            ("600-613", Some(PageRangeFormat::Chicago), "600–613"),
446            ("107-108", Some(PageRangeFormat::Chicago), "107–8"),
447            ("505-517", Some(PageRangeFormat::Chicago), "505–17"),
448            ("1002-1006", Some(PageRangeFormat::Chicago), "1002–6"),
449            ("321-325", Some(PageRangeFormat::Chicago), "321–25"),
450            ("415-532", Some(PageRangeFormat::Chicago), "415–532"),
451            ("1087-1089", Some(PageRangeFormat::Chicago), "1087–89"),
452            ("1496-1500", Some(PageRangeFormat::Chicago), "1496–500"),
453            ("13792-13803", Some(PageRangeFormat::Chicago), "13792–803"),
454            ("12991-13001", Some(PageRangeFormat::Chicago), "12991–3001"),
455            ("3-10", Some(PageRangeFormat::Chicago16), "3–10"),
456            ("71-72", Some(PageRangeFormat::Chicago16), "71–72"),
457            ("92-113", Some(PageRangeFormat::Chicago16), "92–113"),
458            ("100-104", Some(PageRangeFormat::Chicago16), "100–104"),
459            ("600-613", Some(PageRangeFormat::Chicago16), "600–613"),
460            ("107-108", Some(PageRangeFormat::Chicago16), "107–8"),
461            ("505-517", Some(PageRangeFormat::Chicago16), "505–17"),
462            ("1002-1006", Some(PageRangeFormat::Chicago16), "1002–6"),
463            ("321-325", Some(PageRangeFormat::Chicago16), "321–25"),
464            ("415-532", Some(PageRangeFormat::Chicago16), "415–532"),
465            ("1087-1089", Some(PageRangeFormat::Chicago16), "1087–89"),
466            ("1496-1500", Some(PageRangeFormat::Chicago16), "1496–500"),
467            ("13792-13803", Some(PageRangeFormat::Chicago16), "13792–803"),
468            (
469                "12991-13001",
470                Some(PageRangeFormat::Chicago16),
471                "12991–3001",
472            ),
473            ("100-105", Some(PageRangeFormat::Minimal), "100–5"),
474            ("321-328", Some(PageRangeFormat::Minimal), "321–8"),
475            ("42-45", Some(PageRangeFormat::Minimal), "42–5"),
476            ("12-17", Some(PageRangeFormat::Minimal), "12–7"),
477            ("100-105", Some(PageRangeFormat::MinimalTwo), "100–05"),
478            ("42-45", Some(PageRangeFormat::MinimalTwo), "42–45"),
479            ("10", Some(PageRangeFormat::Chicago), "10"),
480            ("10-5", Some(PageRangeFormat::Chicago), "10–5"),
481            ("X-Y", Some(PageRangeFormat::Chicago), "X–Y"),
482            ("10-15-20", Some(PageRangeFormat::Chicago), "10–15–20"),
483        ] {
484            assert_eq!(format_page_range(input, format.as_ref(), en), expected);
485        }
486    }
487
488    #[test]
489    fn test_format_page_range_hyphen_delimiter() {
490        // AMA-style hyphen delimiter: en-dash input is normalized to a hyphen,
491        // and range formats still apply.
492        for (input, format, expected) in [
493            ("436-444", None, "436-444"),
494            ("436–444", None, "436-444"),
495            ("321-328", Some(PageRangeFormat::Expanded), "321-328"),
496            ("321-328", Some(PageRangeFormat::Chicago), "321-28"),
497        ] {
498            assert_eq!(format_page_range(input, format.as_ref(), "-"), expected);
499        }
500    }
501
502    #[test]
503    fn test_check_plural() {
504        for (value, expected) in [
505            ("1-10", true),
506            ("1–10", true),
507            ("1, 3", true),
508            ("1 & 3", true),
509            ("1", false),
510            ("IV", false),
511        ] {
512            assert_eq!(
513                check_plural(value, &citum_schema::citation::LocatorType::Page),
514                expected
515            );
516        }
517    }
518
519    #[test]
520    fn number_var_to_locator_type_maps_printing_number() {
521        assert_eq!(
522            number_var_to_locator_type(&NumberVariable::PrintingNumber),
523            Some(citum_schema::citation::LocatorType::Number)
524        );
525    }
526
527    #[test]
528    fn number_var_to_locator_type_maps_part_number() {
529        assert_eq!(
530            number_var_to_locator_type(&NumberVariable::PartNumber),
531            Some(citum_schema::citation::LocatorType::Part)
532        );
533    }
534
535    #[test]
536    fn number_var_to_locator_type_maps_supplement_number() {
537        assert_eq!(
538            number_var_to_locator_type(&NumberVariable::SupplementNumber),
539            Some(citum_schema::citation::LocatorType::Supplement)
540        );
541    }
542}