1use crate::reference::Reference;
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_schema::locale::{DigitSystem, GeneralTerm, GrammaticalGender, MessageArgs, TermForm};
14use citum_schema::reference::ClassExtension;
15use citum_schema::template::{LabelForm, NumberForm, NumberVariable, TemplateNumber};
16
17fn 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(
72 r.application_number
73 .clone()
74 .unwrap_or_else(|| r.patent_number.clone()),
75 ),
76 _ => None,
77 },
78 NumberVariable::StandardNumber => match reference.extension() {
79 ClassExtension::Standard(r) => Some(r.standard_number.clone()),
80 _ => None,
81 },
82 NumberVariable::ReportNumber => reference.report_number(),
83 NumberVariable::PartNumber => {
84 reference.numbering_value(&citum_schema::reference::NumberingType::Part)
85 }
86 NumberVariable::SupplementNumber => {
87 reference.numbering_value(&citum_schema::reference::NumberingType::Supplement)
88 }
89 NumberVariable::PrintingNumber => {
90 reference.numbering_value(&citum_schema::reference::NumberingType::Printing)
91 }
92 NumberVariable::FirstReferenceNoteNumber => {
93 hints.first_reference_note_number.map(|n| n.to_string())
94 }
95 _ => None,
96 }
97}
98
99fn label_form_to_term_form(label_form: &LabelForm) -> TermForm {
101 match label_form {
102 LabelForm::Long => TermForm::Long,
103 LabelForm::Short => TermForm::Short,
104 LabelForm::Symbol => TermForm::Symbol,
105 }
106}
107
108fn resolve_number_label<F: crate::render::format::OutputFormat<Output = String>>(
110 number: &NumberVariable,
111 label_form: &LabelForm,
112 value: &str,
113 requested_gender: Option<GrammaticalGender>,
114 effective_rendering: &citum_schema::template::Rendering,
115 options: &RenderOptions<'_>,
116 fmt: &F,
117) -> Option<String> {
118 if let Some(locator_type) = number_var_to_locator_type(number) {
119 let plural = check_plural(value, &locator_type);
121 let term_form = label_form_to_term_form(label_form);
122
123 options
124 .locale
125 .resolved_locator_term(&locator_type, plural, &term_form, requested_gender)
126 .map(|t| {
127 let term_str = if crate::values::should_strip_periods(effective_rendering, options)
128 {
129 crate::values::strip_trailing_periods(&t)
130 } else {
131 t
132 };
133 fmt.text(&format!("{term_str} "))
134 })
135 } else {
136 None
137 }
138}
139
140#[must_use]
146fn number_var_to_general_term(var: &NumberVariable) -> Option<GeneralTerm> {
147 match var {
148 NumberVariable::Edition => Some(GeneralTerm::Edition),
149 NumberVariable::Volume => Some(GeneralTerm::Volume),
150 _ => None,
151 }
152}
153
154fn split_numeric_term(term: &str) -> (Option<String>, Option<String>) {
163 if let Some((before, after)) = term.split_once("%s") {
164 let prefix = (!before.is_empty()).then(|| before.to_string());
165 let suffix = (!after.is_empty()).then(|| after.to_string());
166 (prefix, suffix)
167 } else {
168 (None, Some(format!(" {term}")))
169 }
170}
171
172fn render_ordinal(value: String, options: &RenderOptions<'_>) -> String {
177 let Ok(count) = value.parse::<u64>() else {
178 return value;
179 };
180 let args = MessageArgs {
181 count: Some(count),
182 value: Some(&value),
183 ..MessageArgs::default()
184 };
185 options
186 .locale
187 .resolve_message("number.ordinal", &args)
188 .unwrap_or(value)
189}
190
191pub(crate) fn localize_digits(value: String, digit_system: &DigitSystem) -> String {
199 let digits = match digit_system {
200 DigitSystem::Western => return value,
201 DigitSystem::ArabicIndic => ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'],
202 DigitSystem::ExtendedArabicIndic => ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'],
203 DigitSystem::Devanagari => ['०', '१', '२', '३', '४', '५', '६', '७', '८', '९'],
204 _ => return value,
205 };
206
207 value
208 .chars()
209 .map(|ch| {
210 ch.to_digit(10)
211 .filter(|_| ch.is_ascii_digit())
212 .and_then(|digit| digits.get(digit as usize).copied())
213 .unwrap_or(ch)
214 })
215 .collect()
216}
217
218impl ComponentValues for TemplateNumber {
219 fn values<F: crate::render::format::OutputFormat<Output = String>>(
220 &self,
221 reference: &Reference,
222 hints: &ProcHints,
223 options: &RenderOptions<'_>,
224 ) -> Option<ProcValues<F::Output>> {
225 let fmt = F::default();
226
227 let value = resolve_number_value(
228 &self.number,
229 reference,
230 hints,
231 options,
232 self.show_with_locator.unwrap_or(false),
233 );
234
235 value.filter(|s| !s.is_empty()).map(|value| {
236 let effective_rendering = &self.rendering;
238
239 let value = if let Some(tc) = effective_rendering.text_case {
242 let language = reference.language();
243 crate::values::text_case::apply_text_case_with_language(
244 &value,
245 tc,
246 language.as_deref(),
247 )
248 } else {
249 value
250 };
251 let value_is_numeric = is_numeric(&value);
252
253 let value = if self.form == Some(NumberForm::Ordinal) {
254 render_ordinal(value, options)
255 } else {
256 value
257 };
258
259 let label_prefix = if let Some(label_form) = &self.label_form {
261 resolve_number_label(
262 &self.number,
263 label_form,
264 &value,
265 self.gender.clone(),
266 effective_rendering,
267 options,
268 &fmt,
269 )
270 } else {
271 None
272 };
273
274 let (numeric_prefix, numeric_suffix) = self
279 .when_numeric
280 .as_ref()
281 .filter(|_| value_is_numeric)
282 .and_then(|form| {
283 let general_term = number_var_to_general_term(&self.number)?;
284 options.locale.resolved_general_term(
285 &general_term,
286 &label_form_to_term_form(form),
287 self.gender.clone(),
288 )
289 })
290 .map(|term| split_numeric_term(&term))
291 .unwrap_or((None, None));
292
293 let prefix = match (label_prefix, numeric_prefix) {
294 (Some(label), Some(numeric)) => Some(format!("{label}{numeric}")),
295 (Some(label), None) => Some(label),
296 (None, Some(numeric)) => Some(numeric),
297 (None, None) => None,
298 };
299 let suffix = numeric_suffix;
300 let value = localize_digits(value, &options.locale.number_formats.digit_system);
301
302 ProcValues {
303 value,
304 prefix,
305 suffix,
306 url: crate::values::resolve_effective_url(
307 self.links.as_ref(),
308 options.config.links.as_ref(),
309 reference,
310 citum_schema::options::LinkAnchor::Component,
311 ),
312 substituted_key: None,
313 pre_formatted: false,
314 }
315 })
316 }
317}
318
319fn is_numeric(value: &str) -> bool {
329 let value = value.trim();
330 let mut saw_digit = false;
331 for ch in value.chars() {
332 if ch.is_ascii_digit() {
333 saw_digit = true;
334 } else if !matches!(ch, '-' | ',' | '&' | ' ') {
335 return false;
336 }
337 }
338 saw_digit
339}
340
341#[cfg(test)]
342mod is_numeric_tests {
343 use super::is_numeric;
344
345 #[test]
346 fn given_bare_digits_when_checked_then_numeric() {
347 assert!(is_numeric("2"));
348 assert!(is_numeric("510"));
349 }
350
351 #[test]
352 fn given_digit_range_or_list_when_checked_then_numeric() {
353 assert!(is_numeric("1-3"));
354 assert!(is_numeric("12, 14"));
355 }
356
357 #[test]
358 fn given_digit_embedded_in_free_text_when_checked_then_not_numeric() {
359 assert!(!is_numeric("新1版"));
360 }
361
362 #[test]
363 fn given_free_text_without_digits_when_checked_then_not_numeric() {
364 assert!(!is_numeric("修订版"));
365 assert!(!is_numeric("美国卷"));
366 }
367
368 #[test]
369 fn given_cjk_numeral_when_checked_then_not_numeric() {
370 assert!(!is_numeric("第二卷"));
373 }
374
375 #[test]
376 fn given_empty_value_when_checked_then_not_numeric() {
377 assert!(!is_numeric(""));
378 assert!(!is_numeric(" "));
379 }
380}
381
382#[cfg(test)]
383mod digit_system_tests {
384 use super::localize_digits;
385 use citum_schema::locale::DigitSystem;
386
387 #[test]
388 fn localizes_ascii_digits_for_supported_digit_systems() {
389 for (digit_system, expected) in [
390 (DigitSystem::Western, "AB-12, 34"),
391 (DigitSystem::ArabicIndic, "AB-١٢, ٣٤"),
392 (DigitSystem::ExtendedArabicIndic, "AB-۱۲, ۳۴"),
393 (DigitSystem::Devanagari, "AB-१२, ३४"),
394 ] {
395 assert_eq!(
396 localize_digits("AB-12, 34".to_string(), &digit_system),
397 expected
398 );
399 }
400 }
401
402 #[test]
403 fn preserves_western_digits_for_unknown_digit_system() {
404 assert_eq!(
405 localize_digits(
406 "12".to_string(),
407 &DigitSystem::Unknown("future".to_string())
408 ),
409 "12"
410 );
411 }
412}
413
414#[cfg(test)]
415mod split_numeric_term_tests {
416 use super::split_numeric_term;
417
418 #[test]
419 fn given_circumfix_term_when_split_then_wraps_around_marker() {
420 assert_eq!(
421 split_numeric_term("第%s卷"),
422 (Some("第".to_string()), Some("卷".to_string()))
423 );
424 }
425
426 #[test]
427 fn given_suffix_only_term_when_split_then_prefix_only_marker() {
428 assert_eq!(split_numeric_term("%s卷"), (None, Some("卷".to_string())));
430 }
431
432 #[test]
433 fn given_plain_term_when_split_then_space_separated_suffix() {
434 assert_eq!(split_numeric_term("版"), (None, Some(" 版".to_string())));
435 }
436}
437
438#[must_use]
444pub fn number_var_to_locator_type(
445 var: &NumberVariable,
446) -> Option<citum_schema::citation::LocatorType> {
447 use citum_schema::citation::LocatorType;
448 match var {
449 NumberVariable::Volume => Some(LocatorType::Volume),
450 NumberVariable::Pages => Some(LocatorType::Page),
451 NumberVariable::ChapterNumber => Some(LocatorType::Chapter),
452 NumberVariable::NumberOfPages => Some(LocatorType::Page),
453 NumberVariable::NumberOfVolumes => Some(LocatorType::Volume),
454 NumberVariable::Number
455 | NumberVariable::DocketNumber
456 | NumberVariable::PatentNumber
457 | NumberVariable::StandardNumber
458 | NumberVariable::ReportNumber
459 | NumberVariable::PrintingNumber => Some(LocatorType::Number),
460 NumberVariable::PartNumber => Some(LocatorType::Part),
461 NumberVariable::SupplementNumber => Some(LocatorType::Supplement),
462 NumberVariable::Issue => Some(LocatorType::Issue),
463 NumberVariable::Custom(kind) => Some(LocatorType::Custom(kind.clone())),
464 _ => None,
465 }
466}
467
468#[must_use]
474pub fn check_plural(value: &str, _locator_type: &citum_schema::citation::LocatorType) -> bool {
475 value.contains('–') || value.contains('-') || value.contains(',') || value.contains('&')
478}
479
480#[must_use]
486pub fn format_page_range(
487 pages: &str,
488 format: Option<&citum_schema::options::PageRangeFormat>,
489 delimiter: &str,
490) -> String {
491 use citum_schema::options::PageRangeFormat;
492
493 let normalized = pages.replace('\u{2013}', "-");
496 let with_delimiter = || normalized.replace('-', delimiter);
497
498 let Some(format) = format else {
500 return with_delimiter();
501 };
502
503 let parts: Vec<&str> = normalized.split('-').collect();
504 let [start, end] = parts.as_slice() else {
505 return with_delimiter(); };
507 let start = start.trim();
508 let end = end.trim();
509
510 let start_num: Option<u32> = start.parse().ok();
512 let end_num: Option<u32> = end.parse().ok();
513
514 match (start_num, end_num) {
515 (Some(s), Some(e)) if e > s => {
516 let formatted_end = match format {
517 PageRangeFormat::Expanded => end.to_string(),
518 PageRangeFormat::Minimal => format_minimal(start, end, 1),
519 PageRangeFormat::MinimalTwo => format_minimal(start, end, 2),
520 PageRangeFormat::Chicago | PageRangeFormat::Chicago16 => {
521 format_chicago_range_end(s, e)
522 }
523 _ => end.to_string(), };
525 format!("{start}{delimiter}{formatted_end}")
526 }
527 _ => with_delimiter(), }
529}
530
531#[must_use]
533pub fn format_minimal(start: &str, end: &str, min_digits: usize) -> String {
534 let start_chars: Vec<char> = start.chars().collect();
535 let end_chars: Vec<char> = end.chars().collect();
536
537 if start_chars.len() != end_chars.len() {
538 return end.to_string();
539 }
540
541 let mut first_diff = 0;
543 for (i, (s, e)) in start_chars.iter().zip(end_chars.iter()).enumerate() {
544 if s != e {
545 first_diff = i;
546 break;
547 }
548 }
549
550 let keep_from = first_diff.min(end_chars.len().saturating_sub(min_digits));
552 end_chars
553 .get(keep_from..)
554 .unwrap_or_default()
555 .iter()
556 .collect()
557}
558
559#[must_use]
561pub(crate) fn format_chicago_range_end(start: u32, end: u32) -> String {
562 if start < 100 || start.is_multiple_of(100) {
571 return end.to_string();
572 }
573
574 let start_str = start.to_string();
575 let end_str = end.to_string();
576 let changed_end_part = if start % 100 <= 9 {
577 format_minimal(&start_str, &end_str, 1)
578 } else {
579 format_minimal(&start_str, &end_str, 2)
580 };
581
582 if changed_end_part.len() > 1 && changed_end_part.starts_with('0') {
583 let trimmed = changed_end_part.trim_start_matches('0');
584 if trimmed.is_empty() {
585 "0".to_string()
586 } else {
587 trimmed.to_string()
588 }
589 } else {
590 changed_end_part
591 }
592}
593
594#[must_use]
596pub fn format_chicago(start: u32, end: u32) -> String {
597 format_chicago_range_end(start, end)
598}
599
600#[cfg(test)]
601#[allow(
602 clippy::unwrap_used,
603 clippy::expect_used,
604 clippy::panic,
605 clippy::indexing_slicing,
606 clippy::todo,
607 clippy::unimplemented,
608 clippy::unreachable,
609 clippy::get_unwrap,
610 reason = "Panicking is acceptable and often desired in tests."
611)]
612mod tests {
613 use super::*;
614 use citum_schema::options::PageRangeFormat;
615
616 #[test]
617 fn test_format_chicago_page_range_end() {
618 for (start, end, expected) in [
619 (3, 10, "10"),
620 (71, 72, "72"),
621 (92, 113, "113"),
622 (100, 104, "104"),
623 (600, 613, "613"),
624 (107, 108, "8"),
625 (505, 517, "17"),
626 (1002, 1006, "6"),
627 (321, 325, "25"),
628 (415, 532, "532"),
629 (1087, 1089, "89"),
630 (1496, 1500, "500"),
631 (13792, 13803, "803"),
632 (12991, 13001, "3001"),
633 ] {
634 assert_eq!(format_chicago_range_end(start, end), expected);
635 }
636 }
637
638 #[test]
639 fn test_format_minimal() {
640 for (start, end, min_digits, expected) in [
641 ("100", "105", 1, "5"),
642 ("100", "105", 2, "05"),
643 ("1536", "1538", 1, "8"),
644 ("1536", "1538", 2, "38"),
645 ("1536", "1538", 4, "1538"),
646 ("12", "15", 1, "5"),
647 ("12", "15", 2, "15"),
648 ("10", "150", 1, "150"),
649 ] {
650 assert_eq!(format_minimal(start, end, min_digits), expected);
651 }
652 }
653
654 #[test]
655 fn test_format_page_range() {
656 let en = "\u{2013}";
658 for (input, format, expected) in [
659 ("10-15", None, "10–15"),
660 ("10–15", None, "10–15"),
661 ("321-328", None, "321–328"),
662 ("10-15", Some(PageRangeFormat::Expanded), "10–15"),
663 ("42-45", Some(PageRangeFormat::Expanded), "42–45"),
664 ("3-10", Some(PageRangeFormat::Chicago), "3–10"),
665 ("71-72", Some(PageRangeFormat::Chicago), "71–72"),
666 ("92-113", Some(PageRangeFormat::Chicago), "92–113"),
667 ("100-104", Some(PageRangeFormat::Chicago), "100–104"),
668 ("600-613", Some(PageRangeFormat::Chicago), "600–613"),
669 ("107-108", Some(PageRangeFormat::Chicago), "107–8"),
670 ("505-517", Some(PageRangeFormat::Chicago), "505–17"),
671 ("1002-1006", Some(PageRangeFormat::Chicago), "1002–6"),
672 ("321-325", Some(PageRangeFormat::Chicago), "321–25"),
673 ("415-532", Some(PageRangeFormat::Chicago), "415–532"),
674 ("1087-1089", Some(PageRangeFormat::Chicago), "1087–89"),
675 ("1496-1500", Some(PageRangeFormat::Chicago), "1496–500"),
676 ("13792-13803", Some(PageRangeFormat::Chicago), "13792–803"),
677 ("12991-13001", Some(PageRangeFormat::Chicago), "12991–3001"),
678 ("3-10", Some(PageRangeFormat::Chicago16), "3–10"),
679 ("71-72", Some(PageRangeFormat::Chicago16), "71–72"),
680 ("92-113", Some(PageRangeFormat::Chicago16), "92–113"),
681 ("100-104", Some(PageRangeFormat::Chicago16), "100–104"),
682 ("600-613", Some(PageRangeFormat::Chicago16), "600–613"),
683 ("107-108", Some(PageRangeFormat::Chicago16), "107–8"),
684 ("505-517", Some(PageRangeFormat::Chicago16), "505–17"),
685 ("1002-1006", Some(PageRangeFormat::Chicago16), "1002–6"),
686 ("321-325", Some(PageRangeFormat::Chicago16), "321–25"),
687 ("415-532", Some(PageRangeFormat::Chicago16), "415–532"),
688 ("1087-1089", Some(PageRangeFormat::Chicago16), "1087–89"),
689 ("1496-1500", Some(PageRangeFormat::Chicago16), "1496–500"),
690 ("13792-13803", Some(PageRangeFormat::Chicago16), "13792–803"),
691 (
692 "12991-13001",
693 Some(PageRangeFormat::Chicago16),
694 "12991–3001",
695 ),
696 ("100-105", Some(PageRangeFormat::Minimal), "100–5"),
697 ("321-328", Some(PageRangeFormat::Minimal), "321–8"),
698 ("42-45", Some(PageRangeFormat::Minimal), "42–5"),
699 ("12-17", Some(PageRangeFormat::Minimal), "12–7"),
700 ("100-105", Some(PageRangeFormat::MinimalTwo), "100–05"),
701 ("42-45", Some(PageRangeFormat::MinimalTwo), "42–45"),
702 ("10", Some(PageRangeFormat::Chicago), "10"),
703 ("10-5", Some(PageRangeFormat::Chicago), "10–5"),
704 ("X-Y", Some(PageRangeFormat::Chicago), "X–Y"),
705 ("10-15-20", Some(PageRangeFormat::Chicago), "10–15–20"),
706 ] {
707 assert_eq!(format_page_range(input, format.as_ref(), en), expected);
708 }
709 }
710
711 #[test]
712 fn test_format_page_range_hyphen_delimiter() {
713 for (input, format, expected) in [
716 ("436-444", None, "436-444"),
717 ("436–444", None, "436-444"),
718 ("321-328", Some(PageRangeFormat::Expanded), "321-328"),
719 ("321-328", Some(PageRangeFormat::Chicago), "321-28"),
720 ] {
721 assert_eq!(format_page_range(input, format.as_ref(), "-"), expected);
722 }
723 }
724
725 #[test]
726 fn test_check_plural() {
727 for (value, expected) in [
728 ("1-10", true),
729 ("1–10", true),
730 ("1, 3", true),
731 ("1 & 3", true),
732 ("1", false),
733 ("IV", false),
734 ] {
735 assert_eq!(
736 check_plural(value, &citum_schema::citation::LocatorType::Page),
737 expected
738 );
739 }
740 }
741
742 #[test]
743 fn number_var_to_locator_type_maps_printing_number() {
744 assert_eq!(
745 number_var_to_locator_type(&NumberVariable::PrintingNumber),
746 Some(citum_schema::citation::LocatorType::Number)
747 );
748 }
749
750 #[test]
751 fn number_var_to_locator_type_maps_part_number() {
752 assert_eq!(
753 number_var_to_locator_type(&NumberVariable::PartNumber),
754 Some(citum_schema::citation::LocatorType::Part)
755 );
756 }
757
758 #[test]
759 fn number_var_to_locator_type_maps_supplement_number() {
760 assert_eq!(
761 number_var_to_locator_type(&NumberVariable::SupplementNumber),
762 Some(citum_schema::citation::LocatorType::Supplement)
763 );
764 }
765}